diff --git a/.gitignore b/.gitignore index c243514caa..7f88829e74 100644 --- a/.gitignore +++ b/.gitignore @@ -89,4 +89,5 @@ test.html .claude/ # generated env schema for Node runtime -/config/env.schema.runtime.cjs \ No newline at end of file +/config/env.schema.runtime.cjs +.githooks/ diff --git a/__tests__/components/brain/BrainMobile.test.tsx b/__tests__/components/brain/BrainMobile.test.tsx index 8d59fe9d31..dd8f6f0532 100644 --- a/__tests__/components/brain/BrainMobile.test.tsx +++ b/__tests__/components/brain/BrainMobile.test.tsx @@ -46,7 +46,7 @@ jest.mock('@/components/brain/mobile/BrainMobileWaves', () => ({ __esModule: tru jest.mock('@/components/brain/mobile/BrainMobileMessages', () => ({ __esModule: true, default: () =>
})); -jest.mock('@/components/brain/notifications/Notifications', () => ({ __esModule: true, default: () =>
})); +jest.mock('@/components/brain/notifications', () => ({ __esModule: true, default: () =>
})); jest.mock('@/components/brain/my-stream/MyStreamWaveLeaderboard', () => ({ __esModule: true, default: () =>
})); @@ -96,4 +96,3 @@ describe('BrainMobile', () => { rerender(
); }); }); - diff --git a/__tests__/components/brain/notifications/Notifications.test.tsx b/__tests__/components/brain/notifications/Notifications.test.tsx index 2878842537..d1603d2a09 100644 --- a/__tests__/components/brain/notifications/Notifications.test.tsx +++ b/__tests__/components/brain/notifications/Notifications.test.tsx @@ -94,7 +94,7 @@ jest.mock('@/contexts/TitleContext', () => ({ TitleProvider: ({ children }: { children: React.ReactNode }) => children, })); -import Notifications from '@/components/brain/notifications/Notifications'; +import Notifications from '@/components/brain/notifications'; describe('Notifications component', () => { beforeEach(() => { diff --git a/codex/STATE.md b/codex/STATE.md index 8b9b9dc206..f63efab3bd 100644 --- a/codex/STATE.md +++ b/codex/STATE.md @@ -12,6 +12,7 @@ This table is the single source of truth for active and historical tickets. Keep | TKT-0006 | Centralise media and IPFS upload orchestration | Backlog | P1 | evocoder | — | 2025-10-14 | | TKT-0007 | Stabilize group name search input | In-Progress | P0 | simo6529 | [#1540](https://github.com/6529-Collections/6529seize-frontend/pull/1540) | 2025-10-14 | | TKT-0008 | Reconcile Codex board merge conflicts | In-Progress | P1 | openai-assistant | [#1539](https://github.com/6529-Collections/6529seize-frontend/pull/1539) | 2025-10-14 | +| TKT-0009 | Refactor Brain notifications shell for modular clarity | In-Progress | P1 | simo6529 | [#1545](https://github.com/6529-Collections/6529seize-frontend/pull/1545) | 2025-10-15 | ## Usage Guidelines diff --git a/codex/tickets/TKT-0009.md b/codex/tickets/TKT-0009.md new file mode 100644 index 0000000000..d4d03f4964 --- /dev/null +++ b/codex/tickets/TKT-0009.md @@ -0,0 +1,39 @@ +--- +created: 2025-10-15 +id: TKT-0009 +owner: simo6529 +priority: P1 +status: In-Progress +title: Refactor Brain notifications shell for modular clarity +--- + +## Context + +> The `components/brain/notifications/Notifications.tsx` component currently mixes data fetching, auth handling, scroll orchestration, and UI state rendering in one file, making it difficult to maintain and reuse. We need to break it into focused pieces without changing behaviour, visuals, or types. + +## Plan + +- [x] Document proposed subcomponents, hooks, and utilities for the Notifications feature. +- [x] Extract data/side-effect orchestration into a dedicated Notifications hook layer. +- [x] Extract presentational state rendering into focused subcomponents. +- [x] Rebuild the main Notifications shell to compose the new modules with unchanged API. +- [ ] Run type-check and lint to confirm no regressions. _(Blocked by existing repository failures; see log.)_ + +## Acceptance + +- [ ] `components/brain/notifications/index.tsx` acts as the main shell and wires data/hooks → subcomponents. +- [ ] New hooks/subcomponents/utilities live under `components/brain/notifications/{hooks,subcomponents,utils}` and keep runtime behaviour identical. +- [ ] Existing imports of `Notifications` continue to work via stable exports. +- [ ] `npm run type-check` and `npm run lint` succeed after the refactor. + +## Links + +- Primary PR: [#1545](https://github.com/6529-Collections/6529seize-frontend/pull/1545) +- Follow-ups: _(reference additional tickets or TODO items)_ + +## Log + +- 2025-10-15T08:34:17Z – Created ticket and drafted refactor plan. +- 2025-10-15T08:35:49Z – Outlined modular breakdown: main shell in `index.tsx`, hook layer (`useNotificationsController`, `useNotificationsScroll`), presentational states (`NotificationsContent`, `NotificationsStateMessage`), and error utilities. +- 2025-10-15T09:44:55Z – Refactored Notifications into modular files, attempted type-check/lint (failing due to pre-existing repository issues). +- 2025-10-15T10:56:58Z – Hardened mark-all-as-read success handler against context cleanup failures. diff --git a/components/brain/notifications/Notifications.tsx b/components/brain/notifications/Notifications.tsx deleted file mode 100644 index 552de90273..0000000000 --- a/components/brain/notifications/Notifications.tsx +++ /dev/null @@ -1,677 +0,0 @@ -"use client"; - -import { - useCallback, - useContext, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; -import type { ReactNode, UIEventHandler } from "react"; -import { useSetTitle } from "@/contexts/TitleContext"; -import { AuthContext } from "@/components/auth/Auth"; -import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; -import { commonApiPostWithoutBodyAndResponse } from "@/services/api/common-api"; -import NotificationsWrapper from "./NotificationsWrapper"; -import type { TypedNotification } from "@/types/feed.types"; -import { useMutation } from "@tanstack/react-query"; -import MyStreamNoItems from "../my-stream/layout/MyStreamNoItems"; -import { useRouter, useSearchParams, usePathname } from "next/navigation"; -import { ActiveDropState } from "@/types/dropInteractionTypes"; -import { useNotificationsQuery } from "@/hooks/useNotificationsQuery"; -import { useNotificationsContext } from "@/components/notifications/NotificationsContext"; -import { useLayout } from "../my-stream/layout/LayoutContext"; -import NotificationsCauseFilter, { - NotificationFilter, -} from "./NotificationsCauseFilter"; -import SpinnerLoader from "@/components/common/SpinnerLoader"; -import { NEAR_TOP_SCROLL_THRESHOLD_PX } from "../constants"; - -const STICK_TO_BOTTOM_SCROLL_THRESHOLD_PX = 32; -const LOAD_TIMEOUT_MS = 15000; -const DEFAULT_ERROR_MESSAGE = "Failed to load notifications. Please try again."; -const LOAD_TIMEOUT_MESSAGE = - "Loading notifications is taking longer than expected. Please try again."; - -interface StateAction { - readonly label: string; - readonly handler: () => void; -} - -function renderStateMessage( - message: string, - action?: StateAction -): ReactNode { - return ( -
-

{message}

- {action ? ( - - ) : null} -
- ); -} - -interface NotificationsContentParams { - readonly isLoadingProfile: boolean; - readonly hasConnectedProfile: boolean; - readonly hasProfileHandle: boolean; - readonly showProxyDisabledState: boolean; - readonly showErrorState: boolean; - readonly resolvedErrorMessage: string; - readonly handleRetry: () => void; - readonly handleAuthRetry: () => void; - readonly handleProxyDisable: () => void; - readonly showLoader: boolean; - readonly showNoItems: boolean; - readonly items: TypedNotification[]; - readonly loadingOlder: boolean; - readonly activeDrop: ActiveDropState | null; - readonly setActiveDrop: (activeDrop: ActiveDropState | null) => void; -} - -function resolveNotificationsContent({ - isLoadingProfile, - hasConnectedProfile, - hasProfileHandle, - showProxyDisabledState, - showErrorState, - resolvedErrorMessage, - handleRetry, - handleAuthRetry, - handleProxyDisable, - showLoader, - showNoItems, - items, - loadingOlder, - activeDrop, - setActiveDrop, -}: NotificationsContentParams): ReactNode { - if (isLoadingProfile) { - return ( -
- -
- ); - } - - if (!hasConnectedProfile) { - return renderStateMessage("Connect your wallet to view notifications.", { - label: "Reconnect wallet", - handler: handleAuthRetry, - }); - } - - if (!hasProfileHandle) { - return renderStateMessage( - "We couldn't determine your profile handle. Please reconnect to continue.", - { label: "Reconnect wallet", handler: handleAuthRetry } - ); - } - - if (showProxyDisabledState) { - return renderStateMessage( - "Notifications are not available while you are using a profile proxy.", - { label: "Switch to primary profile", handler: handleProxyDisable } - ); - } - - if (showErrorState) { - return renderStateMessage(resolvedErrorMessage, { - label: "Try again", - handler: handleRetry, - }); - } - - if (showLoader) { - return ( -
- -
- ); - } - - if (showNoItems) { - return ( -
- -
- ); - } - - return ( - - ); -} - -interface NotificationsProps { - readonly activeDrop: ActiveDropState | null; - readonly setActiveDrop: (activeDrop: ActiveDropState | null) => void; -} - -const getErrorDetails = (error: unknown) => { - const status = - (error as any)?.status ?? - (error as any)?.response?.status ?? - (error as any)?.cause?.status; - - if (error instanceof Error) { - const message = error.message?.trim() || DEFAULT_ERROR_MESSAGE; - return { - message, - isUnauthorized: status === 401 || /unauthorized/i.test(message), - }; - } - - if (typeof error === "string") { - const message = error.trim() || DEFAULT_ERROR_MESSAGE; - return { - message, - isUnauthorized: status === 401 || /unauthorized/i.test(message), - }; - } - - return { - message: DEFAULT_ERROR_MESSAGE, - isUnauthorized: status === 401, - }; -}; - -export default function Notifications({ activeDrop, setActiveDrop }: NotificationsProps) { - const { - connectedProfile, - activeProfileProxy, - fetchingProfile, - requestAuth, - setToast, - setActiveProfileProxy, - } = useContext(AuthContext); - const scrollContainerRef = useRef(null); - const hasInitializedScrollRef = useRef(false); - const isPinnedToBottomRef = useRef(true); - const hasMarkedAllAsReadRef = useRef(false); - const isPrependingRef = useRef(false); - const previousScrollHeightRef = useRef(0); - const errorToastShownRef = useRef(false); - const reauthTriggeredRef = useRef(false); - const timeoutToastShownRef = useRef(false); - const lastErrorMessageRef = useRef(null); - const { notificationsViewStyle } = useLayout(); - const searchParams = useSearchParams(); - - const [activeFilter, setActiveFilter] = useState( - null - ); - const [hasTimedOut, setHasTimedOut] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - - const { removeAllDeliveredNotifications } = useNotificationsContext(); - - const router = useRouter(); - const pathname = usePathname(); - const reload = searchParams?.get('reload') ?? undefined; - const isAuthenticated = !!connectedProfile?.handle && !activeProfileProxy; - const isLoadingProfile = fetchingProfile && !connectedProfile; - const hasConnectedProfile = !!connectedProfile; - const hasProfileHandle = !!connectedProfile?.handle; - - useSetTitle("Notifications | My Stream | Brain"); - - const { invalidateNotifications } = useContext(ReactQueryWrapperContext); - - const { mutateAsync: markAllAsRead } = useMutation({ - mutationFn: async () => - await commonApiPostWithoutBodyAndResponse({ - endpoint: `notifications/read`, - }), - onSuccess: async () => { - invalidateNotifications(); - await removeAllDeliveredNotifications(); - }, - onError: (error) => { - setToast({ - message: - error instanceof Error ? error.message : String(error), - type: "error", - }); - }, - }); - - useEffect(() => { - if (!isAuthenticated) { - return; - } - if (reload === "true" || hasMarkedAllAsReadRef.current) { - return; - } - - hasMarkedAllAsReadRef.current = true; - markAllAsRead().catch((error) => { - console.error("Failed to mark notifications as read:", error); - }); - }, [markAllAsRead, reload, isAuthenticated]); - - useEffect(() => { - if (!isAuthenticated) { - hasMarkedAllAsReadRef.current = false; - } - }, [isAuthenticated]); - - const { - items, - isFetching, - isFetchingNextPage, - hasNextPage, - fetchNextPage, - refetch, - isInitialQueryDone, - isSuccess, - error: queryError, - } = useNotificationsQuery({ - identity: isAuthenticated ? connectedProfile?.handle : undefined, - activeProfileProxy: !!activeProfileProxy, - limit: "30", - reverse: true, - cause: activeFilter?.cause, - }); - - useEffect(() => { - if (reload !== "true") { - return; - } - - const clearReloadParam = () => { - const params = new URLSearchParams(searchParams?.toString() || ""); - params.delete("reload"); - const newUrl = params.toString() - ? `${pathname}?${params.toString()}` - : pathname || "/my-stream/notifications"; - router.replace(newUrl, { scroll: false }); - }; - - if (!isAuthenticated) { - clearReloadParam(); - return; - } - - refetch() - .then(() => { - hasMarkedAllAsReadRef.current = true; - return markAllAsRead(); - }) - .catch((error) => { - console.error("Error during refetch:", error); - }) - .finally(() => { - clearReloadParam(); - }); - }, [ - reload, - refetch, - markAllAsRead, - searchParams, - pathname, - router, - isAuthenticated, - ]); - - const triggerFetchOlder = useCallback(() => { - if (!isAuthenticated) { - return; - } - if (isFetchingNextPage) { - return; - } - if (!hasNextPage) { - return; - } - const container = scrollContainerRef.current; - if (container) { - previousScrollHeightRef.current = container.scrollHeight; - } - isPrependingRef.current = true; - fetchNextPage(); - }, [isAuthenticated, isFetchingNextPage, hasNextPage, fetchNextPage]); - - useLayoutEffect(() => { - const scrollElement = scrollContainerRef.current; - if (!scrollElement) { - return; - } - - if (items.length === 0) { - return; - } - - if (!hasInitializedScrollRef.current) { - scrollElement.scrollTop = scrollElement.scrollHeight; - hasInitializedScrollRef.current = true; - isPinnedToBottomRef.current = true; - } - }, [items]); - - useEffect(() => { - if (items.length === 0) { - hasInitializedScrollRef.current = false; - isPrependingRef.current = false; - previousScrollHeightRef.current = 0; - isPinnedToBottomRef.current = true; - } - }, [items.length]); - - useEffect(() => { - hasInitializedScrollRef.current = false; - isPinnedToBottomRef.current = true; - }, [activeFilter?.cause]); - - useEffect(() => { - if (!queryError) { - setErrorMessage(null); - errorToastShownRef.current = false; - reauthTriggeredRef.current = false; - lastErrorMessageRef.current = null; - return; - } - - const { message, isUnauthorized } = getErrorDetails(queryError); - - if (lastErrorMessageRef.current !== message) { - errorToastShownRef.current = false; - reauthTriggeredRef.current = false; - lastErrorMessageRef.current = message; - } - - setErrorMessage(message); - setHasTimedOut(false); - - if (!errorToastShownRef.current) { - setToast({ message, type: "error" }); - errorToastShownRef.current = true; - } - - if (isUnauthorized && !reauthTriggeredRef.current) { - requestAuth().catch((error) => { - console.error("Failed to re-authenticate after notifications error:", error); - }); - reauthTriggeredRef.current = true; - } - }, [queryError, setToast, requestAuth]); - - useEffect(() => { - if (isSuccess) { - setHasTimedOut(false); - timeoutToastShownRef.current = false; - return; - } - - if (errorMessage || !isAuthenticated || isLoadingProfile) { - setHasTimedOut(false); - timeoutToastShownRef.current = false; - return; - } - - if (isInitialQueryDone) { - return; - } - - const timerId = globalThis.setTimeout(() => { - setHasTimedOut(true); - }, LOAD_TIMEOUT_MS); - - return () => { - globalThis.clearTimeout(timerId); - }; - }, [ - isSuccess, - errorMessage, - isAuthenticated, - isInitialQueryDone, - isLoadingProfile, - ]); - - useEffect(() => { - if (hasTimedOut) { - if (!timeoutToastShownRef.current) { - setToast({ - message: LOAD_TIMEOUT_MESSAGE, - type: "warning", - }); - timeoutToastShownRef.current = true; - } - } else { - timeoutToastShownRef.current = false; - } - }, [hasTimedOut, setToast]); - - useLayoutEffect(() => { - if (!isPrependingRef.current) { - return; - } - - const container = scrollContainerRef.current; - if (!container) { - isPrependingRef.current = false; - return; - } - - const delta = container.scrollHeight - previousScrollHeightRef.current; - if (delta !== 0) { - container.scrollTop += delta; - } - - isPrependingRef.current = false; - }, [items]); - - const handleRetry = useCallback(() => { - setHasTimedOut(false); - setErrorMessage(null); - errorToastShownRef.current = false; - reauthTriggeredRef.current = false; - lastErrorMessageRef.current = null; - refetch({ cancelRefetch: true }).catch((error) => { - console.error("Failed to retry notifications fetch:", error); - }); - }, [refetch]); - - const handleAuthRetry = useCallback(() => { - requestAuth().catch((error) => { - console.error("Failed to re-authenticate:", error); - setToast({ - message: - error instanceof Error ? error.message : DEFAULT_ERROR_MESSAGE, - type: "error", - }); - }); - }, [requestAuth, setToast]); - - const handleProxyDisable = useCallback(() => { - setActiveProfileProxy(null).catch((error) => { - console.error("Failed to switch to primary profile:", error); - setToast({ - message: - error instanceof Error - ? error.message - : "Unable to switch to primary profile. Please try again.", - type: "error", - }); - }); - }, [setActiveProfileProxy, setToast]); - - const showLoader = - isAuthenticated && - !hasTimedOut && - !errorMessage && - (!isInitialQueryDone || isFetching) && - items.length === 0; - const showNoItems = - isAuthenticated && - !errorMessage && - !hasTimedOut && - isInitialQueryDone && - !isFetching && - items.length === 0; - const showErrorState = (!!errorMessage || hasTimedOut) && items.length === 0; - const shouldEnableInfiniteScroll = - isAuthenticated && !showLoader && !showNoItems && !showErrorState; - - const showProxyDisabledState = !!activeProfileProxy; - const resolvedErrorMessage = hasTimedOut - ? LOAD_TIMEOUT_MESSAGE - : errorMessage ?? DEFAULT_ERROR_MESSAGE; - - useLayoutEffect(() => { - const scrollElement = scrollContainerRef.current; - if (!scrollElement) { - return; - } - - const observationTarget = - (scrollElement.firstElementChild as HTMLElement | null) ?? scrollElement; - - let rafId: number | null = null; - - const scheduleStickToBottom = () => { - if (!hasInitializedScrollRef.current || !isPinnedToBottomRef.current) { - return; - } - - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - - rafId = requestAnimationFrame(() => { - const container = scrollContainerRef.current; - if (!container) { - return; - } - - container.scrollTop = container.scrollHeight; - rafId = null; - }); - }; - - if (typeof ResizeObserver !== "undefined") { - const resizeObserver = new ResizeObserver(() => { - scheduleStickToBottom(); - }); - - resizeObserver.observe(observationTarget); - - return () => { - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - resizeObserver.disconnect(); - }; - } - - if (typeof MutationObserver !== "undefined") { - const mutationObserver = new MutationObserver(() => { - scheduleStickToBottom(); - }); - - mutationObserver.observe(observationTarget, { - attributes: true, - childList: true, - subtree: true, - characterData: true, - }); - - return () => { - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - mutationObserver.disconnect(); - }; - } - - return () => { - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - }; - }, [items, showLoader, showNoItems, showErrorState]); - - const handleScroll: UIEventHandler = useCallback( - (event) => { - const container = event.currentTarget; - const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - const isNearBottom = - distanceFromBottom <= STICK_TO_BOTTOM_SCROLL_THRESHOLD_PX; - - if (isNearBottom) { - isPinnedToBottomRef.current = true; - } else if (distanceFromBottom > STICK_TO_BOTTOM_SCROLL_THRESHOLD_PX) { - isPinnedToBottomRef.current = false; - } - - if (!shouldEnableInfiniteScroll) { - return; - } - - if (container.scrollTop <= NEAR_TOP_SCROLL_THRESHOLD_PX) { - if (!isFetchingNextPage && hasNextPage) { - triggerFetchOlder(); - } - } - }, - [ - shouldEnableInfiniteScroll, - isFetchingNextPage, - hasNextPage, - triggerFetchOlder, - ] - ); - - const notificationsContent = resolveNotificationsContent({ - isLoadingProfile, - hasConnectedProfile, - hasProfileHandle, - showProxyDisabledState, - showErrorState, - resolvedErrorMessage, - handleRetry, - handleAuthRetry, - handleProxyDisable, - showLoader, - showNoItems, - items, - loadingOlder: isFetchingNextPage, - activeDrop, - setActiveDrop, - }); - - return ( -
-
- {isAuthenticated ? ( - - ) : null} -
- {notificationsContent} -
-
-
- ); -} diff --git a/components/brain/notifications/NotificationsContainer.tsx b/components/brain/notifications/NotificationsContainer.tsx index 65c5026644..b3c478b862 100644 --- a/components/brain/notifications/NotificationsContainer.tsx +++ b/components/brain/notifications/NotificationsContainer.tsx @@ -5,7 +5,7 @@ import { ActiveDropState, } from "@/types/dropInteractionTypes"; import BrainContent from "../content/BrainContent"; -import Notifications from "./Notifications"; +import Notifications from "./index"; const NotificationsContainer: React.FC = () => { const [activeDrop, setActiveDrop] = useState(null); @@ -26,4 +26,4 @@ const NotificationsContainer: React.FC = () => { ); }; -export default NotificationsContainer; \ No newline at end of file +export default NotificationsContainer; diff --git a/components/brain/notifications/hooks/useNotificationsController.ts b/components/brain/notifications/hooks/useNotificationsController.ts new file mode 100644 index 0000000000..4d7965da0b --- /dev/null +++ b/components/brain/notifications/hooks/useNotificationsController.ts @@ -0,0 +1,388 @@ +"use client"; + +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { CSSProperties } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useMutation } from "@tanstack/react-query"; +import type { TypedNotification } from "@/types/feed.types"; +import type { NotificationFilter } from "../NotificationsCauseFilter"; +import { useSetTitle } from "@/contexts/TitleContext"; +import { AuthContext } from "@/components/auth/Auth"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { useNotificationsQuery } from "@/hooks/useNotificationsQuery"; +import { useNotificationsContext } from "@/components/notifications/NotificationsContext"; +import { useLayout } from "../../my-stream/layout/LayoutContext"; +import { commonApiPostWithoutBodyAndResponse } from "@/services/api/common-api"; +import { + DEFAULT_ERROR_MESSAGE, + LOAD_TIMEOUT_MESSAGE, + LOAD_TIMEOUT_MS, +} from "../utils/constants"; +import { getNotificationErrorDetails } from "../utils/getNotificationErrorDetails"; + +interface NotificationsContentState { + readonly isLoadingProfile: boolean; + readonly hasConnectedProfile: boolean; + readonly hasProfileHandle: boolean; + readonly showProxyDisabledState: boolean; + readonly showErrorState: boolean; + readonly resolvedErrorMessage: string; + readonly showLoader: boolean; + readonly showNoItems: boolean; +} + +interface NotificationsHandlers { + readonly handleRetry: () => void; + readonly handleAuthRetry: () => void; + readonly handleProxyDisable: () => void; +} + +interface NotificationsPagination { + readonly hasNextPage: boolean; + readonly fetchNextPage: () => void; +} + +interface UseNotificationsControllerResult { + readonly activeFilter: NotificationFilter | null; + readonly setActiveFilter: (filter: NotificationFilter | null) => void; + readonly isAuthenticated: boolean; + readonly notificationsViewStyle: CSSProperties; + readonly items: TypedNotification[]; + readonly isFetchingNextPage: boolean; + readonly pagination: NotificationsPagination; + readonly contentState: NotificationsContentState; + readonly handlers: NotificationsHandlers; +} + +export const useNotificationsController = + (): UseNotificationsControllerResult => { + const { + connectedProfile, + activeProfileProxy, + fetchingProfile, + requestAuth, + setToast, + setActiveProfileProxy, + } = useContext(AuthContext); + const { notificationsViewStyle } = useLayout(); + const { removeAllDeliveredNotifications } = useNotificationsContext(); + const { invalidateNotifications } = useContext(ReactQueryWrapperContext); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const hasMarkedAllAsReadRef = useRef(false); + const errorToastShownRef = useRef(false); + const reauthTriggeredRef = useRef(false); + const timeoutToastShownRef = useRef(false); + const lastErrorMessageRef = useRef(null); + + const [activeFilter, setActiveFilter] = + useState(null); + const [hasTimedOut, setHasTimedOut] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const reload = searchParams?.get("reload") ?? undefined; + const isAuthenticated = !!connectedProfile?.handle && !activeProfileProxy; + const isLoadingProfile = fetchingProfile && !connectedProfile; + const hasConnectedProfile = !!connectedProfile; + const hasProfileHandle = !!connectedProfile?.handle; + + useSetTitle("Notifications | My Stream | Brain"); + + const { mutateAsync: markAllAsRead } = useMutation({ + mutationFn: async () => + await commonApiPostWithoutBodyAndResponse({ + endpoint: `notifications/read`, + }), + onSuccess: async () => { + try { + invalidateNotifications(); + await removeAllDeliveredNotifications(); + } catch (error) { + console.error("Failed to clear delivered notifications:", error); + } + }, + onError: (error) => { + setToast({ + message: error instanceof Error ? error.message : String(error), + type: "error", + }); + }, + }); + + useEffect(() => { + if (!isAuthenticated) { + return; + } + if (reload === "true" || hasMarkedAllAsReadRef.current) { + return; + } + + hasMarkedAllAsReadRef.current = true; + markAllAsRead().catch((error) => { + console.error("Failed to mark notifications as read:", error); + }); + }, [isAuthenticated, markAllAsRead, reload]); + + useEffect(() => { + if (!isAuthenticated) { + hasMarkedAllAsReadRef.current = false; + } + }, [isAuthenticated]); + + const { + items, + isFetching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + refetch, + isInitialQueryDone, + isSuccess, + error: queryError, + } = useNotificationsQuery({ + identity: isAuthenticated ? connectedProfile?.handle : undefined, + activeProfileProxy: !!activeProfileProxy, + limit: "30", + reverse: true, + cause: activeFilter?.cause, + }); + + useEffect(() => { + if (reload !== "true") { + return; + } + + const clearReloadParam = () => { + const params = new URLSearchParams(searchParams?.toString() || ""); + params.delete("reload"); + const newUrl = params.toString() + ? `${pathname}?${params.toString()}` + : pathname || "/my-stream/notifications"; + router.replace(newUrl, { scroll: false }); + }; + + if (!isAuthenticated) { + clearReloadParam(); + return; + } + + refetch() + .then(() => { + hasMarkedAllAsReadRef.current = true; + return markAllAsRead(); + }) + .catch((error) => { + console.error("Error during refetch:", error); + }) + .finally(() => { + clearReloadParam(); + }); + }, [ + pathname, + isAuthenticated, + markAllAsRead, + refetch, + reload, + router, + searchParams, + ]); + + useEffect(() => { + if (!queryError) { + setErrorMessage(null); + setHasTimedOut(false); + errorToastShownRef.current = false; + reauthTriggeredRef.current = false; + lastErrorMessageRef.current = null; + return; + } + + const { message, isUnauthorized } = + getNotificationErrorDetails(queryError); + + if (lastErrorMessageRef.current !== message) { + errorToastShownRef.current = false; + reauthTriggeredRef.current = false; + lastErrorMessageRef.current = message; + } + + setErrorMessage(message); + setHasTimedOut(false); + + if (!errorToastShownRef.current) { + setToast({ message, type: "error" }); + errorToastShownRef.current = true; + } + + if (isUnauthorized && !reauthTriggeredRef.current) { + requestAuth().catch((error) => { + console.error( + "Failed to re-authenticate after notifications error:", + error + ); + }); + reauthTriggeredRef.current = true; + } + }, [queryError, requestAuth, setToast]); + + useEffect(() => { + if (isSuccess) { + setHasTimedOut(false); + timeoutToastShownRef.current = false; + return; + } + + if (errorMessage || !isAuthenticated || isLoadingProfile) { + setHasTimedOut(false); + timeoutToastShownRef.current = false; + return; + } + + if (isInitialQueryDone) { + return; + } + + const timerId = globalThis.setTimeout(() => { + setHasTimedOut(true); + }, LOAD_TIMEOUT_MS); + + return () => { + globalThis.clearTimeout(timerId); + }; + }, [ + errorMessage, + isAuthenticated, + isInitialQueryDone, + isLoadingProfile, + isSuccess, + ]); + + useEffect(() => { + if (hasTimedOut) { + if (!timeoutToastShownRef.current) { + setToast({ + message: LOAD_TIMEOUT_MESSAGE, + type: "warning", + }); + timeoutToastShownRef.current = true; + } + } else { + timeoutToastShownRef.current = false; + } + }, [hasTimedOut, setToast]); + + const handleRetry = useCallback(() => { + setHasTimedOut(false); + setErrorMessage(null); + errorToastShownRef.current = false; + reauthTriggeredRef.current = false; + lastErrorMessageRef.current = null; + refetch({ cancelRefetch: true }).catch((error) => { + console.error("Failed to retry notifications fetch:", error); + }); + }, [refetch]); + + const handleAuthRetry = useCallback(() => { + requestAuth().catch((error) => { + console.error("Failed to re-authenticate:", error); + setToast({ + message: + error instanceof Error ? error.message : DEFAULT_ERROR_MESSAGE, + type: "error", + }); + }); + }, [requestAuth, setToast]); + + const handleProxyDisable = useCallback(() => { + setActiveProfileProxy(null).catch((error) => { + console.error("Failed to switch to primary profile:", error); + setToast({ + message: + error instanceof Error + ? error.message + : "Unable to switch to primary profile. Please try again.", + type: "error", + }); + }); + }, [setActiveProfileProxy, setToast]); + + const showLoader = + isAuthenticated && + !hasTimedOut && + !errorMessage && + (!isInitialQueryDone || isFetching) && + items.length === 0; + const showNoItems = + isAuthenticated && + !errorMessage && + !hasTimedOut && + isInitialQueryDone && + !isFetching && + items.length === 0; + const showErrorState = (!!errorMessage || hasTimedOut) && items.length === 0; + const showProxyDisabledState = !!activeProfileProxy; + const resolvedErrorMessage = hasTimedOut + ? LOAD_TIMEOUT_MESSAGE + : errorMessage ?? DEFAULT_ERROR_MESSAGE; + + const contentState = useMemo( + () => ({ + isLoadingProfile, + hasConnectedProfile, + hasProfileHandle, + showProxyDisabledState, + showErrorState, + resolvedErrorMessage, + showLoader, + showNoItems, + }), + [ + hasConnectedProfile, + hasProfileHandle, + isLoadingProfile, + resolvedErrorMessage, + showErrorState, + showLoader, + showNoItems, + showProxyDisabledState, + ] + ); + + const handlers = useMemo( + () => ({ + handleRetry, + handleAuthRetry, + handleProxyDisable, + }), + [handleAuthRetry, handleProxyDisable, handleRetry] + ); + + const pagination = useMemo( + () => ({ + hasNextPage: !!hasNextPage, + fetchNextPage, + }), + [fetchNextPage, hasNextPage] + ); + + return { + activeFilter, + setActiveFilter, + isAuthenticated, + notificationsViewStyle, + items, + isFetchingNextPage, + pagination, + contentState, + handlers, + }; + }; diff --git a/components/brain/notifications/hooks/useNotificationsScroll.ts b/components/brain/notifications/hooks/useNotificationsScroll.ts new file mode 100644 index 0000000000..9df91cb064 --- /dev/null +++ b/components/brain/notifications/hooks/useNotificationsScroll.ts @@ -0,0 +1,234 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + type MutableRefObject, + type UIEventHandler, +} from "react"; +import type { TypedNotification } from "@/types/feed.types"; +import { NEAR_TOP_SCROLL_THRESHOLD_PX } from "../../constants"; +import { + STICK_TO_BOTTOM_SCROLL_THRESHOLD_PX, +} from "../utils/constants"; + +interface UseNotificationsScrollParams { + readonly items: TypedNotification[]; + readonly isAuthenticated: boolean; + readonly isFetchingNextPage: boolean; + readonly hasNextPage: boolean; + readonly fetchNextPage: () => void; + readonly showLoader: boolean; + readonly showNoItems: boolean; + readonly showErrorState: boolean; + readonly activeFilterKey: string; +} + +interface UseNotificationsScrollResult { + readonly scrollContainerRef: MutableRefObject; + readonly handleScroll: UIEventHandler; +} + +export const useNotificationsScroll = ({ + items, + isAuthenticated, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + showLoader, + showNoItems, + showErrorState, + activeFilterKey, +}: UseNotificationsScrollParams): UseNotificationsScrollResult => { + const scrollContainerRef = useRef(null); + const hasInitializedScrollRef = useRef(false); + const isPinnedToBottomRef = useRef(true); + const isPrependingRef = useRef(false); + const previousScrollHeightRef = useRef(0); + + const shouldEnableInfiniteScroll = + isAuthenticated && !showLoader && !showNoItems && !showErrorState; + + const triggerFetchOlder = useCallback(() => { + if (!isAuthenticated) { + return; + } + if (isFetchingNextPage) { + return; + } + if (!hasNextPage) { + return; + } + const container = scrollContainerRef.current; + if (container) { + previousScrollHeightRef.current = container.scrollHeight; + } + isPrependingRef.current = true; + fetchNextPage(); + }, [fetchNextPage, hasNextPage, isAuthenticated, isFetchingNextPage]); + + useLayoutEffect(() => { + const scrollElement = scrollContainerRef.current; + if (!scrollElement) { + return; + } + + if (items.length === 0) { + return; + } + + if (!hasInitializedScrollRef.current) { + scrollElement.scrollTop = scrollElement.scrollHeight; + hasInitializedScrollRef.current = true; + isPinnedToBottomRef.current = true; + } + }, [items]); + + useEffect(() => { + if (items.length === 0) { + hasInitializedScrollRef.current = false; + isPrependingRef.current = false; + previousScrollHeightRef.current = 0; + isPinnedToBottomRef.current = true; + } + }, [items.length]); + + useEffect(() => { + hasInitializedScrollRef.current = false; + isPrependingRef.current = false; + isPinnedToBottomRef.current = true; + }, [activeFilterKey]); + + useLayoutEffect(() => { + if (!isPrependingRef.current) { + return; + } + + const container = scrollContainerRef.current; + if (!container) { + isPrependingRef.current = false; + return; + } + + const delta = container.scrollHeight - previousScrollHeightRef.current; + if (delta !== 0) { + container.scrollTop += delta; + } + + isPrependingRef.current = false; + }, [items]); + + useLayoutEffect(() => { + const scrollElement = scrollContainerRef.current; + if (!scrollElement) { + return; + } + + const observationTarget = + (scrollElement.firstElementChild as HTMLElement | null) ?? scrollElement; + + let rafId: number | null = null; + + const scheduleStickToBottom = () => { + if (!hasInitializedScrollRef.current || !isPinnedToBottomRef.current) { + return; + } + + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + + rafId = requestAnimationFrame(() => { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + container.scrollTop = container.scrollHeight; + rafId = null; + }); + }; + + if (typeof ResizeObserver !== "undefined") { + const resizeObserver = new ResizeObserver(() => { + scheduleStickToBottom(); + }); + + resizeObserver.observe(observationTarget); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + resizeObserver.disconnect(); + }; + } + + if (typeof MutationObserver !== "undefined") { + const mutationObserver = new MutationObserver(() => { + scheduleStickToBottom(); + }); + + mutationObserver.observe(observationTarget, { + attributes: true, + childList: true, + subtree: true, + characterData: true, + }); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + mutationObserver.disconnect(); + }; + } + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + }, [items, showErrorState, showLoader, showNoItems]); + + const handleScroll: UIEventHandler = useCallback( + (event) => { + const container = event.currentTarget; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + const isNearBottom = + distanceFromBottom <= STICK_TO_BOTTOM_SCROLL_THRESHOLD_PX; + + if (isNearBottom) { + isPinnedToBottomRef.current = true; + } else if ( + distanceFromBottom > STICK_TO_BOTTOM_SCROLL_THRESHOLD_PX + ) { + isPinnedToBottomRef.current = false; + } + + if (!shouldEnableInfiniteScroll) { + return; + } + + if (container.scrollTop <= NEAR_TOP_SCROLL_THRESHOLD_PX) { + if (!isFetchingNextPage && hasNextPage) { + triggerFetchOlder(); + } + } + }, + [ + hasNextPage, + isFetchingNextPage, + shouldEnableInfiniteScroll, + triggerFetchOlder, + ] + ); + + return { + scrollContainerRef, + handleScroll, + }; +}; diff --git a/components/brain/notifications/index.tsx b/components/brain/notifications/index.tsx new file mode 100644 index 0000000000..5b8bb89b9b --- /dev/null +++ b/components/brain/notifications/index.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useMemo } from "react"; +import { ApiNotificationCause } from "@/generated/models/ApiNotificationCause"; +import type { ActiveDropState } from "@/types/dropInteractionTypes"; +import NotificationsCauseFilter from "./NotificationsCauseFilter"; +import { useNotificationsController } from "./hooks/useNotificationsController"; +import { useNotificationsScroll } from "./hooks/useNotificationsScroll"; +import NotificationsContent from "./subcomponents/NotificationsContent"; + +interface NotificationsProps { + readonly activeDrop: ActiveDropState | null; + readonly setActiveDrop: (activeDrop: ActiveDropState | null) => void; +} + +const NOTIFICATION_CAUSE_PRIORITY: Record = { + [ApiNotificationCause.IdentitySubscribed]: 0, + [ApiNotificationCause.IdentityMentioned]: 1, + [ApiNotificationCause.DropQuoted]: 2, + [ApiNotificationCause.DropReplied]: 3, + [ApiNotificationCause.DropVoted]: 4, + [ApiNotificationCause.DropReacted]: 5, + [ApiNotificationCause.WaveCreated]: 6, + [ApiNotificationCause.AllDrops]: 7, +}; + +const compareNotificationCause = ( + firstCause: ApiNotificationCause, + secondCause: ApiNotificationCause, +): number => + NOTIFICATION_CAUSE_PRIORITY[firstCause] - + NOTIFICATION_CAUSE_PRIORITY[secondCause]; + +export default function Notifications({ + activeDrop, + setActiveDrop, +}: NotificationsProps) { + const { + activeFilter, + setActiveFilter, + isAuthenticated, + notificationsViewStyle, + items, + isFetchingNextPage, + pagination, + contentState, + handlers, + } = useNotificationsController(); + + const activeFilterKey = useMemo( + () => + activeFilter?.cause + ? [...activeFilter.cause].sort(compareNotificationCause).join("|") + : "notifications-filter-all", + [activeFilter], + ); + + const { scrollContainerRef, handleScroll } = useNotificationsScroll({ + items, + isAuthenticated, + isFetchingNextPage, + hasNextPage: pagination.hasNextPage, + fetchNextPage: pagination.fetchNextPage, + showLoader: contentState.showLoader, + showNoItems: contentState.showNoItems, + showErrorState: contentState.showErrorState, + activeFilterKey, + }); + + return ( +
+
+ {isAuthenticated ? ( + + ) : null} +
+ +
+
+
+ ); +} + +export type { NotificationsProps }; diff --git a/components/brain/notifications/subcomponents/NotificationsContent.tsx b/components/brain/notifications/subcomponents/NotificationsContent.tsx new file mode 100644 index 0000000000..caa839b9aa --- /dev/null +++ b/components/brain/notifications/subcomponents/NotificationsContent.tsx @@ -0,0 +1,114 @@ +"use client"; + +import type { ReactNode } from "react"; +import MyStreamNoItems from "../../my-stream/layout/MyStreamNoItems"; +import NotificationsWrapper from "../NotificationsWrapper"; +import SpinnerLoader from "@/components/common/SpinnerLoader"; +import type { TypedNotification } from "@/types/feed.types"; +import type { ActiveDropState } from "@/types/dropInteractionTypes"; +import NotificationsStateMessage from "./NotificationsStateMessage"; + +interface NotificationsContentProps { + readonly isLoadingProfile: boolean; + readonly hasConnectedProfile: boolean; + readonly hasProfileHandle: boolean; + readonly showProxyDisabledState: boolean; + readonly showErrorState: boolean; + readonly resolvedErrorMessage: string; + readonly handleRetry: () => void; + readonly handleAuthRetry: () => void; + readonly handleProxyDisable: () => void; + readonly showLoader: boolean; + readonly showNoItems: boolean; + readonly items: TypedNotification[]; + readonly loadingOlder: boolean; + readonly activeDrop: ActiveDropState | null; + readonly setActiveDrop: (activeDrop: ActiveDropState | null) => void; +} + +export default function NotificationsContent({ + isLoadingProfile, + hasConnectedProfile, + hasProfileHandle, + showProxyDisabledState, + showErrorState, + resolvedErrorMessage, + handleRetry, + handleAuthRetry, + handleProxyDisable, + showLoader, + showNoItems, + items, + loadingOlder, + activeDrop, + setActiveDrop, +}: NotificationsContentProps): ReactNode { + if (isLoadingProfile) { + return ( +
+ +
+ ); + } + + if (!hasConnectedProfile) { + return ( + + ); + } + + if (!hasProfileHandle) { + return ( + + ); + } + + if (showProxyDisabledState) { + return ( + + ); + } + + if (showErrorState) { + return ( + + ); + } + + if (showLoader) { + return ( +
+ +
+ ); + } + + if (showNoItems) { + return ( +
+ +
+ ); + } + + return ( + + ); +} diff --git a/components/brain/notifications/subcomponents/NotificationsStateMessage.tsx b/components/brain/notifications/subcomponents/NotificationsStateMessage.tsx new file mode 100644 index 0000000000..73565bc030 --- /dev/null +++ b/components/brain/notifications/subcomponents/NotificationsStateMessage.tsx @@ -0,0 +1,32 @@ +"use client"; + +import type { ReactNode } from "react"; + +interface NotificationsStateAction { + readonly label: string; + readonly handler: () => void; +} + +interface NotificationsStateMessageProps { + readonly message: string; + readonly action?: NotificationsStateAction; +} + +export default function NotificationsStateMessage({ + message, + action, +}: NotificationsStateMessageProps): ReactNode { + return ( +
+

{message}

+ {action ? ( + + ) : null} +
+ ); +} diff --git a/components/brain/notifications/utils/constants.ts b/components/brain/notifications/utils/constants.ts new file mode 100644 index 0000000000..35c2f46176 --- /dev/null +++ b/components/brain/notifications/utils/constants.ts @@ -0,0 +1,6 @@ +export const STICK_TO_BOTTOM_SCROLL_THRESHOLD_PX = 32; +export const LOAD_TIMEOUT_MS = 15000; +export const DEFAULT_ERROR_MESSAGE = + "Failed to load notifications. Please try again."; +export const LOAD_TIMEOUT_MESSAGE = + "Loading notifications is taking longer than expected. Please try again."; diff --git a/components/brain/notifications/utils/getNotificationErrorDetails.ts b/components/brain/notifications/utils/getNotificationErrorDetails.ts new file mode 100644 index 0000000000..dbf29b4c85 --- /dev/null +++ b/components/brain/notifications/utils/getNotificationErrorDetails.ts @@ -0,0 +1,36 @@ +import { DEFAULT_ERROR_MESSAGE } from "./constants"; + +interface NotificationErrorDetails { + readonly message: string; + readonly isUnauthorized: boolean; +} + +export const getNotificationErrorDetails = ( + error: unknown +): NotificationErrorDetails => { + const status = + (error as { status?: number })?.status ?? + (error as { response?: { status?: number } })?.response?.status ?? + (error as { cause?: { status?: number } })?.cause?.status; + + if (error instanceof Error) { + const message = error.message?.trim() || DEFAULT_ERROR_MESSAGE; + return { + message, + isUnauthorized: status === 401 || /unauthorized/i.test(message), + }; + } + + if (typeof error === "string") { + const message = error.trim() || DEFAULT_ERROR_MESSAGE; + return { + message, + isUnauthorized: status === 401 || /unauthorized/i.test(message), + }; + } + + return { + message: DEFAULT_ERROR_MESSAGE, + isUnauthorized: status === 401, + }; +};