diff --git a/__tests__/components/brain/notifications/Notifications.test.tsx b/__tests__/components/brain/notifications/Notifications.test.tsx index 20c70d76be..2878842537 100644 --- a/__tests__/components/brain/notifications/Notifications.test.tsx +++ b/__tests__/components/brain/notifications/Notifications.test.tsx @@ -2,6 +2,9 @@ import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; const mutateAsyncMock = jest.fn(); +const requestAuthMock = jest.fn().mockResolvedValue({ success: true }); +const setActiveProfileProxyMock = jest.fn().mockResolvedValue(undefined); +const setToastMock = jest.fn(); jest.mock('@tanstack/react-query', () => ({ useMutation: () => ({ mutateAsync: mutateAsyncMock }), @@ -19,11 +22,19 @@ jest.mock('@/components/auth/Auth', () => { const React = require('react'); return { AuthContext: React.createContext({ - connectedProfile: { handle: 'bob' }, - activeProfileProxy: false, - setToast: jest.fn(), + connectedProfile: { handle: 'bob', id: '1' }, + activeProfileProxy: null, + fetchingProfile: false, + requestAuth: requestAuthMock, + setToast: setToastMock, + setActiveProfileProxy: setActiveProfileProxyMock, + }), + useAuth: () => ({ + setTitle: setTitleMock, + requestAuth: requestAuthMock, + setToast: setToastMock, + setActiveProfileProxy: setActiveProfileProxyMock, }), - useAuth: () => ({ setTitle: setTitleMock }), }; }); @@ -91,6 +102,11 @@ describe('Notifications component', () => { mutateAsyncMock.mockResolvedValue(undefined); useNotificationsQueryMock.mockReset(); setTitleMock.mockClear(); + requestAuthMock.mockClear(); + requestAuthMock.mockResolvedValue({ success: true }); + setActiveProfileProxyMock.mockClear(); + setActiveProfileProxyMock.mockResolvedValue(undefined); + setToastMock.mockClear(); }); it('shows loader when fetching and no items', async () => { @@ -99,9 +115,11 @@ describe('Notifications component', () => { isFetching: true, isFetchingNextPage: false, hasNextPage: false, - fetchNextPage: jest.fn(), - refetch: jest.fn(), + fetchNextPage: jest.fn().mockResolvedValue(undefined), + refetch: jest.fn().mockResolvedValue(undefined), isInitialQueryDone: false, + isSuccess: false, + error: null, }); render(); @@ -119,9 +137,11 @@ describe('Notifications component', () => { isFetching: false, isFetchingNextPage: false, hasNextPage: false, - fetchNextPage: jest.fn(), - refetch: jest.fn(), + fetchNextPage: jest.fn().mockResolvedValue(undefined), + refetch: jest.fn().mockResolvedValue(undefined), isInitialQueryDone: true, + isSuccess: true, + error: null, }); render(); @@ -138,9 +158,11 @@ describe('Notifications component', () => { isFetching: false, isFetchingNextPage: false, hasNextPage: false, - fetchNextPage: jest.fn(), - refetch: jest.fn(), + fetchNextPage: jest.fn().mockResolvedValue(undefined), + refetch: jest.fn().mockResolvedValue(undefined), isInitialQueryDone: true, + isSuccess: true, + error: null, }); render(); diff --git a/__tests__/components/profile-activity/ProfileActivityLogs.test.tsx b/__tests__/components/profile-activity/ProfileActivityLogs.test.tsx index 4d87e5f773..c1b01ba3d2 100644 --- a/__tests__/components/profile-activity/ProfileActivityLogs.test.tsx +++ b/__tests__/components/profile-activity/ProfileActivityLogs.test.tsx @@ -1,5 +1,7 @@ -import { FilterTargetType } from "@/components/utils/CommonFilterTargetSelect"; -import { ProfileActivityLogType } from "@/enums"; +import { + ProfileActivityFilterTargetType, + ProfileActivityLogType, +} from "@/enums"; import { convertActivityLogParams } from "@/helpers/profile-logs.helpers"; describe("convertActivityLogParams", () => { @@ -8,7 +10,7 @@ describe("convertActivityLogParams", () => { pageSize: 10, logTypes: [ProfileActivityLogType.DROP_CREATED], matter: null, - targetType: FilterTargetType.ALL, + targetType: ProfileActivityFilterTargetType.ALL, handleOrWallet: null, groupId: "g1", }; @@ -43,7 +45,7 @@ describe("convertActivityLogParams", () => { params: { ...base, handleOrWallet: "u", - targetType: FilterTargetType.INCOMING, + targetType: ProfileActivityFilterTargetType.INCOMING, }, disableActiveGroup: false, }); @@ -52,7 +54,7 @@ describe("convertActivityLogParams", () => { params: { ...base, handleOrWallet: "u", - targetType: FilterTargetType.OUTGOING, + targetType: ProfileActivityFilterTargetType.OUTGOING, }, disableActiveGroup: false, }); diff --git a/app/[user]/identity/page.tsx b/app/[user]/identity/page.tsx index 3bbe64256c..a1c31e2501 100644 --- a/app/[user]/identity/page.tsx +++ b/app/[user]/identity/page.tsx @@ -1,8 +1,7 @@ import { createUserTabPage } from "@/app/[user]/_lib/userTabPageFactory"; import type { ActivityLogParams } from "@/components/profile-activity/ProfileActivityLogs"; import UserPageIdentityWrapper from "@/components/user/identity/UserPageIdentityWrapper"; -import { FilterTargetType } from "@/components/utils/CommonFilterTargetSelect"; -import { RateMatter } from "@/enums"; +import { ProfileActivityFilterTargetType, RateMatter } from "@/enums"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import { getProfileLogTypes } from "@/helpers/profile-logs.helpers"; import { getInitialRatersParams } from "@/helpers/server.helpers"; @@ -18,7 +17,7 @@ const getInitialActivityLogParams = ( logTypes: [], }), matter: null, - targetType: FilterTargetType.ALL, + targetType: ProfileActivityFilterTargetType.ALL, handleOrWallet, groupId: null, }); diff --git a/app/[user]/rep/page.tsx b/app/[user]/rep/page.tsx index 1c1187e4bf..fa08ff72e6 100644 --- a/app/[user]/rep/page.tsx +++ b/app/[user]/rep/page.tsx @@ -1,9 +1,8 @@ import { createUserTabPage } from "@/app/[user]/_lib/userTabPageFactory"; import type { ActivityLogParams } from "@/components/profile-activity/ProfileActivityLogs"; import UserPageRepWrapper from "@/components/user/rep/UserPageRepWrapper"; -import { FilterTargetType } from "@/components/utils/CommonFilterTargetSelect"; import { ApiProfileRepRatesState } from "@/entities/IProfile"; -import { RateMatter } from "@/enums"; +import { ProfileActivityFilterTargetType, RateMatter } from "@/enums"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import { getProfileLogTypes } from "@/helpers/profile-logs.helpers"; import { getInitialRatersParams } from "@/helpers/server.helpers"; @@ -24,7 +23,7 @@ const getInitialActivityLogParams = ( logTypes: [], }), matter: RateMatter.REP, - targetType: FilterTargetType.ALL, + targetType: ProfileActivityFilterTargetType.ALL, handleOrWallet, groupId: null, }); diff --git a/components/brain/notifications/Notifications.tsx b/components/brain/notifications/Notifications.tsx index 38a7dc938c..552de90273 100644 --- a/components/brain/notifications/Notifications.tsx +++ b/components/brain/notifications/Notifications.tsx @@ -8,12 +8,13 @@ import { useRef, useState, } from "react"; -import type { UIEventHandler } 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"; @@ -28,33 +29,202 @@ 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, setToast } = - useContext(AuthContext); + 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"); @@ -79,6 +249,9 @@ export default function Notifications({ activeDrop, setActiveDrop }: Notificatio }); useEffect(() => { + if (!isAuthenticated) { + return; + } if (reload === "true" || hasMarkedAllAsReadRef.current) { return; } @@ -87,7 +260,13 @@ export default function Notifications({ activeDrop, setActiveDrop }: Notificatio markAllAsRead().catch((error) => { console.error("Failed to mark notifications as read:", error); }); - }, [markAllAsRead, reload]); + }, [markAllAsRead, reload, isAuthenticated]); + + useEffect(() => { + if (!isAuthenticated) { + hasMarkedAllAsReadRef.current = false; + } + }, [isAuthenticated]); const { items, @@ -97,8 +276,10 @@ export default function Notifications({ activeDrop, setActiveDrop }: Notificatio fetchNextPage, refetch, isInitialQueryDone, + isSuccess, + error: queryError, } = useNotificationsQuery({ - identity: connectedProfile?.handle, + identity: isAuthenticated ? connectedProfile?.handle : undefined, activeProfileProxy: !!activeProfileProxy, limit: "30", reverse: true, @@ -106,25 +287,49 @@ export default function Notifications({ activeDrop, setActiveDrop }: Notificatio }); useEffect(() => { - if (reload === "true") { - refetch() - .then(() => { - hasMarkedAllAsReadRef.current = true; - return markAllAsRead(); - }) - .catch((error) => { - console.error("Error during refetch:", error); - }); + 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; } - }, [reload, refetch, markAllAsRead, searchParams, pathname, router]); + + 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; } @@ -137,7 +342,7 @@ export default function Notifications({ activeDrop, setActiveDrop }: Notificatio } isPrependingRef.current = true; fetchNextPage(); - }, [isFetchingNextPage, hasNextPage, fetchNextPage]); + }, [isAuthenticated, isFetchingNextPage, hasNextPage, fetchNextPage]); useLayoutEffect(() => { const scrollElement = scrollContainerRef.current; @@ -170,6 +375,85 @@ export default function Notifications({ activeDrop, setActiveDrop }: Notificatio 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; @@ -189,9 +473,62 @@ export default function Notifications({ activeDrop, setActiveDrop }: Notificatio isPrependingRef.current = false; }, [items]); - const showLoader = (!isInitialQueryDone || isFetching) && items.length === 0; - const showNoItems = isInitialQueryDone && !isFetching && items.length === 0; - const shouldEnableInfiniteScroll = !showLoader && !showNoItems; + 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; @@ -264,7 +601,7 @@ export default function Notifications({ activeDrop, setActiveDrop }: Notificatio cancelAnimationFrame(rafId); } }; - }, [items, showLoader, showNoItems]); + }, [items, showLoader, showNoItems, showErrorState]); const handleScroll: UIEventHandler = useCallback( (event) => { @@ -298,39 +635,35 @@ export default function Notifications({ activeDrop, setActiveDrop }: Notificatio ] ); - let notificationsContent = null; - if (showLoader) { - notificationsContent = ( -
- -
- ); - } else if (showNoItems) { - notificationsContent = ( -
- -
- ); - } else { - notificationsContent = ( - - ); - } + const notificationsContent = resolveNotificationsContent({ + isLoadingProfile, + hasConnectedProfile, + hasProfileHandle, + showProxyDisabledState, + showErrorState, + resolvedErrorMessage, + handleRetry, + handleAuthRetry, + handleProxyDisable, + showLoader, + showNoItems, + items, + loadingOlder: isFetchingNextPage, + activeDrop, + setActiveDrop, + }); return (
- + {isAuthenticated ? ( + + ) : null}
(initialParams.logTypes); - const [targetType, setTargetType] = useState( + const [targetType, setTargetType] = useState< + ProfileActivityFilterTargetType + >( initialParams.targetType ); const [currentPage, setCurrentPage] = useState(initialParams.page); @@ -75,7 +79,7 @@ export default function ProfileActivityLogs({ setCurrentPage(1); }; - const onTargetType = (target: FilterTargetType) => { + const onTargetType = (target: ProfileActivityFilterTargetType) => { setTargetType(target); setCurrentPage(1); }; diff --git a/components/utils/CommonFilterTargetSelect.tsx b/components/utils/CommonFilterTargetSelect.tsx index da442bb9bc..1fb6954502 100644 --- a/components/utils/CommonFilterTargetSelect.tsx +++ b/components/utils/CommonFilterTargetSelect.tsx @@ -2,24 +2,25 @@ import { useId } from "react"; -export enum FilterTargetType { - ALL = "ALL", - INCOMING = "INCOMING", - OUTGOING = "OUTGOING", -} +import { ProfileActivityFilterTargetType } from "@/enums"; + +export { ProfileActivityFilterTargetType } from "@/enums"; +export { + ProfileActivityFilterTargetType as FilterTargetType, +} from "@/enums"; const TARGETS = [ - { id: FilterTargetType.ALL, name: "All" }, - { id: FilterTargetType.OUTGOING, name: "Outgoing" }, - { id: FilterTargetType.INCOMING, name: "Incoming" }, + { id: ProfileActivityFilterTargetType.ALL, name: "All" }, + { id: ProfileActivityFilterTargetType.OUTGOING, name: "Outgoing" }, + { id: ProfileActivityFilterTargetType.INCOMING, name: "Incoming" }, ]; export default function CommonFilterTargetSelect({ selected, onChange, }: { - readonly selected: FilterTargetType; - readonly onChange: (filter: FilterTargetType) => void; + readonly selected: ProfileActivityFilterTargetType; + readonly onChange: (filter: ProfileActivityFilterTargetType) => void; }) { const baseId = useId().replaceAll(":", ""); const groupName = `filter-target-${baseId}`; diff --git a/enums.ts b/enums.ts index 7bacce86b5..8a7a0b3659 100644 --- a/enums.ts +++ b/enums.ts @@ -145,6 +145,12 @@ export enum DelegationCenterSection { HTML = "html", } +export enum ProfileActivityFilterTargetType { + ALL = "ALL", + INCOMING = "INCOMING", + OUTGOING = "OUTGOING", +} + export enum ProfileActivityLogType { RATING_EDIT = "RATING_EDIT", HANDLE_EDIT = "HANDLE_EDIT", diff --git a/helpers/profile-logs.helpers.ts b/helpers/profile-logs.helpers.ts index 9d0992896e..8ae6abbcd8 100644 --- a/helpers/profile-logs.helpers.ts +++ b/helpers/profile-logs.helpers.ts @@ -2,8 +2,10 @@ import { ActivityLogParams, ActivityLogParamsConverted, } from "@/components/profile-activity/ProfileActivityLogs"; -import { FilterTargetType } from "@/components/utils/CommonFilterTargetSelect"; -import { ProfileActivityLogType } from "@/enums"; +import { + ProfileActivityFilterTargetType, + ProfileActivityLogType, +} from "@/enums"; const DISABLED_LOG_TYPES = [ ProfileActivityLogType.DROP_COMMENT, @@ -34,7 +36,7 @@ export const INITIAL_ACTIVITY_LOGS_PARAMS: ActivityLogParams = { logTypes: [], }), matter: null, - targetType: FilterTargetType.ALL, + targetType: ProfileActivityFilterTargetType.ALL, handleOrWallet: null, groupId: null, }; @@ -65,18 +67,18 @@ export const convertActivityLogParams = ({ return converted; } - if (params.targetType === FilterTargetType.ALL) { + if (params.targetType === ProfileActivityFilterTargetType.ALL) { converted.include_incoming = "true"; converted.profile = params.handleOrWallet; return converted; } - if (params.targetType === FilterTargetType.INCOMING) { + if (params.targetType === ProfileActivityFilterTargetType.INCOMING) { converted.target = params.handleOrWallet; return converted; } - if (params.targetType === FilterTargetType.OUTGOING) { + if (params.targetType === ProfileActivityFilterTargetType.OUTGOING) { converted.profile = params.handleOrWallet; return converted; } diff --git a/hooks/useNotificationsQuery.tsx b/hooks/useNotificationsQuery.tsx index 4aae11d6a7..41a47afc12 100644 --- a/hooks/useNotificationsQuery.tsx +++ b/hooks/useNotificationsQuery.tsx @@ -13,35 +13,36 @@ interface UseNotificationsQueryProps { /** * If true, reverse the notifications order (e.g. for a "descending" / "newest first" display). */ - reverse?: boolean; + readonly reverse?: boolean; /** * Only fetch notifications if we have a valid identity. * Used in "enabled" to avoid sending queries prematurely. */ - identity?: string | null; + readonly identity?: string | null; /** * Example usage where you only fetch if no active profile proxy is set. * Adjust or remove according to your own logic. */ - activeProfileProxy?: boolean; + readonly activeProfileProxy?: boolean; /** * How many notifications to fetch per page. */ - limit?: string; + readonly limit?: string; /** * The cause of the notifications to fetch. */ - cause?: ApiNotificationCause[] | null; + readonly cause?: ApiNotificationCause[] | null; } type NotificationsQueryParams = { limit: string; cause: ApiNotificationCause[] | null; pageParam?: number | null; + signal?: AbortSignal; }; const getIdentityNotificationsQueryKey = ( @@ -54,10 +55,11 @@ const fetchNotifications = async ({ limit, cause, pageParam, + signal, }: NotificationsQueryParams) => { const params: Record = { limit }; - if (pageParam) { + if (pageParam != null) { params.id_less_than = String(pageParam); } @@ -68,6 +70,7 @@ const fetchNotifications = async ({ return await commonApiFetch({ endpoint: "notifications", params, + signal, }); }; @@ -97,12 +100,29 @@ export function useNotificationsQuery({ */ const query = useInfiniteQuery({ queryKey: getIdentityNotificationsQueryKey(identity, limit, cause), - queryFn: ({ pageParam }: { pageParam: number | null }) => - fetchNotifications({ limit, cause, pageParam }), + queryFn: ({ + pageParam, + signal, + }: { + pageParam: number | null; + signal: AbortSignal | undefined; + }) => fetchNotifications({ limit, cause, pageParam, signal }), initialPageParam: null, getNextPageParam: (lastPage) => lastPage.notifications.at(-1)?.id ?? null, enabled: !!identity && !activeProfileProxy, staleTime: 60000, + retry: (failureCount, error: unknown) => { + const status = + (error as any)?.status ?? + (error as any)?.response?.status ?? + (error as any)?.cause?.status; + if (status === 401) return false; + if (typeof error === "string" && /unauthorized/i.test(error)) return false; + if (error instanceof Error && /unauthorized/i.test(error.message)) { + return false; + } + return failureCount < 3; + }, }); const items = useMemo(() => { @@ -145,8 +165,13 @@ export function usePrefetchNotifications() { } queryClient.prefetchInfiniteQuery({ queryKey: getIdentityNotificationsQueryKey(identity, limit, cause), - queryFn: ({ pageParam }: { pageParam?: number | null }) => - fetchNotifications({ limit, cause, pageParam }), + queryFn: ({ + pageParam, + signal, + }: { + pageParam?: number | null; + signal?: AbortSignal; + }) => fetchNotifications({ limit, cause, pageParam, signal }), initialPageParam: null, getNextPageParam: (lastPage) => lastPage.notifications.at(-1)?.id ?? null,