diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9a266247..98c224ba 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -794,6 +794,7 @@ "empty_description": "You're all caught up! Check back later for new updates.", "empty_filter_description": "No notifications match this filter.", "filter_all": "All", + "filter_unread": "Unread", "filter_friends": "Friends", "filter_badges": "Badges", "filter_upvotes": "Upvotes", diff --git a/src/renderer/src/pages/notifications/notifications.scss b/src/renderer/src/pages/notifications/notifications.scss index c8fa7c3f..20fbc343 100644 --- a/src/renderer/src/pages/notifications/notifications.scss +++ b/src/renderer/src/pages/notifications/notifications.scss @@ -8,6 +8,72 @@ width: 100%; max-width: 800px; margin: 0 auto; + min-height: calc(100vh - 200px); + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + } + + &__filter-tabs { + display: flex; + gap: globals.$spacing-unit; + position: relative; + flex: 1; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + &__tab-wrapper { + position: relative; + } + + &__tab { + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: color ease 0.2s; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.5); + + &:hover { + color: rgba(255, 255, 255, 0.8); + } + + &--active { + color: white; + } + } + + &__tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + background-color: rgba(255, 255, 255, 0.15); + border-radius: 6px; + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + line-height: 20px; + } + + &__tab-underline { + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: white; + } &__actions { display: flex; @@ -15,6 +81,12 @@ justify-content: flex-end; } + &__content-wrapper { + display: flex; + flex-direction: column; + flex: 1; + } + &__list { display: flex; flex-direction: column; @@ -23,14 +95,22 @@ &__empty { display: flex; + flex: 1; width: 100%; - height: 100%; justify-content: center; align-items: center; flex-direction: column; gap: globals.$spacing-unit; } + &__empty-filter { + display: flex; + justify-content: center; + align-items: center; + padding: calc(globals.$spacing-unit * 6); + color: globals.$body-color; + } + &__icon-container { width: 60px; height: 60px; diff --git a/src/renderer/src/pages/notifications/notifications.tsx b/src/renderer/src/pages/notifications/notifications.tsx index f9bd0b46..f1c2d4de 100644 --- a/src/renderer/src/pages/notifications/notifications.tsx +++ b/src/renderer/src/pages/notifications/notifications.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BellIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import { AnimatePresence, motion } from "framer-motion"; @@ -18,6 +18,11 @@ import type { } from "@types"; import "./notifications.scss"; +type NotificationFilter = "all" | "unread"; + +const STAGGER_DELAY_MS = 70; +const EXIT_DURATION_MS = 250; + export default function Notifications() { const { t, i18n } = useTranslation("notifications_page"); const { showSuccessToast, showErrorToast } = useToast(); @@ -34,12 +39,14 @@ export default function Notifications() { >([]); const [badges, setBadges] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [clearingIds, setClearingIds] = useState>(new Set()); + const [isClearing, setIsClearing] = useState(false); + const [filter, setFilter] = useState("all"); const [pagination, setPagination] = useState({ total: 0, hasMore: false, skip: 0, }); + const clearingTimeoutsRef = useRef([]); const fetchLocalNotifications = useCallback(async () => { try { @@ -65,7 +72,11 @@ export default function Notifications() { }, [i18n.language]); const fetchApiNotifications = useCallback( - async (skip = 0, append = false) => { + async ( + skip = 0, + append = false, + filterParam: NotificationFilter = "all" + ) => { if (!userDetails) return; try { @@ -74,7 +85,7 @@ export default function Notifications() { await window.electron.hydraApi.get( "/profile/notifications", { - params: { filter: "all", take: 20, skip }, + params: { filter: filterParam, take: 20, skip }, needsAuth: true, } ); @@ -101,24 +112,24 @@ export default function Notifications() { [userDetails] ); - const fetchAllNotifications = useCallback(async () => { - setIsLoading(true); - await Promise.all([ - fetchLocalNotifications(), - fetchBadges(), - userDetails ? fetchApiNotifications(0, false) : Promise.resolve(), - ]); - setIsLoading(false); - }, [ - fetchLocalNotifications, - fetchBadges, - fetchApiNotifications, - userDetails, - ]); + const fetchAllNotifications = useCallback( + async (filterParam: NotificationFilter = "all") => { + setIsLoading(true); + await Promise.all([ + fetchLocalNotifications(), + fetchBadges(), + userDetails + ? fetchApiNotifications(0, false, filterParam) + : Promise.resolve(), + ]); + setIsLoading(false); + }, + [fetchLocalNotifications, fetchBadges, fetchApiNotifications, userDetails] + ); useEffect(() => { - fetchAllNotifications(); - }, [fetchAllNotifications]); + fetchAllNotifications(filter); + }, [fetchAllNotifications, filter]); useEffect(() => { const unsubscribe = window.electron.onLocalNotificationCreated( @@ -130,6 +141,13 @@ export default function Notifications() { return () => unsubscribe(); }, []); + // Cleanup timeouts on unmount + useEffect(() => { + return () => { + clearingTimeoutsRef.current.forEach(clearTimeout); + }; + }, []); + const mergedNotifications = useMemo(() => { const sortByDate = (a: MergedNotification, b: MergedNotification) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); @@ -144,23 +162,28 @@ export default function Notifications() { .filter((n) => n.priority !== 1) .map((n) => ({ ...n, source: "api" as const })); - const localWithSource: MergedNotification[] = localNotifications.map( - (n) => ({ + // Filter local notifications based on current filter + const filteredLocalNotifications = + filter === "unread" + ? localNotifications.filter((n) => !n.isRead) + : localNotifications; + + const localWithSource: MergedNotification[] = + filteredLocalNotifications.map((n) => ({ ...n, source: "local" as const, - }) - ); + })); const lowPriority = [...lowPriorityApi, ...localWithSource].sort( sortByDate ); return [...highPriority, ...lowPriority]; - }, [apiNotifications, localNotifications]); + }, [apiNotifications, localNotifications, filter]); const displayedNotifications = useMemo(() => { - return mergedNotifications.filter((n) => !clearingIds.has(n.id)); - }, [mergedNotifications, clearingIds]); + return mergedNotifications; + }, [mergedNotifications]); const notifyCountChange = useCallback(() => { window.dispatchEvent(new CustomEvent("notificationsChanged")); @@ -251,42 +274,86 @@ export default function Notifications() { [showErrorToast, t, notifyCountChange] ); + const removeNotificationFromState = useCallback( + (notification: MergedNotification) => { + if (notification.source === "api") { + setApiNotifications((prev) => + prev.filter((n) => n.id !== notification.id) + ); + } else { + setLocalNotifications((prev) => + prev.filter((n) => n.id !== notification.id) + ); + } + }, + [] + ); + + const removeNotificationWithDelay = useCallback( + (notification: MergedNotification, delayMs: number): Promise => { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + removeNotificationFromState(notification); + resolve(); + }, delayMs); + + clearingTimeoutsRef.current.push(timeout); + }); + }, + [removeNotificationFromState] + ); + const handleClearAll = useCallback(async () => { + if (isClearing) return; + try { - // Mark all as clearing for animation - const allIds = new Set([ - ...apiNotifications.map((n) => n.id), - ...localNotifications.map((n) => n.id), - ]); - setClearingIds(allIds); + setIsClearing(true); - // Wait for exit animation - await new Promise((resolve) => setTimeout(resolve, 300)); + // Clear any existing timeouts + clearingTimeoutsRef.current.forEach(clearTimeout); + clearingTimeoutsRef.current = []; - // Clear all API notifications - if (userDetails && apiNotifications.length > 0) { + // Snapshot current notifications for staggered removal + const notificationsToRemove = [...displayedNotifications]; + const totalNotifications = notificationsToRemove.length; + + if (totalNotifications === 0) { + setIsClearing(false); + return; + } + + // Remove items one by one with staggered delays for visual effect + const removalPromises = notificationsToRemove.map((notification, index) => + removeNotificationWithDelay(notification, index * STAGGER_DELAY_MS) + ); + + // Wait for all items to be removed from state + await Promise.all(removalPromises); + + // Wait for the last exit animation to complete + await new Promise((resolve) => setTimeout(resolve, EXIT_DURATION_MS)); + + // Perform actual backend deletions (state is already cleared by staggered removal) + if (userDetails) { await window.electron.hydraApi.delete(`/profile/notifications/all`, { needsAuth: true, }); - setApiNotifications([]); } - - // Clear all local notifications await window.electron.clearAllLocalNotifications(); - setLocalNotifications([]); - - setClearingIds(new Set()); setPagination({ total: 0, hasMore: false, skip: 0 }); notifyCountChange(); showSuccessToast(t("cleared_all")); } catch (error) { logger.error("Failed to clear all notifications", error); - setClearingIds(new Set()); showErrorToast(t("failed_to_clear")); + } finally { + setIsClearing(false); + clearingTimeoutsRef.current = []; } }, [ - apiNotifications, - localNotifications, + displayedNotifications, + isClearing, + removeNotificationWithDelay, userDetails, showSuccessToast, showErrorToast, @@ -296,9 +363,19 @@ export default function Notifications() { const handleLoadMore = useCallback(() => { if (pagination.hasMore && !isLoading) { - fetchApiNotifications(pagination.skip, true); + fetchApiNotifications(pagination.skip, true, filter); } - }, [pagination, isLoading, fetchApiNotifications]); + }, [pagination, isLoading, fetchApiNotifications, filter]); + + const handleFilterChange = useCallback( + (newFilter: NotificationFilter) => { + if (newFilter !== filter) { + setFilter(newFilter); + setPagination({ total: 0, hasMore: false, skip: 0 }); + } + }, + [filter] + ); const handleAcceptFriendRequest = useCallback(() => { showSuccessToast(t("friend_request_accepted")); @@ -317,10 +394,13 @@ export default function Notifications() { return ( {notification.source === "local" ? ( @@ -343,8 +423,57 @@ export default function Notifications() { ); }; + const unreadCount = useMemo(() => { + const apiUnread = apiNotifications.filter((n) => !n.isRead).length; + const localUnread = localNotifications.filter((n) => !n.isRead).length; + return apiUnread + localUnread; + }, [apiNotifications, localNotifications]); + + const renderFilterTabs = () => ( +
+
+ + {filter === "all" && ( + + )} +
+
+ + {filter === "unread" && ( + + )} +
+
+ ); + + const hasNoNotifications = mergedNotifications.length === 0; + const shouldDisableActions = isClearing || hasNoNotifications; + const renderContent = () => { - if (isLoading && mergedNotifications.length === 0) { + if (isLoading && hasNoNotifications) { return (
{t("loading")} @@ -352,36 +481,61 @@ export default function Notifications() { ); } - if (mergedNotifications.length === 0) { - return ( -
-
- -
-

{t("empty_title")}

-

{t("empty_description")}

-
- ); - } - return (
-
- - +
+ {renderFilterTabs()} +
+ + +
-
- - {displayedNotifications.map(renderNotification)} - -
+ {/* Keep AnimatePresence mounted during clearing to preserve exit animations */} + + + {hasNoNotifications && !isClearing ? ( +
+
+ +
+

{t("empty_title")}

+

+ {filter === "unread" + ? t("empty_filter_description") + : t("empty_description")} +

+
+ ) : ( +
+ + {displayedNotifications.map(renderNotification)} + +
+ )} +
+
- {pagination.hasMore && ( + {pagination.hasMore && !isClearing && (