diff --git a/__tests__/components/auth/SeizeConnectContext.addAccount.test.tsx b/__tests__/components/auth/SeizeConnectContext.addAccount.test.tsx index 63b986f3f2..5d72104185 100644 --- a/__tests__/components/auth/SeizeConnectContext.addAccount.test.tsx +++ b/__tests__/components/auth/SeizeConnectContext.addAccount.test.tsx @@ -57,6 +57,13 @@ jest.mock("@/hooks/useConnectedAccountsUnreadNotifications", () => ({ useConnectedAccountsUnreadNotifications: () => ({}), })); +jest.mock("@/hooks/useUnreadNotifications", () => ({ + useUnreadNotifications: () => ({ + notifications: { unread_count: 0 }, + haveUnreadNotifications: false, + }), +})); + jest.mock("@/services/auth/auth.utils", () => ({ WALLET_ACCOUNTS_UPDATED_EVENT: "6529-wallet-accounts-updated", canStoreAnotherWalletAccount: jest.fn(() => true), diff --git a/__tests__/components/auth/SeizeConnectContext.switch-sync.test.tsx b/__tests__/components/auth/SeizeConnectContext.switch-sync.test.tsx index cef839694f..5be5e8d3e3 100644 --- a/__tests__/components/auth/SeizeConnectContext.switch-sync.test.tsx +++ b/__tests__/components/auth/SeizeConnectContext.switch-sync.test.tsx @@ -35,6 +35,13 @@ jest.mock("@/hooks/useConnectedAccountsUnreadNotifications", () => ({ useConnectedAccountsUnreadNotifications: jest.fn(() => ({})), })); +jest.mock("@/hooks/useUnreadNotifications", () => ({ + useUnreadNotifications: jest.fn(() => ({ + notifications: { unread_count: 0 }, + haveUnreadNotifications: false, + })), +})); + jest.mock("@/services/auth/auth.utils", () => ({ canStoreAnotherWalletAccount: jest.fn(() => true), getConnectedWalletAccounts: jest.fn(() => []), @@ -54,20 +61,40 @@ const AddressProbe: React.FC = () => { return
{address ?? "undefined"}
; }; +const UnreadProbe: React.FC = () => { + const { connectedAccountUnreadNotifications } = useSeizeConnectContext(); + return ( +
+ {JSON.stringify(connectedAccountUnreadNotifications)} +
+ ); +}; + const buildStoredAccount = ( - address: string + address: string, + profileHandle: string | null = null, + jwt: string | null = null ): authUtils.ConnectedWalletAccount => ({ address, refreshToken: "dummy-refresh-token", role: null, - jwt: null, + jwt, profileId: null, - profileHandle: null, + profileHandle, }); describe("SeizeConnectContext switch sync guard", () => { beforeEach(() => { jest.clearAllMocks(); + require("@/hooks/useConnectedAccountsUnreadNotifications").useConnectedAccountsUnreadNotifications.mockReturnValue( + {} + ); + require("@/hooks/useUnreadNotifications").useUnreadNotifications.mockReturnValue( + { + notifications: { unread_count: 0 }, + haveUnreadNotifications: false, + } + ); }); it("prefers stored active account while provider still reports previous known account", async () => { @@ -185,4 +212,126 @@ describe("SeizeConnectContext switch sync guard", () => { expect(screen.getByTestId("active-address")).toHaveTextContent(addressA); }); }); + + it("polls inactive accounts only and merges the active unread count", async () => { + const { useAppKitAccount } = require("@reown/appkit/react"); + const mockGetWalletAddress = + authUtils.getWalletAddress as jest.MockedFunction< + typeof authUtils.getWalletAddress + >; + const mockGetConnectedWalletAccounts = + authUtils.getConnectedWalletAccounts as jest.MockedFunction< + typeof authUtils.getConnectedWalletAccounts + >; + const mockUseConnectedAccountsUnreadNotifications = + require("@/hooks/useConnectedAccountsUnreadNotifications") + .useConnectedAccountsUnreadNotifications as jest.Mock; + const mockUseUnreadNotifications = require("@/hooks/useUnreadNotifications") + .useUnreadNotifications as jest.Mock; + + const activeAccount = buildStoredAccount(addressA, "alice"); + const inactiveAccount = buildStoredAccount(addressB, "bob"); + + (useAppKitAccount as jest.Mock).mockReturnValue({ + address: addressA, + isConnected: true, + status: "connected", + }); + mockGetWalletAddress.mockReturnValue(addressA); + mockGetConnectedWalletAccounts.mockReturnValue([ + activeAccount, + inactiveAccount, + ]); + mockUseConnectedAccountsUnreadNotifications.mockReturnValue({ + [addressB]: 4, + }); + mockUseUnreadNotifications.mockReturnValue({ + notifications: { unread_count: 7 }, + haveUnreadNotifications: true, + }); + + render( + + + + ); + + await waitFor(() => { + expect(mockUseConnectedAccountsUnreadNotifications).toHaveBeenCalledWith([ + inactiveAccount, + ]); + expect(mockUseUnreadNotifications).toHaveBeenCalledWith("alice"); + }); + + const unreadMap = JSON.parse( + screen.getByTestId("unread-map").textContent ?? "{}" + ); + + expect(unreadMap).toEqual({ + [addressA]: 7, + [addressB]: 4, + }); + }); + + it("keeps the active JWT unread count when the active account has no profile handle", async () => { + const { useAppKitAccount } = require("@reown/appkit/react"); + const mockGetWalletAddress = + authUtils.getWalletAddress as jest.MockedFunction< + typeof authUtils.getWalletAddress + >; + const mockGetConnectedWalletAccounts = + authUtils.getConnectedWalletAccounts as jest.MockedFunction< + typeof authUtils.getConnectedWalletAccounts + >; + const mockUseConnectedAccountsUnreadNotifications = + require("@/hooks/useConnectedAccountsUnreadNotifications") + .useConnectedAccountsUnreadNotifications as jest.Mock; + const mockUseUnreadNotifications = require("@/hooks/useUnreadNotifications") + .useUnreadNotifications as jest.Mock; + + const activeAccount = buildStoredAccount(addressA, null, "active-jwt"); + const inactiveAccount = buildStoredAccount(addressB, "bob", "inactive-jwt"); + + (useAppKitAccount as jest.Mock).mockReturnValue({ + address: addressA, + isConnected: true, + status: "connected", + }); + mockGetWalletAddress.mockReturnValue(addressA); + mockGetConnectedWalletAccounts.mockReturnValue([ + activeAccount, + inactiveAccount, + ]); + mockUseConnectedAccountsUnreadNotifications.mockReturnValue({ + [addressA]: 9, + [addressB]: 4, + }); + mockUseUnreadNotifications.mockReturnValue({ + notifications: { unread_count: 0 }, + haveUnreadNotifications: false, + }); + + render( + + + + ); + + await waitFor(() => { + expect(mockUseConnectedAccountsUnreadNotifications).toHaveBeenCalledWith([ + activeAccount, + inactiveAccount, + ]); + expect(mockUseUnreadNotifications).toHaveBeenCalledWith(null); + }); + + const unreadMap = JSON.parse( + screen.getByTestId("unread-map").textContent ?? "{}" + ); + + expect(unreadMap).toEqual({ + [addressA]: 9, + [addressB]: 4, + }); + }); }); diff --git a/__tests__/components/auth/SeizeConnectContext.test.tsx b/__tests__/components/auth/SeizeConnectContext.test.tsx index d165f55867..0e5f10f64e 100644 --- a/__tests__/components/auth/SeizeConnectContext.test.tsx +++ b/__tests__/components/auth/SeizeConnectContext.test.tsx @@ -43,6 +43,17 @@ jest.mock("viem", () => ({ getAddress: jest.fn((address: string) => address.toLowerCase()), })); +jest.mock("@/hooks/useConnectedAccountsUnreadNotifications", () => ({ + useConnectedAccountsUnreadNotifications: jest.fn(() => ({})), +})); + +jest.mock("@/hooks/useUnreadNotifications", () => ({ + useUnreadNotifications: jest.fn(() => ({ + notifications: { unread_count: 0 }, + haveUnreadNotifications: false, + })), +})); + // Mock auth utils jest.mock("@/services/auth/auth.utils", () => ({ canStoreAnotherWalletAccount: jest.fn(() => true), diff --git a/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx index 4acfea92e6..cc2a326d1f 100644 --- a/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx @@ -21,6 +21,16 @@ const invalidateNotificationsMock = jest.fn(); const mockUseAuth = jest.fn(); const mockApprovalStatus = jest.fn(); +let documentVisibilityState: DocumentVisibilityState = "visible"; + +const setDocumentVisibilityState = (state: DocumentVisibilityState) => { + documentVisibilityState = state; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => documentVisibilityState, + }); +}; + jest.mock("next/navigation", () => ({ useRouter: () => ({ replace: replaceMock }), useSearchParams: () => searchParamsMock, @@ -115,10 +125,28 @@ jest.mock("@/components/auth/Auth", () => ({ useAuth: () => mockUseAuth(), })); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: () => ({ address: "0xAAA" }), +})); + jest.mock("@/services/api/common-api", () => ({ commonApiPostWithoutBodyAndResponse: jest.fn().mockResolvedValue(undefined), })); +jest.mock("@/services/auth/auth.utils", () => ({ + getAuthJwt: () => "test-jwt", +})); + +jest.mock("jwt-decode", () => ({ + jwtDecode: (token: string) => { + if (token !== "test-jwt") { + throw new Error(`Unexpected JWT decode for ${token}`); + } + + return { sub: "0xAAA", role: null, exp: 4102444800 }; + }, +})); + const wave = { id: "10", participation: {}, @@ -126,18 +154,12 @@ const wave = { wave: { type: ApiWaveType.Rank, winning_threshold: null }, } as any; const mockOnDropClick = jest.fn(); -const setDocumentVisibility = (visibilityState: DocumentVisibilityState) => { - Object.defineProperty(document, "visibilityState", { - configurable: true, - value: visibilityState, - }); -}; describe("MyStreamWaveChat", () => { let store: any; beforeEach(() => { - setDocumentVisibility("visible"); + setDocumentVisibilityState("visible"); capturedPropsHolder.current = {}; capturedCreatorPropsHolder.current = {}; capturedMemesButtonPropsHolder.current = {}; @@ -160,6 +182,7 @@ describe("MyStreamWaveChat", () => { }); mockUseAuth.mockReturnValue({ connectedProfile: { handle: "tester" }, + activeProfileProxy: null, }); ( commonApiPostWithoutBodyAndResponse as jest.MockedFunction< @@ -319,13 +342,16 @@ describe("MyStreamWaveChat", () => { expect(mockRemoveWaveDeliveredNotifications).toHaveBeenCalledWith("10"); expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledWith({ endpoint: "notifications/wave/10/read", + headers: { Authorization: "Bearer test-jwt" }, }); expect(invalidateNotificationsMock).toHaveBeenCalled(); }); }); - it("does not call the read endpoint on unmount when the tab is hidden", async () => { - setDocumentVisibility("hidden"); + it("does not mark notifications read on hidden unmount", async () => { + setDocumentVisibilityState("hidden"); + searchParamsMock.get.mockReturnValueOnce("5").mockReturnValue(null); + searchParamsMock.toString.mockReturnValue("serialNo=5"); const { unmount } = renderWithProvider( { await act(async () => { unmount(); + await Promise.resolve(); }); + expect(mockSetUnreadDividerSerialNo).toHaveBeenCalledWith(null); + expect(mockRemoveWaveDeliveredNotifications).not.toHaveBeenCalled(); expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); expect(invalidateNotificationsMock).not.toHaveBeenCalled(); - expect(mockRemoveWaveDeliveredNotifications).not.toHaveBeenCalled(); }); it("skips notification cleanup on unmount for anonymous viewers", async () => { mockUseAuth.mockReturnValue({ connectedProfile: null, + activeProfileProxy: null, }); const { unmount } = renderWithProvider( diff --git a/__tests__/components/brain/my-stream/useWaveChatLeaveCleanup.test.tsx b/__tests__/components/brain/my-stream/useWaveChatLeaveCleanup.test.tsx new file mode 100644 index 0000000000..edd3ac6dcb --- /dev/null +++ b/__tests__/components/brain/my-stream/useWaveChatLeaveCleanup.test.tsx @@ -0,0 +1,179 @@ +import { useWaveChatLeaveCleanup } from "@/components/brain/my-stream/useWaveChatLeaveCleanup"; +import { act, renderHook, waitFor } from "@testing-library/react"; + +type CleanupCallbacks = { + readonly setUnreadDividerSerialNo: jest.Mock; + readonly removeWaveDeliveredNotifications: jest.Mock; + readonly markWaveNotificationsRead: jest.Mock; +}; + +type HookProps = CleanupCallbacks & { + readonly enabled: boolean; + readonly waveId: string; +}; + +let documentVisibilityState: DocumentVisibilityState = "visible"; + +const setDocumentVisibilityState = (state: DocumentVisibilityState) => { + documentVisibilityState = state; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => documentVisibilityState, + }); +}; + +const createCallbacks = (): CleanupCallbacks => ({ + setUnreadDividerSerialNo: jest.fn(), + removeWaveDeliveredNotifications: jest.fn().mockResolvedValue(undefined), + markWaveNotificationsRead: jest.fn().mockResolvedValue(undefined), +}); + +const renderLeaveCleanupHook = (props: HookProps) => + renderHook((hookProps: HookProps) => useWaveChatLeaveCleanup(hookProps), { + initialProps: props, + }); + +describe("useWaveChatLeaveCleanup", () => { + beforeEach(() => { + setDocumentVisibilityState("visible"); + }); + + it("does not clean up when only callbacks change", async () => { + const firstCallbacks = createCallbacks(); + const nextCallbacks = createCallbacks(); + const { rerender } = renderLeaveCleanupHook({ + enabled: true, + waveId: "wave-1", + ...firstCallbacks, + }); + + await act(async () => { + rerender({ + enabled: true, + waveId: "wave-1", + ...nextCallbacks, + }); + await Promise.resolve(); + }); + + expect(firstCallbacks.setUnreadDividerSerialNo).not.toHaveBeenCalled(); + expect( + firstCallbacks.removeWaveDeliveredNotifications + ).not.toHaveBeenCalled(); + expect(firstCallbacks.markWaveNotificationsRead).not.toHaveBeenCalled(); + expect(nextCallbacks.setUnreadDividerSerialNo).not.toHaveBeenCalled(); + expect( + nextCallbacks.removeWaveDeliveredNotifications + ).not.toHaveBeenCalled(); + expect(nextCallbacks.markWaveNotificationsRead).not.toHaveBeenCalled(); + }); + + it("cleans up the old wave with the latest callbacks on wave id change", async () => { + const firstCallbacks = createCallbacks(); + const latestCallbacks = createCallbacks(); + const { rerender } = renderLeaveCleanupHook({ + enabled: true, + waveId: "wave-1", + ...firstCallbacks, + }); + + await act(async () => { + rerender({ + enabled: true, + waveId: "wave-1", + ...latestCallbacks, + }); + }); + + await act(async () => { + rerender({ + enabled: true, + waveId: "wave-2", + ...latestCallbacks, + }); + }); + + expect(latestCallbacks.setUnreadDividerSerialNo).toHaveBeenCalledWith(null); + await waitFor(() => { + expect( + latestCallbacks.removeWaveDeliveredNotifications + ).toHaveBeenCalledWith("wave-1"); + expect(latestCallbacks.markWaveNotificationsRead).toHaveBeenCalledWith( + "wave-1", + { queueIfBlocked: false } + ); + }); + expect(firstCallbacks.setUnreadDividerSerialNo).not.toHaveBeenCalled(); + expect( + firstCallbacks.removeWaveDeliveredNotifications + ).not.toHaveBeenCalled(); + expect(firstCallbacks.markWaveNotificationsRead).not.toHaveBeenCalled(); + }); + + it("cleans up when enabled changes from true to false", async () => { + const callbacks = createCallbacks(); + const { rerender } = renderLeaveCleanupHook({ + enabled: true, + waveId: "wave-1", + ...callbacks, + }); + + await act(async () => { + rerender({ + enabled: false, + waveId: "wave-1", + ...callbacks, + }); + }); + + expect(callbacks.setUnreadDividerSerialNo).toHaveBeenCalledWith(null); + await waitFor(() => { + expect(callbacks.removeWaveDeliveredNotifications).toHaveBeenCalledWith( + "wave-1" + ); + expect(callbacks.markWaveNotificationsRead).toHaveBeenCalledWith( + "wave-1", + { queueIfBlocked: false } + ); + }); + }); + + it("marks leave cleanup reads without saving a delayed replay", async () => { + const callbacks = createCallbacks(); + const { unmount } = renderLeaveCleanupHook({ + enabled: true, + waveId: "wave-1", + ...callbacks, + }); + + await act(async () => { + unmount(); + }); + + await waitFor(() => { + expect(callbacks.markWaveNotificationsRead).toHaveBeenCalledWith( + "wave-1", + { queueIfBlocked: false } + ); + }); + }); + + it("clears the divider but skips notification calls when hidden", async () => { + setDocumentVisibilityState("hidden"); + const callbacks = createCallbacks(); + const { unmount } = renderLeaveCleanupHook({ + enabled: true, + waveId: "wave-1", + ...callbacks, + }); + + await act(async () => { + unmount(); + await Promise.resolve(); + }); + + expect(callbacks.setUnreadDividerSerialNo).toHaveBeenCalledWith(null); + expect(callbacks.removeWaveDeliveredNotifications).not.toHaveBeenCalled(); + expect(callbacks.markWaveNotificationsRead).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/react-query-wrapper/ReactQueryWrapper.test.tsx b/__tests__/components/react-query-wrapper/ReactQueryWrapper.test.tsx index bc59f55893..edc091cdcb 100644 --- a/__tests__/components/react-query-wrapper/ReactQueryWrapper.test.tsx +++ b/__tests__/components/react-query-wrapper/ReactQueryWrapper.test.tsx @@ -1,11 +1,17 @@ -import React, { useContext } from 'react'; -import { render, act } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import ReactQueryWrapper, { ReactQueryWrapperContext, QueryKey } from '@/components/react-query-wrapper/ReactQueryWrapper'; +import React, { useContext } from "react"; +import { render, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import ReactQueryWrapper, { + ReactQueryWrapperContext, + QueryKey, +} from "@/components/react-query-wrapper/ReactQueryWrapper"; -jest.mock('@/helpers/Helpers', () => ({ ...jest.requireActual('../../../helpers/Helpers'), wait: jest.fn(() => Promise.resolve()) })); +jest.mock("@/helpers/Helpers", () => ({ + ...jest.requireActual("../../../helpers/Helpers"), + wait: jest.fn(() => Promise.resolve()), +})); -const wait = require('@/helpers/Helpers').wait as jest.Mock; +const wait = require("@/helpers/Helpers").wait as jest.Mock; type ContextType = { setProfile: (profile: any) => void; @@ -14,7 +20,11 @@ type ContextType = { onWaveFollowChange: (params: { waveId: string; following: boolean }) => void; invalidateAll: () => void; setProfileProxy: (proxy: any) => void; - onProfileProxyModify: (params: { profileProxyId: string; createdByHandle: string; grantedToHandle: string }) => void; + onProfileProxyModify: (params: { + profileProxyId: string; + createdByHandle: string; + grantedToHandle: string; + }) => void; setWave: (wave: any) => void; setWavesOverviewPage: (waves: any[]) => void; onIdentityFollowChange: () => void; @@ -27,146 +37,246 @@ type ContextType = { const createTestSetup = () => { const client = new QueryClient(); - jest.spyOn(client, 'invalidateQueries'); - jest.spyOn(client, 'setQueryData'); + jest.spyOn(client, "invalidateQueries"); + jest.spyOn(client, "setQueryData"); let ctx: ContextType; - function Child() { - ctx = useContext(ReactQueryWrapperContext) as ContextType; - return null; + function Child() { + ctx = useContext(ReactQueryWrapperContext) as ContextType; + return null; } const renderResult = render( - + + + ); return { client, ctx: ctx!, renderResult }; }; -describe('ReactQueryWrapper context', () => { - it('sets profile data in query cache', () => { +describe("ReactQueryWrapper context", () => { + it("sets profile data in query cache", () => { const { client, ctx } = createTestSetup(); - const profile = { handle: 'Alice', wallets: [{ wallet: '0x1', display: 'Alice' }] } as any; + const profile = { + handle: "Alice", + wallets: [{ wallet: "0x1", display: "Alice" }], + } as any; act(() => ctx.setProfile(profile)); - expect(client.getQueryData([QueryKey.PROFILE, 'alice'])).toEqual(profile); - expect(client.getQueryData([QueryKey.PROFILE, '0x1'])).toEqual(profile); + expect(client.getQueryData([QueryKey.PROFILE, "alice"])).toEqual(profile); + expect(client.getQueryData([QueryKey.PROFILE, "0x1"])).toEqual(profile); }); - it('waits then invalidates drops', async () => { + it("waits then invalidates drops", async () => { const { client, ctx } = createTestSetup(); await act(async () => { await ctx.waitAndInvalidateDrops(); }); expect(wait).toHaveBeenCalledWith(500); - expect((client.invalidateQueries as jest.Mock).mock.calls[0][0]).toEqual({ queryKey: [QueryKey.DROPS] }); + expect((client.invalidateQueries as jest.Mock).mock.calls[0][0]).toEqual({ + queryKey: [QueryKey.DROPS], + }); }); - it('onIdentityFollowChange invalidates related queries', () => { + it("onIdentityFollowChange invalidates related queries", () => { const { client, ctx } = createTestSetup(); act(() => ctx.onIdentityFollowChange()); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.IDENTITY_FOLLOWING_ACTIONS] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.IDENTITY_FOLLOWERS] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.IDENTITY_NOTIFICATIONS] }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.IDENTITY_FOLLOWING_ACTIONS], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.IDENTITY_FOLLOWERS], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.IDENTITY_NOTIFICATIONS], + }); }); - it('onGroupCreate invalidates groups list', () => { + it("onGroupCreate invalidates groups list", () => { const { client, ctx } = createTestSetup(); act(() => ctx.onGroupCreate()); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.GROUPS] }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.GROUPS], + }); }); - it('onGroupRemoved invalidates all group queries', () => { + it("onGroupRemoved invalidates all group queries", () => { const { client, ctx } = createTestSetup(); - act(() => ctx.onGroupRemoved({ groupId: '1' })); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.GROUPS] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.GROUP, '1'] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.PROFILE_LOGS, { groupId: '1' }] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.COMMUNITY_MEMBERS_TOP, { groupId: '1' }] }); + act(() => ctx.onGroupRemoved({ groupId: "1" })); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.GROUPS], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.GROUP, "1"], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.PROFILE_LOGS, { groupId: "1" }], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.COMMUNITY_MEMBERS_TOP, { groupId: "1" }], + }); }); - it('onGroupChanged invalidates all group queries', () => { + it("onGroupChanged invalidates all group queries", () => { const { client, ctx } = createTestSetup(); - act(() => ctx.onGroupChanged({ groupId: '2' })); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.GROUPS] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.GROUP, '2'] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.PROFILE_LOGS, { groupId: '2' }] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.COMMUNITY_MEMBERS_TOP, { groupId: '2' }] }); + act(() => ctx.onGroupChanged({ groupId: "2" })); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.GROUPS], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.GROUP, "2"], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.PROFILE_LOGS, { groupId: "2" }], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.COMMUNITY_MEMBERS_TOP, { groupId: "2" }], + }); }); - it('onIdentityBulkRate invalidates all related queries', () => { + it("onIdentityBulkRate invalidates all related queries", () => { const { client, ctx } = createTestSetup(); act(() => ctx.onIdentityBulkRate()); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.PROFILE_LOGS] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.PROFILE_RATERS] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.PROFILE_RATER_CIC_STATE] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.IDENTITY_AVAILABLE_CREDIT] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.PROFILE_PROFILE_PROXIES] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.PROFILE_PROXY] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.PROFILE_REP_RATINGS] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.COMMUNITY_MEMBERS_TOP] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.GROUP] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.GROUPS] }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.PROFILE_LOGS], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.PROFILE_RATERS], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.PROFILE_RATER_CIC_STATE], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.IDENTITY_AVAILABLE_CREDIT], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.PROFILE_PROFILE_PROXIES], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.PROFILE_PROXY], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.PROFILE_REP_RATINGS], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.COMMUNITY_MEMBERS_TOP], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.GROUP], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.GROUPS], + }); }); - it('invalidateNotifications invalidates notifications query', () => { + it("invalidateNotifications invalidates notification queries", () => { const { client, ctx } = createTestSetup(); act(() => ctx.invalidateNotifications()); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.IDENTITY_NOTIFICATIONS] }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.IDENTITY_NOTIFICATIONS], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.CONNECTED_ACCOUNT_UNREAD_NOTIFICATIONS], + }); }); }); -it('sets initial wave drops only when cache empty', () => { +it("sets initial wave drops only when cache empty", () => { const { client, ctx } = createTestSetup(); - const feed = { drops: ['d1'] } as any; - act(() => ctx.setWaveDrops({ waveDrops: feed, waveId: 'w1' })); - expect(client.getQueryData([QueryKey.DROPS, { waveId: 'w1', limit: 50, dropId: null }])).toEqual({ pages: [feed], pageParams: [undefined] }); + const feed = { drops: ["d1"] } as any; + act(() => ctx.setWaveDrops({ waveDrops: feed, waveId: "w1" })); + expect( + client.getQueryData([ + QueryKey.DROPS, + { waveId: "w1", limit: 50, dropId: null }, + ]) + ).toEqual({ pages: [feed], pageParams: [undefined] }); // second call should not overwrite - const other = { drops: ['d2'] } as any; - act(() => ctx.setWaveDrops({ waveDrops: other, waveId: 'w1' })); - expect(client.getQueryData([QueryKey.DROPS, { waveId: 'w1', limit: 50, dropId: null }])).toEqual({ pages: [feed], pageParams: [undefined] }); + const other = { drops: ["d2"] } as any; + act(() => ctx.setWaveDrops({ waveDrops: other, waveId: "w1" })); + expect( + client.getQueryData([ + QueryKey.DROPS, + { waveId: "w1", limit: 50, dropId: null }, + ]) + ).toEqual({ pages: [feed], pageParams: [undefined] }); }); -it('sets initial waves overview page only once', () => { +it("sets initial waves overview page only once", () => { const { client, ctx } = createTestSetup(); - const waves = [{ id: 'w1' }] as any; + const waves = [{ id: "w1" }] as any; act(() => ctx.setWavesOverviewPage(waves)); - const key = [QueryKey.WAVES_OVERVIEW, { limit: 20, type: "RECENTLY_DROPPED_TO", only_waves_followed_by_authenticated_user: true }]; - expect(client.getQueryData(key)).toEqual({ pages: [waves], pageParams: [undefined] }); - const other = [{ id: 'w2' }] as any; + const key = [ + QueryKey.WAVES_OVERVIEW, + { + limit: 20, + type: "RECENTLY_DROPPED_TO", + only_waves_followed_by_authenticated_user: true, + }, + ]; + expect(client.getQueryData(key)).toEqual({ + pages: [waves], + pageParams: [undefined], + }); + const other = [{ id: "w2" }] as any; act(() => ctx.setWavesOverviewPage(other)); - expect(client.getQueryData(key)).toEqual({ pages: [waves], pageParams: [undefined] }); + expect(client.getQueryData(key)).toEqual({ + pages: [waves], + pageParams: [undefined], + }); }); -test('wave follow change toggles and invalidates', () => { +test("wave follow change toggles and invalidates", () => { jest.useFakeTimers(); - const toggle = require('@/components/react-query-wrapper/utils/toggleWaveFollowing'); - jest.spyOn(toggle, 'toggleWaveFollowing').mockResolvedValue(undefined); + const toggle = require("@/components/react-query-wrapper/utils/toggleWaveFollowing"); + jest.spyOn(toggle, "toggleWaveFollowing").mockResolvedValue(undefined); const { client, ctx } = createTestSetup(); - act(() => ctx.onWaveFollowChange({ waveId: 'w1', following: true })); - expect(toggle.toggleWaveFollowing).toHaveBeenCalledWith({ waveId: 'w1', following: true, queryClient: client }); + act(() => ctx.onWaveFollowChange({ waveId: "w1", following: true })); + expect(toggle.toggleWaveFollowing).toHaveBeenCalledWith({ + waveId: "w1", + following: true, + queryClient: client, + }); jest.runAllTimers(); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.WAVES_OVERVIEW] }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.WAVES_OVERVIEW], + }); jest.useRealTimers(); }); -it('invalidateAll calls queryClient.invalidateQueries with no args', () => { +it("invalidateAll calls queryClient.invalidateQueries with no args", () => { const { client, ctx } = createTestSetup(); act(() => ctx.invalidateAll()); expect(client.invalidateQueries).toHaveBeenCalledWith(); }); -it('sets profile proxy and invalidates on modify', () => { +it("sets profile proxy and invalidates on modify", () => { const { client, ctx } = createTestSetup(); - const proxy = { id: 'p1' } as any; + const proxy = { id: "p1" } as any; act(() => ctx.setProfileProxy(proxy)); - expect(client.setQueryData).toHaveBeenCalledWith([QueryKey.PROFILE_PROXY, { id: 'p1' }], proxy); - act(() => ctx.onProfileProxyModify({ profileProxyId: 'p1', createdByHandle: 'a', grantedToHandle: 'b' })); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.PROFILE_PROXY, { id: 'p1' }] }); - expect(client.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.PROFILE_PROFILE_PROXIES] }); + expect(client.setQueryData).toHaveBeenCalledWith( + [QueryKey.PROFILE_PROXY, { id: "p1" }], + proxy + ); + act(() => + ctx.onProfileProxyModify({ + profileProxyId: "p1", + createdByHandle: "a", + grantedToHandle: "b", + }) + ); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.PROFILE_PROXY, { id: "p1" }], + }); + expect(client.invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKey.PROFILE_PROFILE_PROXIES], + }); }); -it('sets wave data in cache', () => { +it("sets wave data in cache", () => { const { client, ctx } = createTestSetup(); - const wave = { id: 'w123' } as any; + const wave = { id: "w123" } as any; act(() => ctx.setWave(wave)); - expect(client.getQueryData([QueryKey.WAVE, { wave_id: 'w123' }])).toEqual(wave); + expect(client.getQueryData([QueryKey.WAVE, { wave_id: "w123" }])).toEqual( + wave + ); }); diff --git a/__tests__/components/waves/drops/WaveDropsAll.test.tsx b/__tests__/components/waves/drops/WaveDropsAll.test.tsx index c9fdfc53c7..befc00fd24 100644 --- a/__tests__/components/waves/drops/WaveDropsAll.test.tsx +++ b/__tests__/components/waves/drops/WaveDropsAll.test.tsx @@ -34,7 +34,16 @@ jest.mock("@/hooks/useWaveIsTyping"); jest.mock("@/hooks/useWaveBoostedDrops"); jest.mock("@/components/notifications/NotificationsContext"); jest.mock("@/components/auth/Auth"); +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(), +})); jest.mock("@/services/api/common-api"); +jest.mock("@/services/auth/auth.utils", () => ({ + getAuthJwt: jest.fn(), +})); +jest.mock("jwt-decode", () => ({ + jwtDecode: jest.fn(), +})); jest.mock("next/navigation"); jest.mock("@/hooks/useDeviceInfo", () => ({ __esModule: true, @@ -130,6 +139,9 @@ const mockFetchNextPage = jest.fn(); const mockWaitAndRevealDrop = jest.fn(); const mockRemoveNotifications = jest.fn(); const mockCommonApiPost = jest.fn(); +const mockAddress = "0xAAA"; +const mockJwt = "test-jwt"; +const mockJwtExp = 4102444800; const useVirtualizedWaveDropsMock = useVirtualizedWaveDrops as jest.MockedFunction< @@ -208,6 +220,8 @@ function setupMocks(options: MockSetupOptions = {}) { containerProps = undefined; dropsProps = undefined; scrollButtonProps = undefined; + mockFetchNextPage.mockReset(); + mockFetchNextPage.mockResolvedValue(undefined); // Setup useVirtualizedWaveDrops mock const defaultWaveMessages: WaveMessagesMock = { @@ -277,6 +291,22 @@ function setupMocks(options: MockSetupOptions = {}) { // Setup auth mock require("@/components/auth/Auth").useAuth.mockReturnValue({ connectedProfile: options.auth?.connectedProfile ?? null, + activeProfileProxy: null, + }); + + require("@/components/auth/SeizeConnectContext").useSeizeConnectContext.mockReturnValue( + { + address: mockAddress, + } + ); + + require("@/services/auth/auth.utils").getAuthJwt.mockReturnValue(mockJwt); + require("jwt-decode").jwtDecode.mockImplementation((token: string) => { + if (token !== mockJwt) { + throw new Error(`Unexpected JWT decode for ${token}`); + } + + return { sub: mockAddress, role: null, exp: mockJwtExp }; }); // Setup typing mock @@ -315,6 +345,7 @@ interface RenderOptions { onQuote?: jest.Mock | undefined; activeDrop?: ActiveDropState | null | undefined; initialDrop?: number | null | undefined; + unreadCount?: number | undefined; onDropContentClick?: jest.Mock | undefined; winningThreshold?: number | null | undefined; isVotingClosed?: boolean | undefined; @@ -329,6 +360,7 @@ function renderComponent(options: RenderOptions = {}) { onQuote: jest.fn(), activeDrop: null, initialDrop: null, + unreadCount: 1, onDropContentClick: jest.fn(), ...options, }; @@ -990,14 +1022,10 @@ describe("WaveDropsAll", () => { renderComponent(); - // Trigger the error scenario - try { - await act(async () => { - await containerProps.onTopIntersection(); - }); - } catch (error) { - // Expected to throw, but component should still render - } + await act(async () => { + containerProps.onTopIntersection(); + await Promise.resolve(); + }); // Component should not crash on fetch failure expect(screen.getByTestId("drops-list")).toBeInTheDocument(); @@ -1057,13 +1085,10 @@ describe("WaveDropsAll", () => { renderComponent(); // Trigger error scenario and ensure component stays stable - try { - await act(async () => { - await containerProps.onTopIntersection(); - }); - } catch (error) { - // Expected to handle errors gracefully - } + await act(async () => { + containerProps.onTopIntersection(); + await Promise.resolve(); + }); expect(screen.getByTestId("drops-list")).toBeInTheDocument(); consoleError.mockRestore(); @@ -1075,13 +1100,16 @@ describe("WaveDropsAll", () => { .mockImplementation(() => {}); mockCommonApiPost.mockRejectedValueOnce(new Error("API error")); - setupMocks(); + setupMocks({ + auth: { connectedProfile: { handle: "testuser" } }, + }); renderComponent({ waveId: "test-wave" }); await waitFor(() => { expect(mockCommonApiPost).toHaveBeenCalledWith({ endpoint: "notifications/wave/test-wave/read", + headers: { Authorization: `Bearer ${mockJwt}` }, }); }); @@ -1134,16 +1162,23 @@ describe("WaveDropsAll", () => { }); describe("Component Lifecycle", () => { - it("removes notifications and marks wave as read on mount", async () => { - setupMocks(); + it("removes notifications and marks wave as read on mount with no unread drops", async () => { + setupMocks({ + auth: { connectedProfile: { handle: "testuser" } }, + }); // Don't pass initialDrop to avoid triggering AbortController code path - renderComponent({ waveId: "test-wave", initialDrop: null }); + renderComponent({ + waveId: "test-wave", + initialDrop: null, + unreadCount: 0, + }); expect(mockRemoveNotifications).toHaveBeenCalledWith("test-wave"); await waitFor(() => { expect(mockCommonApiPost).toHaveBeenCalledWith({ endpoint: "notifications/wave/test-wave/read", + headers: { Authorization: `Bearer ${mockJwt}` }, }); }); }); diff --git a/__tests__/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.test.tsx b/__tests__/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.test.tsx index 94c6b6a625..058978ca26 100644 --- a/__tests__/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.test.tsx +++ b/__tests__/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.test.tsx @@ -1,13 +1,111 @@ +import { useAuth } from "@/components/auth/Auth"; import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { useWaveDropsNotificationRead } from "@/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead"; import { commonApiPostWithoutBodyAndResponse } from "@/services/api/common-api"; import { act, render, waitFor } from "@testing-library/react"; +import { jwtDecode } from "jwt-decode"; import React from "react"; jest.mock("@/services/api/common-api", () => ({ commonApiPostWithoutBodyAndResponse: jest.fn().mockResolvedValue(undefined), })); +jest.mock("@/components/auth/Auth", () => ({ + useAuth: jest.fn(), +})); + +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: () => ({ address: "0xAAA" }), +})); + +jest.mock("@/services/auth/auth.utils", () => ({ + getAuthJwt: jest.fn(() => "test-jwt"), +})); + +jest.mock("jwt-decode", () => ({ + jwtDecode: jest.fn(), +})); + +interface Deferred { + readonly promise: Promise; + readonly resolve: () => void; + readonly reject: (error: unknown) => void; +} + +const createDeferred = (): Deferred => { + let resolve: () => void = () => {}; + let reject: (error: unknown) => void = () => {}; + + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, resolve, reject }; +}; + +const apiPostMock = commonApiPostWithoutBodyAndResponse as jest.MockedFunction< + typeof commonApiPostWithoutBodyAndResponse +>; +const { getAuthJwt } = jest.requireMock("@/services/auth/auth.utils") as { + readonly getAuthJwt: jest.Mock; +}; +const getAuthJwtMock = getAuthJwt; +const useAuthMock = useAuth as jest.MockedFunction; +const jwtDecodeMock = jwtDecode as jest.MockedFunction; +const mockJwtExp = 4102444800; +type AuthValue = ReturnType; + +const createAuthValue = ( + activeProfileProxy: AuthValue["activeProfileProxy"] +): AuthValue => + ({ + activeProfileProxy, + }) as AuthValue; + +const createActiveProfileProxy = ({ + id, + creatorId, +}: { + readonly id: string; + readonly creatorId: string; +}): AuthValue["activeProfileProxy"] => + ({ + id, + created_by: { id: creatorId }, + }) as AuthValue["activeProfileProxy"]; + +const mockJwtRole = (role: string | null) => { + jwtDecodeMock.mockImplementation((token: string): T => { + if (token !== "test-jwt") { + throw new Error(`Unexpected JWT decode for ${token}`); + } + + return { sub: "0xAAA", role, exp: mockJwtExp } as T; + }); +}; + +const createReactQueryContextValue = ( + invalidateNotifications: jest.Mock +): React.ContextType => + ({ + invalidateNotifications, + }) as React.ContextType; + +let documentVisibilityState: DocumentVisibilityState = "visible"; + +const setDocumentVisibilityState = (state: DocumentVisibilityState) => { + documentVisibilityState = state; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => documentVisibilityState, + }); +}; + +const dispatchVisibilityChange = () => { + document.dispatchEvent(new Event("visibilitychange")); +}; + function TestComponent({ enabled, removeWaveDeliveredNotifications, @@ -33,22 +131,19 @@ describe("useWaveDropsNotificationRead", () => { const removeWaveDeliveredNotifications = jest .fn() .mockResolvedValue(undefined); - const setDocumentVisibility = (visibilityState: DocumentVisibilityState) => { - Object.defineProperty(document, "visibilityState", { - configurable: true, - value: visibilityState, - }); - }; beforeEach(() => { - setDocumentVisibility("visible"); + setDocumentVisibilityState("visible"); invalidateNotifications.mockClear(); removeWaveDeliveredNotifications.mockClear(); - ( - commonApiPostWithoutBodyAndResponse as jest.MockedFunction< - typeof commonApiPostWithoutBodyAndResponse - > - ).mockClear(); + apiPostMock.mockReset(); + apiPostMock.mockResolvedValue(undefined); + getAuthJwtMock.mockReset(); + getAuthJwtMock.mockReturnValue("test-jwt"); + useAuthMock.mockReset(); + useAuthMock.mockReturnValue(createAuthValue(null)); + jwtDecodeMock.mockReset(); + mockJwtRole(null); }); it("skips read-sync when disabled", () => { @@ -69,7 +164,32 @@ describe("useWaveDropsNotificationRead", () => { expect(invalidateNotifications).not.toHaveBeenCalled(); }); - it("marks the wave as read when enabled", async () => { + it("does not mark the wave as read while hidden", async () => { + setDocumentVisibilityState("hidden"); + + render( + + + + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(removeWaveDeliveredNotifications).not.toHaveBeenCalled(); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("marks the wave as read after a hidden tab becomes visible", async () => { + setDocumentVisibilityState("hidden"); + render( { ); + await act(async () => { + await Promise.resolve(); + }); + + expect(removeWaveDeliveredNotifications).not.toHaveBeenCalled(); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + + await act(async () => { + setDocumentVisibilityState("visible"); + dispatchVisibilityChange(); + }); + await waitFor(() => { expect(removeWaveDeliveredNotifications).toHaveBeenCalledWith("wave-1"); expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledWith({ endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer test-jwt" }, }); expect(invalidateNotifications).toHaveBeenCalled(); }); }); - it("does not call the read endpoint when the tab is hidden", () => { - setDocumentVisibility("hidden"); - + it("marks the wave as read when enabled", async () => { render( { ); + await waitFor(() => { + expect(removeWaveDeliveredNotifications).toHaveBeenCalledWith("wave-1"); + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer test-jwt" }, + }); + expect(invalidateNotifications).toHaveBeenCalled(); + }); + }); + + it("drops a delayed read after unmount", async () => { + getAuthJwtMock.mockReturnValue(null); + + const { unmount } = render( + + + + ); + + await waitFor(() => { + expect(removeWaveDeliveredNotifications).toHaveBeenCalledWith( + "wave-delayed-unmount" + ); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + + unmount(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + getAuthJwtMock.mockReturnValue("test-jwt"); + + render( + + + + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); expect(invalidateNotifications).not.toHaveBeenCalled(); - expect(removeWaveDeliveredNotifications).not.toHaveBeenCalled(); }); - it("syncs read state when a hidden tab becomes visible", async () => { - setDocumentVisibility("hidden"); + it("drops a delayed read after the visible wave changes", async () => { + getAuthJwtMock.mockReturnValue(null); + + const renderTestComponent = (waveId: string) => ( + + + + ); + + const { rerender } = render(renderTestComponent("wave-delayed-old")); + + await waitFor(() => { + expect(removeWaveDeliveredNotifications).toHaveBeenCalledWith( + "wave-delayed-old" + ); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + + getAuthJwtMock.mockReturnValue("test-jwt"); + rerender(renderTestComponent("wave-delayed-new")); + + await waitFor(() => { + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledTimes(1); + }); + + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-delayed-new/read", + headers: { Authorization: "Bearer test-jwt" }, + }); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-delayed-old/read", + headers: { Authorization: "Bearer test-jwt" }, + }); + }); + + it("drops a delayed read after the tab becomes hidden", async () => { + getAuthJwtMock.mockReturnValue(null); + + const renderTestComponent = () => ( + + + + ); + + const { rerender } = render(renderTestComponent()); + + await waitFor(() => { + expect(removeWaveDeliveredNotifications).toHaveBeenCalledWith( + "wave-delayed-hidden" + ); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + + setDocumentVisibilityState("hidden"); + getAuthJwtMock.mockReturnValue("test-jwt"); + rerender(renderTestComponent()); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("queues a follow-up read when the same visible wave syncs while pending", async () => { + const firstReadRequest = createDeferred(); + apiPostMock.mockReturnValueOnce(firstReadRequest.promise); render( { ); - expect(removeWaveDeliveredNotifications).not.toHaveBeenCalled(); + await waitFor(() => { + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledTimes(1); + }); + + expect(removeWaveDeliveredNotifications).toHaveBeenCalledTimes(1); + expect(invalidateNotifications).not.toHaveBeenCalled(); + + await act(async () => { + dispatchVisibilityChange(); + }); + + await waitFor(() => { + expect(removeWaveDeliveredNotifications).toHaveBeenCalledTimes(2); + }); + + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledTimes(1); + + await act(async () => { + firstReadRequest.resolve(); + await firstReadRequest.promise; + }); + + await waitFor(() => { + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledTimes(2); + }); + + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenNthCalledWith(1, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer test-jwt" }, + }); + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer test-jwt" }, + }); + }); + + it("does not repeat the initial read-sync when a matching proxy loads", async () => { + const firstReadRequest = createDeferred(); + + mockJwtRole("creator-1"); + apiPostMock.mockReturnValueOnce(firstReadRequest.promise); + + const renderTestComponent = () => ( + + + + ); + + const { rerender } = render(renderTestComponent()); + + await waitFor(() => { + expect(removeWaveDeliveredNotifications).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await Promise.resolve(); + }); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + + useAuthMock.mockReturnValue( + createAuthValue( + createActiveProfileProxy({ + id: "proxy-1", + creatorId: "creator-1", + }) + ) + ); + + rerender(renderTestComponent()); - act(() => { - setDocumentVisibility("visible"); - document.dispatchEvent(new Event("visibilitychange")); + await waitFor(() => { + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledTimes(1); + }); + + expect(removeWaveDeliveredNotifications).toHaveBeenCalledTimes(1); + + await act(async () => { + firstReadRequest.resolve(); + await firstReadRequest.promise; }); await waitFor(() => { - expect(removeWaveDeliveredNotifications).toHaveBeenCalledWith("wave-1"); - expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledWith({ + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledTimes(1); + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer test-jwt" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + expect(removeWaveDeliveredNotifications).toHaveBeenCalledTimes(1); + }); + + it("retries with the loaded proxy after a hidden temporary-proxy replay is skipped", async () => { + mockJwtRole("creator-1"); + + const renderTestComponent = () => ( + + + + ); + + const { rerender } = render(renderTestComponent()); + + await waitFor(() => { + expect(removeWaveDeliveredNotifications).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + + setDocumentVisibilityState("hidden"); + useAuthMock.mockReturnValue( + createAuthValue( + createActiveProfileProxy({ + id: "proxy-1", + creatorId: "creator-1", + }) + ) + ); + + rerender(renderTestComponent()); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + + await act(async () => { + setDocumentVisibilityState("visible"); + dispatchVisibilityChange(); + }); + + await waitFor(() => { + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledTimes(1); + }); + + expect(removeWaveDeliveredNotifications).toHaveBeenCalledTimes(2); + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer test-jwt" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("retries with the loaded proxy when the temporary read request fails", async () => { + const firstReadRequest = createDeferred(); + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + + try { + mockJwtRole("creator-1"); + apiPostMock.mockReturnValueOnce(firstReadRequest.promise); + + const renderTestComponent = () => ( + + + + ); + + const { rerender } = render(renderTestComponent()); + + await waitFor(() => { + expect(removeWaveDeliveredNotifications).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + + useAuthMock.mockReturnValue( + createAuthValue( + createActiveProfileProxy({ + id: "proxy-1", + creatorId: "creator-1", + }) + ) + ); + + rerender(renderTestComponent()); + + await waitFor(() => { + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + firstReadRequest.reject(new Error("temporary proxy read failed")); + await firstReadRequest.promise.catch(() => undefined); + }); + + await waitFor(() => { + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledTimes(2); + }); + + await waitFor(() => { + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + expect(removeWaveDeliveredNotifications).toHaveBeenCalledTimes(2); + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenNthCalledWith(1, { endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer test-jwt" }, }); - expect(invalidateNotifications).toHaveBeenCalled(); + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer test-jwt" }, + }); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it("syncs again when the active proxy changes while the same wave stays visible", async () => { + mockJwtRole("creator-1"); + useAuthMock.mockReturnValue( + createAuthValue( + createActiveProfileProxy({ + id: "proxy-1", + creatorId: "creator-1", + }) + ) + ); + + const renderTestComponent = () => ( + + + + ); + + const { rerender } = render(renderTestComponent()); + + await waitFor(() => { + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledTimes(1); + }); + + expect(removeWaveDeliveredNotifications).toHaveBeenCalledTimes(1); + + useAuthMock.mockReturnValue( + createAuthValue( + createActiveProfileProxy({ + id: "proxy-2", + creatorId: "creator-1", + }) + ) + ); + + rerender(renderTestComponent()); + + await waitFor(() => { + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledTimes(2); + }); + + expect(removeWaveDeliveredNotifications).toHaveBeenCalledTimes(2); + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenNthCalledWith(1, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer test-jwt" }, + }); + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer test-jwt" }, }); }); }); diff --git a/__tests__/hooks/useConnectedAccountsUnreadNotifications.test.ts b/__tests__/hooks/useConnectedAccountsUnreadNotifications.test.ts new file mode 100644 index 0000000000..83742ce93a --- /dev/null +++ b/__tests__/hooks/useConnectedAccountsUnreadNotifications.test.ts @@ -0,0 +1,56 @@ +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { useConnectedAccountsUnreadNotifications } from "@/hooks/useConnectedAccountsUnreadNotifications"; +import { renderHook } from "@testing-library/react"; + +const useQueryMock = jest.fn(); +const getQueryDataMock = jest.fn(); + +jest.mock("@tanstack/react-query", () => ({ + useQuery: (params: unknown) => useQueryMock(params), + useQueryClient: () => ({ + getQueryData: getQueryDataMock, + }), +})); + +jest.mock("@/hooks/useCapacitor", () => ({ + __esModule: true, + default: () => ({ isCapacitor: false }), +})); + +jest.mock("@/services/api/common-api", () => ({ + commonApiFetch: jest.fn(), +})); + +describe("useConnectedAccountsUnreadNotifications", () => { + beforeEach(() => { + useQueryMock.mockReset(); + getQueryDataMock.mockReset(); + }); + + it("uses a separate query key from active identity notifications", () => { + useQueryMock.mockReturnValue({ data: {} }); + + renderHook(() => + useConnectedAccountsUnreadNotifications([ + { + address: "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + refreshToken: "refresh-token", + role: null, + jwt: "jwt-token", + profileId: null, + profileHandle: "alice", + }, + ]) + ); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: [ + QueryKey.CONNECTED_ACCOUNT_UNREAD_NOTIFICATIONS, + "connected-account-unread-counts", + ["0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + ], + }) + ); + }); +}); diff --git a/__tests__/hooks/useMarkWaveNotificationsRead.test.tsx b/__tests__/hooks/useMarkWaveNotificationsRead.test.tsx new file mode 100644 index 0000000000..46fc65438c --- /dev/null +++ b/__tests__/hooks/useMarkWaveNotificationsRead.test.tsx @@ -0,0 +1,2632 @@ +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { useAuth } from "@/components/auth/Auth"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; +import { + useMarkWaveNotificationsRead, + useWaveNotificationsReadMarkerState, +} from "@/hooks/useMarkWaveNotificationsRead"; +import { + getWaveReadIdentityKey, + getWaveReadProxyRoleIdentityKey, + getWaveReadProxyRoleRequestKey, + getWaveReadRequestKey, +} from "@/hooks/useMarkWaveNotificationsRead.identity"; +import type { WaveReadVerifiedIdentity } from "@/hooks/useMarkWaveNotificationsRead.identity"; +import { + clearAllWaveReadState, + enqueuePendingWaveReadRequest, + flushPendingClearedWaveReadRequests, + flushPendingWaveReadRequests, + markWaveReadIdentityCleared, +} from "@/hooks/useMarkWaveNotificationsRead.requests"; +import type { WaveReadAddressEpoch } from "@/hooks/useMarkWaveNotificationsRead.types"; +import { commonApiPostWithoutBodyAndResponse } from "@/services/api/common-api"; +import { getAuthJwt } from "@/services/auth/auth.utils"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { jwtDecode } from "jwt-decode"; +import type { ReactNode } from "react"; + +jest.mock("@/components/auth/Auth", () => ({ + useAuth: jest.fn(), +})); + +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(), +})); + +jest.mock("@/services/api/common-api", () => ({ + commonApiPostWithoutBodyAndResponse: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("@/services/auth/auth.utils", () => ({ + getAuthJwt: jest.fn(), +})); + +jest.mock("jwt-decode", () => ({ + jwtDecode: jest.fn(), +})); + +interface Deferred { + promise: Promise; + resolve: () => void; + reject: (error: unknown) => void; +} + +interface JwtPayload { + readonly sub: string; + readonly role: string | null; + readonly exp?: number | undefined; +} + +const getCurrentJwtSecond = (): number => Math.floor(Date.now() / 1000); + +const getFutureJwtExp = (): number => getCurrentJwtSecond() + 60; + +const mockCurrentJwtSecond = (currentSecond: number) => + jest.spyOn(Date, "now").mockReturnValue(currentSecond * 1000); + +const createDeferred = (): Deferred => { + let resolve: () => void = () => {}; + let reject: (error: unknown) => void = () => {}; + + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, resolve, reject }; +}; + +const flushMicrotasks = async (): Promise => { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } +}; + +const trackPromiseSettlement = ( + promise: Promise +): { readonly isSettled: () => boolean } => { + let settled = false; + void promise.then( + () => { + settled = true; + }, + () => { + settled = true; + } + ); + + return { + isSettled: () => settled, + }; +}; + +const apiPostMock = commonApiPostWithoutBodyAndResponse as jest.MockedFunction< + typeof commonApiPostWithoutBodyAndResponse +>; +const useAuthMock = useAuth as jest.MockedFunction; +const useSeizeConnectContextMock = + useSeizeConnectContext as jest.MockedFunction; +const getAuthJwtMock = getAuthJwt as jest.MockedFunction; +const jwtDecodeMock = jwtDecode as jest.MockedFunction; +const jwtPayloadsByToken = new Map(); + +const createWrapper = + (invalidateNotifications: jest.Mock) => + ({ children }: { readonly children: ReactNode }) => ( + + {children} + + ); + +const setActiveIdentity = ({ + address, + jwt, + activeProfileProxyId, + activeProfileProxyCreatorId, + jwtAddress, + jwtRole, + jwtExp, + jwtHasExp, +}: { + readonly address?: string | undefined; + readonly jwt?: string | null | undefined; + readonly activeProfileProxyId?: string | null | undefined; + readonly activeProfileProxyCreatorId?: string | null | undefined; + readonly jwtAddress?: string | undefined; + readonly jwtRole?: string | null | undefined; + readonly jwtExp?: number | undefined; + readonly jwtHasExp?: boolean | undefined; +}) => { + const proxyCreatorId = + activeProfileProxyId != null + ? (activeProfileProxyCreatorId ?? activeProfileProxyId) + : null; + + if (jwt && address) { + const payload: JwtPayload = { + sub: jwtAddress ?? address, + role: jwtRole !== undefined ? jwtRole : proxyCreatorId, + ...((jwtHasExp ?? true) ? { exp: jwtExp ?? getFutureJwtExp() } : {}), + }; + jwtPayloadsByToken.set(jwt, payload); + } + + useSeizeConnectContextMock.mockReturnValue({ address } as any); + useAuthMock.mockReturnValue({ + activeProfileProxy: activeProfileProxyId + ? { id: activeProfileProxyId, created_by: { id: proxyCreatorId } } + : null, + } as any); + getAuthJwtMock.mockReturnValue(jwt ?? null); +}; + +const createAddressEpochState = (): { + readonly addressEpoch: WaveReadAddressEpoch; + readonly latestAddressEpochRef: { current: WaveReadAddressEpoch }; +} => { + const addressEpoch = {}; + return { + addressEpoch, + latestAddressEpochRef: { current: addressEpoch }, + }; +}; + +const createVerifiedIdentity = ({ + addressKey = "0xaaa", + activeProfileProxyId = null, + activeProfileProxyCreatorId = null, + identityKey = getWaveReadIdentityKey({ addressKey, activeProfileProxyId }), + jwt = "jwt-a", +}: { + readonly addressKey?: string | undefined; + readonly activeProfileProxyId?: string | null | undefined; + readonly activeProfileProxyCreatorId?: string | null | undefined; + readonly identityKey?: string | undefined; + readonly jwt?: string | undefined; +} = {}): WaveReadVerifiedIdentity => ({ + addressKey, + activeProfileProxyId, + activeProfileProxyCreatorId, + identityKey, + jwtExpiresAt: getFutureJwtExp(), + authHeaders: { Authorization: `Bearer ${jwt}` }, +}); + +describe("useMarkWaveNotificationsRead", () => { + beforeEach(() => { + apiPostMock.mockReset(); + jwtPayloadsByToken.clear(); + jwtDecodeMock.mockReset(); + jwtDecodeMock.mockImplementation((token) => { + const payload = jwtPayloadsByToken.get(token); + if (!payload) { + throw new Error(`Unexpected JWT decode for ${token}`); + } + return payload as any; + }); + setActiveIdentity({}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("treats a missing active profile proxy as no proxy", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + useAuthMock.mockReturnValue({} as any); + + const { result } = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + await expect(result.current("wave-1")).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + }); + + it("does not queue a read before the wallet address is known", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ address: undefined, jwt: null }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const noAddressPromise = result.current("wave-1"); + + await expect(noAddressPromise).resolves.toBe("skipped"); + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + + await expect(result.current("wave-1")).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("sends one trailing read after two calls for the same wave", async () => { + const firstRequest = createDeferred(); + const trailingRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + + apiPostMock + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(trailingRequest.promise); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + const { result } = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + const firstPromise = result.current("wave-1"); + const secondPromise = result.current("wave-1"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + + firstRequest.resolve(); + + await waitFor(() => { + expect(apiPostMock).toHaveBeenCalledTimes(2); + }); + expect(apiPostMock).toHaveBeenLastCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + + trailingRequest.resolve(); + + await expect(firstPromise).resolves.toBe("sent"); + await expect(secondPromise).resolves.toBe("sent"); + expect(invalidateNotifications).toHaveBeenCalledTimes(2); + }); + + it("collapses repeated same-wave calls into one trailing read", async () => { + const firstRequest = createDeferred(); + const trailingRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + + apiPostMock + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(trailingRequest.promise); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + const { result } = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + const promises = [ + result.current("wave-1"), + result.current("wave-1"), + result.current("wave-1"), + result.current("wave-1"), + ]; + + expect(apiPostMock).toHaveBeenCalledTimes(1); + + firstRequest.resolve(); + + await waitFor(() => { + expect(apiPostMock).toHaveBeenCalledTimes(2); + }); + + trailingRequest.resolve(); + + await expect(Promise.all(promises)).resolves.toEqual([ + "sent", + "sent", + "sent", + "sent", + ]); + + expect(apiPostMock).toHaveBeenCalledTimes(2); + expect(invalidateNotifications).toHaveBeenCalledTimes(2); + }); + + it("keeps the callback stable across JWT refresh and uses the refreshed JWT", async () => { + const invalidateNotifications = jest.fn(); + + apiPostMock.mockResolvedValueOnce(undefined); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-old", + activeProfileProxyId: "proxy-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const firstCallback = result.current; + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-new", + activeProfileProxyId: "proxy-1", + }); + rerender(); + + expect(result.current).toBe(firstCallback); + + await expect(result.current("wave-1")).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-new" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("uses a refreshed JWT for a trailing same-identity read", async () => { + const firstRequest = createDeferred(); + const trailingRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + + apiPostMock + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(trailingRequest.promise); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-old", + activeProfileProxyId: "proxy-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const firstCallback = result.current; + + const firstPromise = result.current("wave-1"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenNthCalledWith(1, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-old" }, + }); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-new", + activeProfileProxyId: "proxy-1", + }); + rerender(); + + expect(result.current).toBe(firstCallback); + + const secondPromise = result.current("wave-1"); + + expect(secondPromise).toBe(firstPromise); + expect(apiPostMock).toHaveBeenCalledTimes(1); + + firstRequest.resolve(); + + await waitFor(() => { + expect(apiPostMock).toHaveBeenCalledTimes(2); + }); + expect(apiPostMock).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-new" }, + }); + + trailingRequest.resolve(); + + await expect(firstPromise).resolves.toBe("sent"); + await expect(secondPromise).resolves.toBe("sent"); + expect(invalidateNotifications).toHaveBeenCalledTimes(2); + }); + + it("rejects an old cached account callback after switching accounts", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const accountCallback = result.current; + + setActiveIdentity({ address: "0xBBB", jwt: "jwt-b" }); + rerender(); + + expect(result.current).not.toBe(accountCallback); + + await expect(accountCallback("wave-1")).rejects.toThrow( + "wallet address changed or disconnected" + ); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("skips an old cached account callback after switching accounts when queueing is disabled", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const accountCallback = result.current; + + setActiveIdentity({ address: "0xBBB", jwt: "jwt-b" }); + rerender(); + + expect(result.current).not.toBe(accountCallback); + + await expect( + accountCallback("wave-1", { queueIfBlocked: false }) + ).resolves.toBe("skipped"); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("keeps same-wave read requests separate across active account switches", async () => { + const firstAccountRequest = createDeferred(); + const secondAccountRequest = createDeferred(); + const firstAccountLaterRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + + apiPostMock + .mockReturnValueOnce(firstAccountRequest.promise) + .mockReturnValueOnce(secondAccountRequest.promise) + .mockReturnValueOnce(firstAccountLaterRequest.promise); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a-first" }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const firstAccountPromise = result.current("wave-1"); + + setActiveIdentity({ address: "0xBBB", jwt: "jwt-b" }); + rerender(); + const secondAccountPromise = result.current("wave-1"); + + expect(apiPostMock).toHaveBeenCalledTimes(2); + expect(apiPostMock).toHaveBeenNthCalledWith(1, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-a-first" }, + }); + expect(apiPostMock).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-b" }, + }); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a-later" }); + rerender(); + const firstAccountQueuedPromise = result.current("wave-1"); + + expect(apiPostMock).toHaveBeenCalledTimes(3); + expect(apiPostMock).toHaveBeenNthCalledWith(3, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-a-later" }, + }); + + firstAccountRequest.resolve(); + secondAccountRequest.resolve(); + firstAccountLaterRequest.resolve(); + + await expect(firstAccountPromise).resolves.toBe("sent"); + await expect(firstAccountQueuedPromise).resolves.toBe("sent"); + await expect(secondAccountPromise).resolves.toBe("sent"); + }); + + it("keeps same-wave read requests separate across active proxy switches", async () => { + const firstProxyRequest = createDeferred(); + const secondProxyRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + + apiPostMock + .mockReturnValueOnce(firstProxyRequest.promise) + .mockReturnValueOnce(secondProxyRequest.promise); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-1", + activeProfileProxyId: "proxy-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const firstProxyCallback = result.current; + + const firstProxyPromise = result.current("wave-1"); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-2", + activeProfileProxyId: "proxy-2", + }); + rerender(); + + expect(result.current).not.toBe(firstProxyCallback); + + const secondProxyPromise = result.current("wave-1"); + + expect(apiPostMock).toHaveBeenCalledTimes(2); + expect(apiPostMock).toHaveBeenNthCalledWith(1, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-1" }, + }); + expect(apiPostMock).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-2" }, + }); + + firstProxyRequest.resolve(); + secondProxyRequest.resolve(); + + await expect(firstProxyPromise).resolves.toBe("sent"); + await expect(secondProxyPromise).resolves.toBe("sent"); + expect(invalidateNotifications).toHaveBeenCalledTimes(2); + }); + + it("uses the old proxy JWT when an old proxy callback queues a trailing read after switching proxies", async () => { + const firstRequest = createDeferred(); + const trailingRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + + apiPostMock + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(trailingRequest.promise); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-1", + activeProfileProxyId: "proxy-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const firstProxyCallback = result.current; + + const firstProxyPromise = firstProxyCallback("wave-1"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenNthCalledWith(1, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-1" }, + }); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-2", + activeProfileProxyId: "proxy-2", + }); + rerender(); + + expect(result.current).not.toBe(firstProxyCallback); + + const queuedProxyPromise = firstProxyCallback("wave-1"); + + expect(queuedProxyPromise).toBe(firstProxyPromise); + expect(apiPostMock).toHaveBeenCalledTimes(1); + + firstRequest.resolve(); + + await waitFor(() => { + expect(apiPostMock).toHaveBeenCalledTimes(2); + }); + expect(apiPostMock).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-1" }, + }); + + trailingRequest.resolve(); + + await expect(firstProxyPromise).resolves.toBe("sent"); + await expect(queuedProxyPromise).resolves.toBe("sent"); + expect(invalidateNotifications).toHaveBeenCalledTimes(2); + }); + + it("does not flush a cleared primary read with proxy auth", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-primary-old", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + setActiveIdentity({ + address: "0xAAA", + jwt: null, + }); + rerender(); + + const queuedPromise = result.current("wave-1"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-1", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-primary-new", + }); + rerender(); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-primary-new" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("flushes a cleared proxy read only when the same proxy is verified", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-1", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + setActiveIdentity({ + address: "0xAAA", + jwt: null, + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + const queuedPromise = result.current("wave-1"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-2", + activeProfileProxyId: "proxy-2", + activeProfileProxyCreatorId: "creator-2", + }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-1-new", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-1-new" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("does not use another proxy auth for a stale callback from a cleared proxy", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-1", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const firstProxyCallback = result.current; + + setActiveIdentity({ + address: "0xAAA", + jwt: null, + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-2", + activeProfileProxyId: "proxy-2", + activeProfileProxyCreatorId: "creator-2", + }); + rerender(); + + expect(result.current).not.toBe(firstProxyCallback); + + const queuedPromise = firstProxyCallback("wave-1"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-1-new", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-1-new" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("rejects a cleared queued proxy read on wallet address switch", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-a-proxy-1", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + setActiveIdentity({ + address: "0xAAA", + jwt: null, + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + const firstAccountPromise = result.current("wave-1"); + const rejection = expect(firstAccountPromise).rejects.toThrow( + "wallet address changed or disconnected" + ); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xBBB", + jwt: "jwt-b-proxy-2", + activeProfileProxyId: "proxy-2", + activeProfileProxyCreatorId: "creator-2", + }); + rerender(); + + await rejection; + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-a-proxy-2", + activeProfileProxyId: "proxy-2", + activeProfileProxyCreatorId: "creator-2", + }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-a-proxy-1-new", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("queues a trailing read after auth is cleared and uses the next verified JWT", async () => { + const firstRequest = createDeferred(); + const trailingRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + + apiPostMock + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(trailingRequest.promise); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-old", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const firstProxyCallback = result.current; + + const firstProxyPromise = firstProxyCallback("wave-1"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenNthCalledWith(1, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-old" }, + }); + + setActiveIdentity({ + address: "0xAAA", + jwt: null, + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + expect(result.current).toBe(firstProxyCallback); + + const queuedProxyPromise = firstProxyCallback("wave-1"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-new", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + + firstRequest.resolve(); + + await waitFor(() => { + expect(apiPostMock).toHaveBeenCalledTimes(2); + }); + expect(apiPostMock).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-new" }, + }); + + trailingRequest.resolve(); + + await expect(firstProxyPromise).resolves.toBe("sent"); + await expect(queuedProxyPromise).resolves.toBe("sent"); + expect(invalidateNotifications).toHaveBeenCalledTimes(2); + }); + + it("does not use cached headers after auth is cleared", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-old", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const firstCallback = result.current; + + setActiveIdentity({ + address: "0xAAA", + jwt: null, + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + expect(result.current).toBe(firstCallback); + + const queuedPromise = result.current("wave-1"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-new", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-new" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("queues a read while the JWT is missing and sends it when the JWT is verified", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ address: "0xAAA", jwt: null }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const firstCallback = result.current; + + const queuedPromise = result.current("wave-1"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + rerender(); + + expect(result.current).toBe(firstCallback); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("does not send a read with a JWT that is missing exp", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-missing-exp", + jwtHasExp: false, + }); + const { result } = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + await expect( + result.current("wave-missing-exp", { queueIfBlocked: false }) + ).resolves.toBe("skipped"); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("does not send a read with an expired JWT", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-expired", + jwtExp: getCurrentJwtSecond(), + }); + const { result } = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + await expect( + result.current("wave-expired", { queueIfBlocked: false }) + ).resolves.toBe("skipped"); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("sends a queued read after an expired JWT is replaced by a fresh JWT", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-expired", + jwtExp: getCurrentJwtSecond(), + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const queuedPromise = result.current("wave-expired-replaced"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-fresh" }); + rerender(); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-expired-replaced/read", + headers: { Authorization: "Bearer jwt-fresh" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("skips an account read when cached JWT expires and queueing is disabled", async () => { + const invalidateNotifications = jest.fn(); + const currentSecond = 1_700_000_000; + const jwtExpiresAt = currentSecond + 10; + const dateNowSpy = mockCurrentJwtSecond(currentSecond); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-cached", + jwtExp: jwtExpiresAt, + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + dateNowSpy.mockReturnValue(jwtExpiresAt * 1000); + + await expect( + result.current("wave-cached-expired", { queueIfBlocked: false }) + ).resolves.toBe("skipped"); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-fresh", + jwtExp: jwtExpiresAt + 60, + }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("queues an account read after cached JWT expiry and flushes it with a fresh JWT", async () => { + const invalidateNotifications = jest.fn(); + const currentSecond = 1_700_000_100; + const jwtExpiresAt = currentSecond + 10; + const dateNowSpy = mockCurrentJwtSecond(currentSecond); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-old", + jwtExp: jwtExpiresAt, + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + dateNowSpy.mockReturnValue(jwtExpiresAt * 1000); + + const queuedPromise = result.current("wave-cached-refresh"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-fresh", + jwtExp: jwtExpiresAt + 60, + }); + rerender(); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-cached-refresh/read", + headers: { Authorization: "Bearer jwt-fresh" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("queues a proxy-role read after cached proxy JWT expiry and flushes it with a fresh proxy JWT", async () => { + const invalidateNotifications = jest.fn(); + const currentSecond = 1_700_000_200; + const jwtExpiresAt = currentSecond + 10; + const dateNowSpy = mockCurrentJwtSecond(currentSecond); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-old", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + jwtExp: jwtExpiresAt, + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + dateNowSpy.mockReturnValue(jwtExpiresAt * 1000); + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-role-fresh", + jwtRole: "creator-1", + jwtExp: jwtExpiresAt + 60, + }); + rerender(); + + const queuedPromise = result.current("wave-proxy-cached-expired"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-fresh", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + jwtExp: jwtExpiresAt + 60, + }); + rerender(); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-proxy-cached-expired/read", + headers: { Authorization: "Bearer jwt-proxy-fresh" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("rejects a queued read when the wallet disconnects", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ address: "0xAAA", jwt: null }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const queuedPromise = result.current("wave-disconnect"); + const rejection = expect(queuedPromise).rejects.toThrow( + "wallet address changed or disconnected" + ); + + setActiveIdentity({ address: undefined, jwt: null }); + rerender(); + + await rejection; + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("rejects a queued read when the wallet address switches", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ address: "0xAAA", jwt: null }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const queuedPromise = result.current("wave-address-switch"); + const rejection = expect(queuedPromise).rejects.toThrow( + "wallet address changed or disconnected" + ); + + setActiveIdentity({ address: "0xBBB", jwt: "jwt-b" }); + rerender(); + + await rejection; + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("clears queued reads when the last marker hook unmounts", async () => { + jest.useFakeTimers(); + const invalidateNotifications = jest.fn(); + + try { + setActiveIdentity({ address: "0xAAA", jwt: null }); + const { result, unmount } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const queuedPromise = result.current("wave-last-unmount"); + const queuedSettlement = trackPromiseSettlement(queuedPromise); + const rejection = expect(queuedPromise).rejects.toThrow( + "no marker hooks are mounted" + ); + + unmount(); + await flushMicrotasks(); + + expect(queuedSettlement.isSettled()).toBe(false); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + await rejection; + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it("keeps queued reads when one marker hook unmounts and another remains mounted", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ address: "0xAAA", jwt: null }); + const firstHook = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + const secondHook = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + const queuedPromise = firstHook.result.current("wave-one-unmount"); + const resolved = expect(queuedPromise).resolves.toBe("sent"); + + firstHook.unmount(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + secondHook.rerender(); + + await resolved; + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-one-unmount/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("keeps queued reads when a marker hook remounts before deferred cleanup runs", async () => { + jest.useFakeTimers(); + const invalidateNotifications = jest.fn(); + + try { + setActiveIdentity({ address: "0xAAA", jwt: null }); + const firstHook = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + const queuedPromise = firstHook.result.current("wave-remount"); + const queuedSettlement = trackPromiseSettlement(queuedPromise); + const resolved = expect(queuedPromise).resolves.toBe("sent"); + + firstHook.unmount(); + + const secondHook = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + act(() => { + jest.runOnlyPendingTimers(); + }); + await flushMicrotasks(); + + expect(queuedSettlement.isSettled()).toBe(false); + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + secondHook.rerender(); + + await resolved; + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-remount/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + + secondHook.unmount(); + act(() => { + jest.runOnlyPendingTimers(); + }); + } finally { + jest.useRealTimers(); + } + }); + + it("rejects queued reads when a different wallet remounts before deferred cleanup runs", async () => { + jest.useFakeTimers(); + const invalidateNotifications = jest.fn(); + + try { + setActiveIdentity({ address: "0xAAA", jwt: null }); + const firstHook = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + const queuedPromise = firstHook.result.current( + "wave-remount-address-switch" + ); + const rejection = expect(queuedPromise).rejects.toThrow( + "wallet address changed or disconnected" + ); + + firstHook.unmount(); + + setActiveIdentity({ address: "0xBBB", jwt: null }); + const secondHook = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + await rejection; + + act(() => { + jest.runOnlyPendingTimers(); + }); + await flushMicrotasks(); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + secondHook.rerender(); + await flushMicrotasks(); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + + secondHook.unmount(); + act(() => { + jest.runOnlyPendingTimers(); + }); + } finally { + jest.useRealTimers(); + } + }); + + it("drops a queued missing-JWT read when its guard becomes false before replay", async () => { + const invalidateNotifications = jest.fn(); + let shouldSend = true; + + setActiveIdentity({ address: "0xAAA", jwt: null }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const queuedPromise = result.current("wave-guard-missing-jwt", { + shouldSend: () => shouldSend, + }); + + expect(apiPostMock).not.toHaveBeenCalled(); + + shouldSend = false; + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + rerender(); + + await expect(queuedPromise).resolves.toBe("skipped"); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("sends one merged queued read when at least one guard still allows it", async () => { + const invalidateNotifications = jest.fn(); + let firstShouldSend = true; + let secondShouldSend = true; + + setActiveIdentity({ address: "0xAAA", jwt: null }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const firstPromise = result.current("wave-guard-merged", { + shouldSend: () => firstShouldSend, + }); + const secondPromise = result.current("wave-guard-merged", { + shouldSend: () => secondShouldSend, + }); + + expect(secondPromise).toBe(firstPromise); + expect(apiPostMock).not.toHaveBeenCalled(); + + firstShouldSend = false; + secondShouldSend = true; + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + rerender(); + + await expect(firstPromise).resolves.toBe("sent"); + await expect(secondPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-guard-merged/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("drops a trailing same-wave replay when its guard becomes false", async () => { + const firstRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + let shouldSendReplay = true; + + apiPostMock.mockReturnValueOnce(firstRequest.promise); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + const { result } = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + const firstPromise = result.current("wave-guard-trailing"); + const trailingPromise = result.current("wave-guard-trailing", { + shouldSend: () => shouldSendReplay, + }); + + expect(trailingPromise).toBe(firstPromise); + expect(apiPostMock).toHaveBeenCalledTimes(1); + + shouldSendReplay = false; + firstRequest.resolve(); + + await expect(firstPromise).resolves.toBe("sent"); + await expect(trailingPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("does not queue a blocked read when queueIfBlocked is false", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ address: "0xAAA", jwt: null }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const blockedPromise = result.current("wave-no-queue", { + queueIfBlocked: false, + }); + + await expect(blockedPromise).resolves.toBe("skipped"); + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("queues a proxy read by JWT role until the matching proxy loads", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-loading", + jwtRole: "creator-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const queuedPromise = result.current("wave-1"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-other", + activeProfileProxyId: "proxy-2", + activeProfileProxyCreatorId: "creator-2", + }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-loading", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-loading" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("waits for fresh auth when proxy data loads after the role JWT expires", async () => { + const invalidateNotifications = jest.fn(); + const currentSecond = 1_700_000_300; + const jwtExpiresAt = currentSecond + 10; + const dateNowSpy = mockCurrentJwtSecond(currentSecond); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-loading-old", + jwtRole: "creator-1", + jwtExp: jwtExpiresAt, + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const queuedPromise = result.current("wave-proxy-load-after-expiry"); + const queuedSettlement = trackPromiseSettlement(queuedPromise); + + expect(apiPostMock).not.toHaveBeenCalled(); + + dateNowSpy.mockReturnValue(jwtExpiresAt * 1000); + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-loading-old", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + jwtExp: jwtExpiresAt, + }); + rerender(); + await flushMicrotasks(); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(queuedSettlement.isSettled()).toBe(false); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-loading-fresh", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + jwtExp: jwtExpiresAt + 60, + }); + rerender(); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-proxy-load-after-expiry/read", + headers: { Authorization: "Bearer jwt-proxy-loading-fresh" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("flushes a requeued trailing same-wave read when fresh auth is already cached", async () => { + const firstRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + const currentSecond = 1_700_000_350; + const oldJwtExpiresAt = currentSecond + 10; + const freshJwtExpiresAt = oldJwtExpiresAt + 60; + const dateNowSpy = mockCurrentJwtSecond(currentSecond); + + apiPostMock.mockReturnValueOnce(firstRequest.promise); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-requeued-old", + jwtExp: oldJwtExpiresAt, + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const firstPromise = result.current("wave-requeued-cached-auth"); + const firstSettlement = trackPromiseSettlement(firstPromise); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenNthCalledWith(1, { + endpoint: "notifications/wave/wave-requeued-cached-auth/read", + headers: { Authorization: "Bearer jwt-requeued-old" }, + }); + + const queuedPromise = result.current("wave-requeued-cached-auth"); + const queuedSettlement = trackPromiseSettlement(queuedPromise); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-requeued-fresh", + jwtExp: freshJwtExpiresAt, + }); + rerender(); + await flushMicrotasks(); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(firstSettlement.isSettled()).toBe(false); + expect(queuedSettlement.isSettled()).toBe(false); + + dateNowSpy.mockReturnValue(oldJwtExpiresAt * 1000); + firstRequest.resolve(); + + await waitFor(() => { + expect(apiPostMock).toHaveBeenCalledTimes(2); + }); + expect(apiPostMock).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-requeued-cached-auth/read", + headers: { Authorization: "Bearer jwt-requeued-fresh" }, + }); + + await expect(firstPromise).resolves.toBe("sent"); + await expect(queuedPromise).resolves.toBe("sent"); + expect(invalidateNotifications).toHaveBeenCalledTimes(2); + }); + + it("requeues a trailing same-wave read when fresh auth expires before the trailing send", async () => { + const firstRequest = createDeferred(); + const trailingRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + const currentSecond = 1_700_000_400; + const oldJwtExpiresAt = currentSecond + 10; + const temporaryJwtExpiresAt = oldJwtExpiresAt + 10; + const dateNowSpy = mockCurrentJwtSecond(currentSecond); + + apiPostMock + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(trailingRequest.promise); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-trailing-old", + jwtExp: oldJwtExpiresAt, + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const firstPromise = result.current("wave-trailing-expiry"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenNthCalledWith(1, { + endpoint: "notifications/wave/wave-trailing-expiry/read", + headers: { Authorization: "Bearer jwt-trailing-old" }, + }); + + dateNowSpy.mockReturnValue(oldJwtExpiresAt * 1000); + const queuedPromise = result.current("wave-trailing-expiry"); + const firstSettlement = trackPromiseSettlement(firstPromise); + const queuedSettlement = trackPromiseSettlement(queuedPromise); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-trailing-temporary", + jwtExp: temporaryJwtExpiresAt, + }); + rerender(); + await flushMicrotasks(); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + + dateNowSpy.mockReturnValue(temporaryJwtExpiresAt * 1000); + firstRequest.resolve(); + await flushMicrotasks(); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(firstSettlement.isSettled()).toBe(false); + expect(queuedSettlement.isSettled()).toBe(false); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-trailing-newest", + jwtExp: temporaryJwtExpiresAt + 60, + }); + rerender(); + + await waitFor(() => { + expect(apiPostMock).toHaveBeenCalledTimes(2); + }); + expect(apiPostMock).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-trailing-expiry/read", + headers: { Authorization: "Bearer jwt-trailing-newest" }, + }); + + trailingRequest.resolve(); + + await expect(firstPromise).resolves.toBe("sent"); + await expect(queuedPromise).resolves.toBe("sent"); + expect(invalidateNotifications).toHaveBeenCalledTimes(2); + }); + + it("does not create a proxy-role identity from an expired JWT role", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-expired-role", + jwtRole: "creator-1", + jwtExp: getCurrentJwtSecond(), + }); + const { result } = renderHook(() => useWaveNotificationsReadMarkerState(), { + wrapper: createWrapper(invalidateNotifications), + }); + + expect(result.current.proxyRoleIdentityKey).toBeNull(); + await expect( + result.current.markWaveNotificationsRead("wave-expired-role", { + queueIfBlocked: false, + }) + ).resolves.toBe("skipped"); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("drops a queued proxy-role read when its guard becomes stale before the proxy loads", async () => { + const invalidateNotifications = jest.fn(); + let shouldSend = true; + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-loading", + jwtRole: "creator-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const queuedPromise = result.current("wave-proxy-guard-stale", { + shouldSend: () => shouldSend, + }); + + expect(apiPostMock).not.toHaveBeenCalled(); + + shouldSend = false; + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-loading", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + await expect(queuedPromise).resolves.toBe("skipped"); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("keeps a queued proxy-role read tied to the JWT role that created its callback", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-role-1", + jwtRole: "creator-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const creatorOneCallback = result.current; + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-role-2", + jwtRole: "creator-2", + }); + rerender(); + + expect(result.current).not.toBe(creatorOneCallback); + + const queuedPromise = creatorOneCallback("wave-1"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-2", + activeProfileProxyId: "proxy-2", + activeProfileProxyCreatorId: "creator-2", + }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-1", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-1" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("rejects an old temporary proxy-role callback after wallet address switch", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-role-1", + jwtRole: "creator-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const oldRoleCallback = result.current; + + setActiveIdentity({ address: "0xBBB", jwt: "jwt-b" }); + rerender(); + + expect(result.current).not.toBe(oldRoleCallback); + + await expect(oldRoleCallback("wave-1")).rejects.toThrow( + "wallet address changed or disconnected" + ); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-1", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("flushes queued proxy-role and loaded-proxy reads for the same wave with one request", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-role-1", + jwtRole: "creator-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const roleQueuedPromise = result.current("wave-1"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: null, + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + const proxyQueuedPromise = result.current("wave-1"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-1", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + await expect(roleQueuedPromise).resolves.toBe("sent"); + await expect(proxyQueuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-1" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("collapses repeated queued same-wave reads into one request", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ address: "0xAAA", jwt: null }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const firstPromise = result.current("wave-1"); + const secondPromise = result.current("wave-1"); + const thirdPromise = result.current("wave-1"); + + expect(secondPromise).toBe(firstPromise); + expect(thirdPromise).toBe(firstPromise); + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + rerender(); + + await expect(firstPromise).resolves.toBe("sent"); + await expect(secondPromise).resolves.toBe("sent"); + await expect(thirdPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("does not replace a cached account header with another account token", async () => { + const firstRequest = createDeferred(); + const trailingRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + + apiPostMock + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(trailingRequest.promise); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const firstPromise = result.current("wave-1"); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-b", + jwtAddress: "0xBBB", + }); + rerender(); + + const secondPromise = result.current("wave-1"); + + firstRequest.resolve(); + + await waitFor(() => { + expect(apiPostMock).toHaveBeenCalledTimes(2); + }); + expect(apiPostMock).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + + trailingRequest.resolve(); + + await expect(firstPromise).resolves.toBe("sent"); + await expect(secondPromise).resolves.toBe("sent"); + }); + + it("queues a read when the JWT belongs to another account and sends it after the matching JWT appears", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-b", + jwtAddress: "0xBBB", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const firstCallback = result.current; + + const queuedPromise = result.current("wave-1"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + rerender(); + + expect(result.current).toBe(firstCallback); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("rejects an old missing-auth account callback after wallet address switch", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ address: "0xAAA", jwt: null }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const oldAccountCallback = result.current; + + setActiveIdentity({ address: "0xBBB", jwt: "jwt-b" }); + rerender(); + + expect(result.current).not.toBe(oldAccountCallback); + + await expect(oldAccountCallback("wave-1")).rejects.toThrow( + "wallet address changed or disconnected" + ); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("does not replay a queued account read after the wallet address switches back", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ address: "0xAAA", jwt: null }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const firstAccountPromise = result.current("wave-1"); + const rejection = expect(firstAccountPromise).rejects.toThrow( + "wallet address changed or disconnected" + ); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xBBB", jwt: "jwt-b" }); + rerender(); + + await rejection; + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + rerender(); + + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("does not replace a cached proxy header with another proxy token", async () => { + const firstRequest = createDeferred(); + const trailingRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + + apiPostMock + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(trailingRequest.promise); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-1", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const firstPromise = result.current("wave-1"); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-2", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + jwtRole: "creator-2", + }); + rerender(); + + const secondPromise = result.current("wave-1"); + + firstRequest.resolve(); + + await waitFor(() => { + expect(apiPostMock).toHaveBeenCalledTimes(2); + }); + expect(apiPostMock).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-1" }, + }); + + trailingRequest.resolve(); + + await expect(firstPromise).resolves.toBe("sent"); + await expect(secondPromise).resolves.toBe("sent"); + }); + + it("queues a read when the JWT belongs to another proxy and sends it after the matching JWT appears", async () => { + const invalidateNotifications = jest.fn(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-2", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + jwtRole: "creator-2", + }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + const firstCallback = result.current; + + const queuedPromise = result.current("wave-1"); + + expect(apiPostMock).not.toHaveBeenCalled(); + + setActiveIdentity({ + address: "0xAAA", + jwt: "jwt-proxy-1", + activeProfileProxyId: "proxy-1", + activeProfileProxyCreatorId: "creator-1", + }); + rerender(); + + expect(result.current).toBe(firstCallback); + + await expect(queuedPromise).resolves.toBe("sent"); + + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-proxy-1" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("does not send a trailing same-wave read after switching accounts", async () => { + const firstRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + + apiPostMock.mockReturnValueOnce(firstRequest.promise); + + setActiveIdentity({ address: "0xBBB", jwt: "jwt-b" }); + const { result, rerender } = renderHook( + () => useMarkWaveNotificationsRead(), + { + wrapper: createWrapper(invalidateNotifications), + } + ); + + const firstPromise = result.current("wave-1"); + const secondPromise = result.current("wave-1"); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + rerender(); + firstRequest.resolve(); + + await expect(firstPromise).resolves.toBe("sent"); + await expect(secondPromise).resolves.toBe("sent"); + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("resolves queued same-wave calls when a failed first read is replayed successfully", async () => { + const firstRequest = createDeferred(); + const trailingRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + + apiPostMock + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(trailingRequest.promise); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + const { result } = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + const firstPromise = result.current("wave-1"); + const secondPromise = result.current("wave-1"); + + firstRequest.reject(new Error("first read failed")); + + await waitFor(() => { + expect(apiPostMock).toHaveBeenCalledTimes(2); + }); + expect(invalidateNotifications).not.toHaveBeenCalled(); + + trailingRequest.resolve(); + + await expect(firstPromise).resolves.toBe("sent"); + await expect(secondPromise).resolves.toBe("sent"); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + }); + + it("rejects a failed read when its queued replay is skipped", async () => { + const firstRequest = createDeferred(); + const readError = new Error("first read failed"); + const invalidateNotifications = jest.fn(); + let shouldSend = true; + + apiPostMock.mockReturnValueOnce(firstRequest.promise); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + const { result } = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + const firstPromise = result.current("wave-1"); + const secondPromise = result.current("wave-1", { + shouldSend: () => shouldSend, + }); + const rejection = expect(firstPromise).rejects.toBe(readError); + + expect(secondPromise).toBe(firstPromise); + expect(apiPostMock).toHaveBeenCalledTimes(1); + + shouldSend = false; + firstRequest.reject(readError); + + await rejection; + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("rejects a failed read when no replay is queued", async () => { + const readError = new Error("read failed"); + const invalidateNotifications = jest.fn(); + + apiPostMock.mockRejectedValueOnce(readError); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + const { result } = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + await expect(result.current("wave-1")).rejects.toBe(readError); + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(invalidateNotifications).not.toHaveBeenCalled(); + }); + + it("rejects a queued missing-JWT read that becomes stale before JWT verification", async () => { + const invalidateNotifications = jest.fn(); + const addressKey = "0xaaa"; + const waveId = "wave-stale-missing-jwt"; + const identityKey = getWaveReadIdentityKey({ + addressKey, + activeProfileProxyId: null, + }); + const requestKey = getWaveReadRequestKey({ + addressKey, + activeProfileProxyId: null, + waveId, + }); + const { addressEpoch, latestAddressEpochRef } = createAddressEpochState(); + + try { + const queuedPromise = enqueuePendingWaveReadRequest({ + addressKey, + activeProfileProxyId: null, + proxyCreatorId: null, + identityKey, + requestKey, + waveId, + addressEpoch, + latestAddressEpochRef, + shouldSend: undefined, + queueIfBlocked: true, + }); + const rejection = expect(queuedPromise).rejects.toThrow( + "wallet address changed or disconnected" + ); + + latestAddressEpochRef.current = {}; + flushPendingWaveReadRequests({ + verifiedIdentity: createVerifiedIdentity({ addressKey, identityKey }), + invalidateNotificationsRef: { current: invalidateNotifications }, + }); + + await rejection; + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + } finally { + clearAllWaveReadState(); + } + }); + + it("sends a fresh queued read when a stale same-wave proxy-role read is dropped", async () => { + const invalidateNotifications = jest.fn(); + const addressKey = "0xaaa"; + const waveId = "wave-mixed-stale-fresh"; + const proxyCreatorId = "creator-1"; + const activeProfileProxyId = "proxy-1"; + const proxyRoleIdentityKey = getWaveReadProxyRoleIdentityKey({ + addressKey, + proxyCreatorId, + }); + const proxyRoleRequestKey = getWaveReadProxyRoleRequestKey({ + addressKey, + proxyCreatorId, + waveId, + }); + const verifiedIdentity = createVerifiedIdentity({ + addressKey, + activeProfileProxyId, + activeProfileProxyCreatorId: proxyCreatorId, + jwt: "jwt-proxy-1", + }); + const loadedProxyRequestKey = getWaveReadRequestKey({ + addressKey, + activeProfileProxyId, + waveId, + }); + const staleEpochState = createAddressEpochState(); + const freshEpochState = createAddressEpochState(); + + try { + const stalePromise = enqueuePendingWaveReadRequest({ + addressKey, + activeProfileProxyId: null, + proxyCreatorId, + identityKey: proxyRoleIdentityKey, + requestKey: proxyRoleRequestKey, + waveId, + addressEpoch: staleEpochState.addressEpoch, + latestAddressEpochRef: staleEpochState.latestAddressEpochRef, + shouldSend: undefined, + queueIfBlocked: true, + }); + const freshPromise = enqueuePendingWaveReadRequest({ + addressKey, + activeProfileProxyId, + proxyCreatorId: null, + identityKey: verifiedIdentity.identityKey, + requestKey: loadedProxyRequestKey, + waveId, + addressEpoch: freshEpochState.addressEpoch, + latestAddressEpochRef: freshEpochState.latestAddressEpochRef, + shouldSend: undefined, + queueIfBlocked: true, + }); + const staleRejection = expect(stalePromise).rejects.toThrow( + "wallet address changed or disconnected" + ); + + staleEpochState.latestAddressEpochRef.current = {}; + flushPendingWaveReadRequests({ + verifiedIdentity, + invalidateNotificationsRef: { current: invalidateNotifications }, + }); + + await staleRejection; + await expect(freshPromise).resolves.toBe("sent"); + expect(apiPostMock).toHaveBeenCalledTimes(1); + expect(apiPostMock).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-mixed-stale-fresh/read", + headers: { Authorization: "Bearer jwt-proxy-1" }, + }); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + } finally { + clearAllWaveReadState(); + } + }); + + it("rejects a stale queued read on the cleared-auth flush path", async () => { + const invalidateNotifications = jest.fn(); + const addressKey = "0xaaa"; + const waveId = "wave-stale-cleared-auth"; + const verifiedIdentity = createVerifiedIdentity({ addressKey }); + const requestKey = getWaveReadRequestKey({ + addressKey, + activeProfileProxyId: null, + waveId, + }); + const { addressEpoch, latestAddressEpochRef } = createAddressEpochState(); + + try { + markWaveReadIdentityCleared(verifiedIdentity); + const queuedPromise = enqueuePendingWaveReadRequest({ + addressKey, + activeProfileProxyId: null, + proxyCreatorId: null, + identityKey: verifiedIdentity.identityKey, + requestKey, + waveId, + addressEpoch, + latestAddressEpochRef, + shouldSend: undefined, + queueIfBlocked: true, + }); + const rejection = expect(queuedPromise).rejects.toThrow( + "wallet address changed or disconnected" + ); + + latestAddressEpochRef.current = {}; + flushPendingClearedWaveReadRequests({ + verifiedIdentity, + invalidateNotificationsRef: { current: invalidateNotifications }, + }); + + await rejection; + expect(apiPostMock).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + } finally { + clearAllWaveReadState(); + } + }); + + it("reads different waves independently", async () => { + const waveOneRequest = createDeferred(); + const waveTwoRequest = createDeferred(); + const invalidateNotifications = jest.fn(); + + apiPostMock.mockImplementation(({ endpoint }) => { + if (endpoint === "notifications/wave/wave-1/read") { + return waveOneRequest.promise; + } + + return waveTwoRequest.promise; + }); + + setActiveIdentity({ address: "0xAAA", jwt: "jwt-a" }); + const { result } = renderHook(() => useMarkWaveNotificationsRead(), { + wrapper: createWrapper(invalidateNotifications), + }); + + const waveOnePromise = result.current("wave-1"); + const waveTwoPromise = result.current("wave-2"); + + expect(apiPostMock).toHaveBeenCalledTimes(2); + expect(apiPostMock).toHaveBeenNthCalledWith(1, { + endpoint: "notifications/wave/wave-1/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + expect(apiPostMock).toHaveBeenNthCalledWith(2, { + endpoint: "notifications/wave/wave-2/read", + headers: { Authorization: "Bearer jwt-a" }, + }); + + waveTwoRequest.resolve(); + + await expect(waveTwoPromise).resolves.toBe("sent"); + expect(invalidateNotifications).toHaveBeenCalledTimes(1); + + waveOneRequest.resolve(); + + await expect(waveOnePromise).resolves.toBe("sent"); + expect(invalidateNotifications).toHaveBeenCalledTimes(2); + }); +}); diff --git a/__tests__/useWaveRealtimeUpdater.test.ts b/__tests__/useWaveRealtimeUpdater.test.ts index b294d15678..62221f1342 100644 --- a/__tests__/useWaveRealtimeUpdater.test.ts +++ b/__tests__/useWaveRealtimeUpdater.test.ts @@ -9,10 +9,32 @@ jest.mock("@/services/websocket/useWebSocketMessage", () => ({ useWebSocketMessage: () => ({ isConnected: true }), })); +jest.mock("@/components/auth/Auth", () => ({ + useAuth: () => ({ activeProfileProxy: null }), +})); + +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: () => ({ address: "0xAAA" }), +})); + jest.mock("@/services/api/common-api", () => ({ commonApiPostWithoutBodyAndResponse: jest.fn().mockResolvedValue(undefined), })); +jest.mock("@/services/auth/auth.utils", () => ({ + getAuthJwt: jest.fn(() => "test-jwt"), +})); + +jest.mock("jwt-decode", () => ({ + jwtDecode: (token: string) => { + if (token !== "test-jwt") { + throw new Error(`Unexpected JWT decode for ${token}`); + } + + return { sub: "0xAAA", role: null, exp: 4102444800 }; + }, +})); + jest.mock("@/services/api/drop-api", () => ({ fetchDropByIdBatched: jest.fn(), })); @@ -27,19 +49,25 @@ const { commonApiPostWithoutBodyAndResponse, } = require("@/services/api/common-api"); const { fetchDropByIdBatched } = require("@/services/api/drop-api"); +const { getAuthJwt } = require("@/services/auth/auth.utils"); +const getAuthJwtMock = getAuthJwt as jest.Mock; const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); -describe("useWaveRealtimeUpdater", () => { - const setDocumentVisibility = (visibilityState: DocumentVisibilityState) => { - Object.defineProperty(document, "visibilityState", { - configurable: true, - value: visibilityState, - }); - }; +let documentVisibilityState: DocumentVisibilityState = "visible"; + +const setDocumentVisibilityState = (state: DocumentVisibilityState) => { + documentVisibilityState = state; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => documentVisibilityState, + }); +}; +describe("useWaveRealtimeUpdater", () => { beforeEach(() => { - setDocumentVisibility("visible"); + setDocumentVisibilityState("visible"); + getAuthJwtMock.mockReturnValue("test-jwt"); }); afterEach(() => { @@ -233,11 +261,13 @@ describe("useWaveRealtimeUpdater", () => { ); expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledWith({ endpoint: "notifications/wave/wave1/read", + headers: { Authorization: "Bearer test-jwt" }, }); }); - it("does not call the read endpoint for an active hidden wave", async () => { - setDocumentVisibility("hidden"); + it("does not mark active wave as read while hidden", async () => { + setDocumentVisibilityState("hidden"); + const store = { wave1: { drops: [], latestFetchedSerialNo: 10 }, }; @@ -254,8 +284,8 @@ describe("useWaveRealtimeUpdater", () => { ); await flushPromises(); - expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); expect(props.removeWaveDeliveredNotifications).not.toHaveBeenCalled(); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); }); it("does not mark non-active wave as read", async () => { @@ -279,6 +309,82 @@ describe("useWaveRealtimeUpdater", () => { expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); }); + it("drops a delayed active-wave read after the active wave changes", async () => { + getAuthJwtMock.mockReturnValue(null); + + const store = { + wave1: { drops: [], latestFetchedSerialNo: 10 }, + }; + const props = baseProps(store); + props.activeWaveId = "wave1"; + const { result, rerender } = renderHook(() => + useWaveRealtimeUpdater(props) + ); + const drop: any = { + id: "d-delayed-active", + wave: { id: "wave1" }, + author: {}, + }; + + await act(async () => + result.current.processIncomingDrop( + drop, + ProcessIncomingDropType.DROP_INSERT + ) + ); + await flushPromises(); + + expect(props.removeWaveDeliveredNotifications).toHaveBeenCalledWith( + "wave1" + ); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + + props.activeWaveId = "wave2"; + getAuthJwtMock.mockReturnValue("test-jwt"); + rerender(); + await flushPromises(); + + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + }); + + it("drops a delayed active-wave read after the tab becomes hidden", async () => { + getAuthJwtMock.mockReturnValue(null); + + const store = { + wave1: { drops: [], latestFetchedSerialNo: 10 }, + }; + const props = baseProps(store); + props.activeWaveId = "wave1"; + const { result, rerender } = renderHook(() => + useWaveRealtimeUpdater(props) + ); + const drop: any = { + id: "d-delayed-hidden", + wave: { id: "wave1" }, + author: {}, + }; + + await act(async () => + result.current.processIncomingDrop( + drop, + ProcessIncomingDropType.DROP_INSERT + ) + ); + await flushPromises(); + + expect(props.removeWaveDeliveredNotifications).toHaveBeenCalledWith( + "wave1" + ); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + + setDocumentVisibilityState("hidden"); + getAuthJwtMock.mockReturnValue("test-jwt"); + rerender(); + await flushPromises(); + + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + }); + it("skips processing when wave is muted", async () => { const store = { wave1: { drops: [], latestFetchedSerialNo: 10 }, diff --git a/components/auth/SeizeConnectContext.tsx b/components/auth/SeizeConnectContext.tsx index 86bbb64915..eb52063b37 100644 --- a/components/auth/SeizeConnectContext.tsx +++ b/components/auth/SeizeConnectContext.tsx @@ -30,6 +30,7 @@ import { WALLET_ACCOUNTS_UPDATED_EVENT, } from "@/services/auth/auth.utils"; import { useConnectedAccountsUnreadNotifications } from "@/hooks/useConnectedAccountsUnreadNotifications"; +import { useUnreadNotifications } from "@/hooks/useUnreadNotifications"; import { WalletInitializationError } from "@/src/errors/wallet"; import { SecurityEventType } from "@/src/types/security"; import { @@ -1140,8 +1141,74 @@ export const SeizeConnectProvider: React.FC<{ children: React.ReactNode }> = ({ }); }, [activeAddress, liveConnectedAddress, storedConnectedAccounts]); - const connectedAccountUnreadNotifications = - useConnectedAccountsUnreadNotifications(storedConnectedAccounts); + const activeStoredAccount = useMemo(() => { + if (!activeAddress) { + return null; + } + + return ( + storedConnectedAccounts.find( + (storedAccount) => + normalizeAddress(storedAccount.address) === + normalizeAddress(activeAddress) + ) ?? null + ); + }, [activeAddress, storedConnectedAccounts]); + + const jwtPollingStoredConnectedAccounts = useMemo(() => { + if (!activeAddress) { + return storedConnectedAccounts; + } + + if (!activeStoredAccount?.profileHandle) { + return storedConnectedAccounts; + } + + return storedConnectedAccounts.filter( + (storedAccount) => + normalizeAddress(storedAccount.address) !== + normalizeAddress(activeAddress) + ); + }, [ + activeAddress, + activeStoredAccount?.profileHandle, + storedConnectedAccounts, + ]); + + const jwtConnectedAccountUnreadNotifications = + useConnectedAccountsUnreadNotifications(jwtPollingStoredConnectedAccounts); + + const { notifications: activeUnreadNotifications } = useUnreadNotifications( + activeStoredAccount?.profileHandle ?? null + ); + + const connectedAccountUnreadNotifications = useMemo(() => { + const unreadNotificationsByAddress = { + ...jwtConnectedAccountUnreadNotifications, + }; + + if (activeStoredAccount?.profileHandle) { + const activeAccountAddress = normalizeAddress( + activeStoredAccount.address + ); + const activeUnreadCount = activeUnreadNotifications?.unread_count; + + if (typeof activeUnreadCount === "number") { + unreadNotificationsByAddress[activeAccountAddress] = activeUnreadCount; + } + } else if (activeStoredAccount) { + const activeAccountAddress = normalizeAddress( + activeStoredAccount.address + ); + unreadNotificationsByAddress[activeAccountAddress] ??= 0; + } + + return unreadNotificationsByAddress; + }, [ + activeStoredAccount, + activeUnreadNotifications?.unread_count, + jwtConnectedAccountUnreadNotifications, + ]); const contextValue = useMemo( (): SeizeConnectContextType => ({ diff --git a/components/brain/my-stream/MyStreamWaveChat.tsx b/components/brain/my-stream/MyStreamWaveChat.tsx index 88555c1c8c..385ae71735 100644 --- a/components/brain/my-stream/MyStreamWaveChat.tsx +++ b/components/brain/my-stream/MyStreamWaveChat.tsx @@ -9,7 +9,6 @@ import PrivilegedDropCreator, { DropMode, } from "@/components/waves/PrivilegedDropCreator"; import { useNotificationsContext } from "@/components/notifications/NotificationsContext"; -import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { UnreadDividerProvider, useUnreadDivider, @@ -23,13 +22,13 @@ import { WaveSubmissionExperience, } from "@/helpers/waves/wave-submission-experience.helpers"; import useDeviceInfo from "@/hooks/useDeviceInfo"; +import { useMarkWaveNotificationsRead } from "@/hooks/useMarkWaveNotificationsRead"; import { useWave } from "@/hooks/useWave"; import { useApprovalWaveStatus } from "@/hooks/waves/useApprovalWaveStatus"; import type { WaveViewMode } from "@/hooks/useWaveViewMode"; import { selectEditingDropId } from "@/store/editSlice"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; import { ActiveDropAction } from "@/types/dropInteractionTypes"; -import { commonApiPostWithoutBodyAndResponse } from "@/services/api/common-api"; import { ACCEPTED_FILE_TYPE_LABELS, isSupportedUploadFile, @@ -37,7 +36,6 @@ import { import { usePathname, useRouter, useSearchParams } from "next/navigation"; import React, { useCallback, - useContext, useEffect, useMemo, useRef, @@ -45,6 +43,7 @@ import React, { } from "react"; import { useSelector } from "react-redux"; import { useLayout } from "./layout/LayoutContext"; +import { useWaveChatLeaveCleanup } from "./useWaveChatLeaveCleanup"; interface InitialDropState { readonly waveId: string; @@ -72,46 +71,15 @@ const WaveChatLeaveHandler: React.FC = ({ }) => { const { setUnreadDividerSerialNo } = useUnreadDivider(); const { removeWaveDeliveredNotifications } = useNotificationsContext(); - const { invalidateNotifications } = useContext(ReactQueryWrapperContext); + const markWaveNotificationsRead = useMarkWaveNotificationsRead(); - useEffect(() => { - if (!enabled) { - return; - } - - return () => { - setUnreadDividerSerialNo(null); - void (async () => { - if (document.visibilityState !== "visible") { - return; - } - - try { - await Promise.resolve(removeWaveDeliveredNotifications(waveId)); - } catch (error: unknown) { - console.error( - "Failed to remove wave delivered notifications:", - error - ); - } - - try { - await commonApiPostWithoutBodyAndResponse({ - endpoint: `notifications/wave/${waveId}/read`, - }); - invalidateNotifications(); - } catch (error: unknown) { - console.error("Failed to mark feed as read:", error); - } - })(); - }; - }, [ + useWaveChatLeaveCleanup({ enabled, waveId, setUnreadDividerSerialNo, removeWaveDeliveredNotifications, - invalidateNotifications, - ]); + markWaveNotificationsRead, + }); return null; }; @@ -123,6 +91,7 @@ const MyStreamWaveChat: React.FC = ({ onDropClick, }) => { const router = useRouter(); + // react-doctor-disable-next-line react-doctor/nextjs-no-use-search-params-without-suspense covered by MyStreamWave Suspense wrapper const searchParams = useSearchParams(); const pathname = usePathname(); const galleryContainerRef = useRef(null); diff --git a/components/brain/my-stream/useWaveChatLeaveCleanup.ts b/components/brain/my-stream/useWaveChatLeaveCleanup.ts new file mode 100644 index 0000000000..dd954e68f2 --- /dev/null +++ b/components/brain/my-stream/useWaveChatLeaveCleanup.ts @@ -0,0 +1,66 @@ +"use client"; + +import { useEffect, useEffectEvent } from "react"; + +type SetUnreadDividerSerialNo = ( + serialNo: number | null | ((current: number | null) => number | null) +) => void; + +interface MarkWaveNotificationsReadOptions { + readonly shouldSend?: () => boolean; + readonly queueIfBlocked?: boolean; +} + +interface UseWaveChatLeaveCleanupParams { + readonly enabled: boolean; + readonly waveId: string; + readonly setUnreadDividerSerialNo: SetUnreadDividerSerialNo; + readonly removeWaveDeliveredNotifications: ( + waveId: string + ) => Promise | void; + readonly markWaveNotificationsRead: ( + waveId: string, + options?: MarkWaveNotificationsReadOptions + ) => Promise | void; +} + +export function useWaveChatLeaveCleanup({ + enabled, + waveId, + setUnreadDividerSerialNo, + removeWaveDeliveredNotifications, + markWaveNotificationsRead, +}: UseWaveChatLeaveCleanupParams) { + const cleanupLeftWave = useEffectEvent((leftWaveId: string) => { + setUnreadDividerSerialNo(null); + void (async () => { + if (document.visibilityState !== "visible") { + return; + } + + try { + await Promise.resolve(removeWaveDeliveredNotifications(leftWaveId)); + } catch (error: unknown) { + console.error("Failed to remove wave delivered notifications:", error); + } + + try { + await markWaveNotificationsRead(leftWaveId, { + queueIfBlocked: false, + }); + } catch (error: unknown) { + console.error("Failed to mark feed as read:", error); + } + })(); + }); + + useEffect(() => { + if (!enabled) { + return; + } + + return () => { + cleanupLeftWave(waveId); + }; + }, [enabled, waveId]); +} diff --git a/components/react-query-wrapper/ReactQueryWrapper.tsx b/components/react-query-wrapper/ReactQueryWrapper.tsx index 33b09530de..c2ecdc7cb6 100644 --- a/components/react-query-wrapper/ReactQueryWrapper.tsx +++ b/components/react-query-wrapper/ReactQueryWrapper.tsx @@ -60,6 +60,7 @@ export enum QueryKey { IDENTITY_FOLLOWING_ACTIONS = "IDENTITY_FOLLOWING_ACTIONS", IDENTITY_FOLLOWERS = "IDENTITY_FOLLOWERS", IDENTITY_NOTIFICATIONS = "IDENTITY_NOTIFICATIONS", + CONNECTED_ACCOUNT_UNREAD_NOTIFICATIONS = "CONNECTED_ACCOUNT_UNREAD_NOTIFICATIONS", IDENTITY_SEARCH = "IDENTITY_SEARCH", IDENTITY_FAVOURITE_WAVES = "IDENTITY_FAVOURITE_WAVES", WALLET_TDH_HISTORY = "WALLET_TDH_HISTORY", @@ -980,9 +981,12 @@ const createReactQueryContextValue = ( }; const invalidateNotifications = () => { - queryClient.invalidateQueries({ + void queryClient.invalidateQueries({ queryKey: [QueryKey.IDENTITY_NOTIFICATIONS], }); + void queryClient.invalidateQueries({ + queryKey: [QueryKey.CONNECTED_ACCOUNT_UNREAD_NOTIFICATIONS], + }); }; const invalidateIdentityTdhStats = ({ identity }: { identity: string }) => { diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.ts index 258491add6..962272e89a 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.ts @@ -1,6 +1,11 @@ -import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; -import { useContext, useEffect } from "react"; -import { commonApiPostWithoutBodyAndResponse } from "@/services/api/common-api"; +import { useWaveNotificationsReadMarkerState } from "@/hooks/useMarkWaveNotificationsRead"; +import { + useCallback, + useEffect, + useEffectEvent, + useLayoutEffect, + useRef, +} from "react"; interface UseWaveDropsNotificationReadParams { readonly waveId: string; @@ -10,55 +15,219 @@ interface UseWaveDropsNotificationReadParams { ) => Promise | void; } +interface ReadSyncState { + readonly waveId: string; + readonly identityKey: string; + readonly proxyRoleIdentityKey: string | null; +} + +type ReadSyncStatus = "pending" | "success" | "failed" | "skipped"; + +interface ReadSyncAttempt { + readonly state: ReadSyncState; + readonly promise: Promise; + status: ReadSyncStatus; +} + +interface ReadGuardState { + readonly enabled: boolean; + readonly hookInstanceId: symbol; + readonly waveId: string; +} + +const usedTemporaryProxyRole = (state: ReadSyncState): boolean => + state.proxyRoleIdentityKey !== null && + state.identityKey === state.proxyRoleIdentityKey; + +const usesLoadedProxyForSameRole = ({ + previousState, + currentState, +}: { + readonly previousState: ReadSyncState | null; + readonly currentState: ReadSyncState; +}): boolean => + previousState !== null && + previousState.waveId === currentState.waveId && + usedTemporaryProxyRole(previousState) && + currentState.proxyRoleIdentityKey !== null && + currentState.identityKey !== currentState.proxyRoleIdentityKey && + previousState.identityKey === currentState.proxyRoleIdentityKey; + export const useWaveDropsNotificationRead = ({ waveId, enabled = true, removeWaveDeliveredNotifications, }: UseWaveDropsNotificationReadParams) => { - const { invalidateNotifications } = useContext(ReactQueryWrapperContext); + const readSyncAttemptRef = useRef(null); + const hookInstanceIdRef = useRef(Symbol("useWaveDropsNotificationRead")); + const isHookMountedRef = useRef(false); + const latestReadGuardStateRef = useRef({ + enabled, + hookInstanceId: hookInstanceIdRef.current, + waveId, + }); - useEffect(() => { - if (!enabled) { - return; - } + useLayoutEffect(() => { + latestReadGuardStateRef.current = { + enabled, + hookInstanceId: hookInstanceIdRef.current, + waveId, + }; + }, [enabled, waveId]); - const syncReadState = async () => { - if (document.visibilityState !== "visible") { - return; - } + useLayoutEffect(() => { + isHookMountedRef.current = true; + + return () => { + isHookMountedRef.current = false; + }; + }, []); + const { markWaveNotificationsRead, identityKey, proxyRoleIdentityKey } = + useWaveNotificationsReadMarkerState(); + + const canSendReadForWave = useCallback((expectedWaveId: string): boolean => { + const latestState = latestReadGuardStateRef.current; + + return ( + isHookMountedRef.current && + latestState.hookInstanceId === hookInstanceIdRef.current && + latestState.waveId === expectedWaveId && + latestState.enabled === true && + document.visibilityState === "visible" + ); + }, []); + + const syncReadState = useEffectEvent((state: ReadSyncState) => { + const runReadSyncAttempt = async ( + currentState: ReadSyncState + ): Promise => { try { - await Promise.resolve(removeWaveDeliveredNotifications(waveId)); + await Promise.resolve( + removeWaveDeliveredNotifications(currentState.waveId) + ); } catch (error) { console.error("Failed to remove wave delivered notifications:", error); } - commonApiPostWithoutBodyAndResponse({ - endpoint: `notifications/wave/${waveId}/read`, - }) - .then(() => { - invalidateNotifications(); - }) - .catch((error) => console.error("Failed to mark feed as read:", error)); + try { + const readResult = await markWaveNotificationsRead( + currentState.waveId, + { + shouldSend: () => canSendReadForWave(currentState.waveId), + } + ); + return readResult === "sent" ? "success" : "skipped"; + } catch (error) { + console.error("Failed to mark feed as read:", error); + return "failed"; + } }; + const trackReadSyncAttempt = ({ + currentState, + promise, + }: { + readonly currentState: ReadSyncState; + readonly promise: Promise; + }): ReadSyncAttempt => { + let attempt: ReadSyncAttempt | null = null; + const trackedPromise = promise.then((status) => { + if (attempt) { + attempt.status = status; + } + return status; + }); + + attempt = { + state: currentState, + status: "pending", + promise: trackedPromise, + }; + readSyncAttemptRef.current = attempt; + return attempt; + }; + + const markReadSyncAttemptCovered = (currentState: ReadSyncState) => { + readSyncAttemptRef.current = { + state: currentState, + status: "success", + promise: Promise.resolve("success"), + }; + }; + + if (document.visibilityState !== "visible") { + return; + } + + const previousAttempt = readSyncAttemptRef.current; + + if ( + usesLoadedProxyForSameRole({ + previousState: previousAttempt?.state ?? null, + currentState: state, + }) + ) { + if (previousAttempt?.status === "success") { + markReadSyncAttemptCovered(state); + return; + } + + if (previousAttempt?.status === "pending") { + let followUpAttempt: ReadSyncAttempt | null = null; + const followUpPromise = previousAttempt.promise.then( + async (previousStatus) => { + if (previousStatus === "success") { + return "success"; + } + + if (readSyncAttemptRef.current !== followUpAttempt) { + return "failed"; + } + + return runReadSyncAttempt(state); + } + ); + + followUpAttempt = trackReadSyncAttempt({ + currentState: state, + promise: followUpPromise, + }); + return; + } + } + + trackReadSyncAttempt({ + currentState: state, + promise: runReadSyncAttempt(state), + }); + }); + + useEffect(() => { + if (!enabled) { + return; + } + const syncReadStateWhenVisible = () => { if (document.visibilityState === "visible") { - void syncReadState(); + void syncReadState({ + waveId, + identityKey, + proxyRoleIdentityKey, + }); } }; - void syncReadState(); + syncReadState({ + waveId, + identityKey, + proxyRoleIdentityKey, + }); document.addEventListener("visibilitychange", syncReadStateWhenVisible); return () => document.removeEventListener( "visibilitychange", syncReadStateWhenVisible ); - }, [ - enabled, - waveId, - removeWaveDeliveredNotifications, - invalidateNotifications, - ]); + }, [enabled, identityKey, proxyRoleIdentityKey, waveId]); }; diff --git a/components/waves/drops/wave-drops-all/index.tsx b/components/waves/drops/wave-drops-all/index.tsx index 8c3a9b11fc..25eb95750a 100644 --- a/components/waves/drops/wave-drops-all/index.tsx +++ b/components/waves/drops/wave-drops-all/index.tsx @@ -197,7 +197,7 @@ const WaveDropsAllInner: React.FC = ({ }, [waveChatScroll, waveId, queueSerialTarget]); const revealPendingDrops = useCallback(() => { - if (!waveMessages?.drops?.length) { + if ((waveMessages?.drops.length ?? 0) === 0) { return; } @@ -205,48 +205,44 @@ const WaveDropsAllInner: React.FC = ({ forcePinToBottom(); }, [waveMessages, revealDeferredPendingDrops, forcePinToBottom]); - const handleTopIntersection = useCallback(async () => { - if ( - waveMessages?.hasNextPage && - !waveMessages?.isLoading && - !waveMessages?.isLoadingNextPage - ) { - await fetchNextPage( - { - waveId, - type: DropSize.FULL, - }, - dropId - ); + const canFetchMoreDrops = + !!waveMessages && + waveMessages.hasNextPage && + !waveMessages.isLoading && + !waveMessages.isLoadingNextPage; + + const handleTopIntersection = useCallback(() => { + if (!canFetchMoreDrops) { + return; } - }, [ - waveMessages?.hasNextPage, - waveMessages?.isLoading, - waveMessages?.isLoadingNextPage, - fetchNextPage, - waveId, - dropId, - ]); + + void fetchNextPage( + { + waveId, + type: DropSize.FULL, + }, + dropId + ).catch(() => undefined); + }, [canFetchMoreDrops, fetchNextPage, waveId, dropId]); const handleQuoteClick = useCallback( (drop: ApiDrop) => { if (drop.wave.id === waveId) { queueSerialTarget(drop.serial_no); } else { - const waveDetails = - (drop.wave as unknown as { - chat?: - | { - scope?: - | { - group?: - | { is_direct_message?: boolean | undefined } - | undefined; - } - | undefined; - } - | undefined; - }) ?? undefined; + const waveDetails = drop.wave as unknown as { + chat?: + | { + scope?: + | { + group?: + | { is_direct_message?: boolean | undefined } + | undefined; + } + | undefined; + } + | undefined; + }; const isDirectMessage = isWaveDirectMessage(drop.wave.id, waveDetails); const href = getWaveRoute({ waveId: drop.wave.id, diff --git a/contexts/wave/hooks/useWaveRealtimeUpdater.ts b/contexts/wave/hooks/useWaveRealtimeUpdater.ts index 7177449c86..b376705b3a 100644 --- a/contexts/wave/hooks/useWaveRealtimeUpdater.ts +++ b/contexts/wave/hooks/useWaveRealtimeUpdater.ts @@ -10,13 +10,12 @@ import type { import { WsMessageType } from "@/helpers/Types"; import type { Drop, ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; -import { commonApiPostWithoutBodyAndResponse } from "@/services/api/common-api"; +import { useMarkWaveNotificationsRead } from "@/hooks/useMarkWaveNotificationsRead"; import { fetchDropByIdBatched } from "@/services/api/drop-api"; import { useWebSocketMessage } from "@/services/websocket/useWebSocketMessage"; -import { useCallback, useContext, useEffect, useRef } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { useWaveEligibility } from "../WaveEligibilityContext"; import type { WaveDataStoreUpdater } from "./types"; -import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { WebSocketStatus } from "@/services/websocket/WebSocketTypes"; import { recordReactionRealtimeReconciliation } from "@/utils/monitoring/dropReactionMonitoring"; import { useQueryClient } from "@tanstack/react-query"; @@ -110,11 +109,22 @@ export function useWaveRealtimeUpdater({ const isFetchingNewestRef = useRef>({}); const needsRefetchAfterCurrentRef = useRef>({}); const abortControllersRef = useRef>({}); + const activeWaveIdRef = useRef(activeWaveId); + useLayoutEffect(() => { + activeWaveIdRef.current = activeWaveId; + }, [activeWaveId]); const { refreshEligibility } = useWaveEligibility(); - const { invalidateNotifications } = useContext(ReactQueryWrapperContext); + const markWaveNotificationsRead = useMarkWaveNotificationsRead(); const queryClient = useQueryClient(); const tabJustBecameVisibleRef = useRef(false); + const canSendReadForWave = useCallback((waveId: string): boolean => { + return ( + activeWaveIdRef.current === waveId && + document.visibilityState === "visible" + ); + }, []); + // Function to cleanup abort controllers const cleanupController = useCallback((waveId: string) => { if (abortControllersRef.current[waveId]) { @@ -187,17 +197,6 @@ export function useWaveRealtimeUpdater({ // WebSocket message handler const processIncomingDrop: ProcessIncomingDropFn = useCallback( async (drop: ApiDrop, type: ProcessIncomingDropType) => { - const markWaveAsRead = async (waveId: string) => { - if (document.visibilityState !== "visible") { - return; - } - - await commonApiPostWithoutBodyAndResponse({ - endpoint: `notifications/wave/${waveId}/read`, - }); - invalidateNotifications(); - }; - if (!drop?.wave?.id) { return; } @@ -314,12 +313,25 @@ export function useWaveRealtimeUpdater({ } if (activeWaveId === waveId && document.visibilityState === "visible") { - removeWaveDeliveredNotifications(waveId).catch((error) => - console.error("Failed to remove wave delivered notifications:", error) - ); - markWaveAsRead(waveId).catch((error) => - console.error("Failed to mark wave as read:", error) - ); + void (async () => { + try { + await removeWaveDeliveredNotifications(waveId); + } catch (error) { + console.error( + "Failed to remove wave delivered notifications:", + error + ); + } + })(); + void (async () => { + try { + await markWaveNotificationsRead(waveId, { + shouldSend: () => canSendReadForWave(waveId), + }); + } catch (error) { + console.error("Failed to mark wave as read:", error); + } + })(); } }, [ @@ -329,9 +341,10 @@ export function useWaveRealtimeUpdater({ registerWave, initiateFetchNewestCycle, removeWaveDeliveredNotifications, + markWaveNotificationsRead, + canSendReadForWave, refreshEligibility, isWaveMuted, - invalidateNotifications, queryClient, ] ); diff --git a/hooks/useConnectedAccountsUnreadNotifications.ts b/hooks/useConnectedAccountsUnreadNotifications.ts index 2ebdb9e295..f1d22ca2bc 100644 --- a/hooks/useConnectedAccountsUnreadNotifications.ts +++ b/hooks/useConnectedAccountsUnreadNotifications.ts @@ -44,7 +44,7 @@ export function useConnectedAccountsUnreadNotifications( const { isCapacitor } = useCapacitor(); const queryClient = useQueryClient(); const queryKey = [ - QueryKey.IDENTITY_NOTIFICATIONS, + QueryKey.CONNECTED_ACCOUNT_UNREAD_NOTIFICATIONS, "connected-account-unread-counts", accounts.map((account) => toAddressKey(account.address)), ] as const; diff --git a/hooks/useMarkWaveNotificationsRead.cache.ts b/hooks/useMarkWaveNotificationsRead.cache.ts new file mode 100644 index 0000000000..32903f6c18 --- /dev/null +++ b/hooks/useMarkWaveNotificationsRead.cache.ts @@ -0,0 +1,598 @@ +import { + getWaveReadProxyRoleRequestKey, + getWaveReadRequestKey, + isWaveReadJwtExpired, +} from "@/hooks/useMarkWaveNotificationsRead.identity"; +import type { + WaveReadTemporaryProxyRoleIdentity, + WaveReadVerifiedIdentity, +} from "@/hooks/useMarkWaveNotificationsRead.identity"; +import { + clearAllWaveReadState, + clearPendingWaveReadsForAddress, + deleteLatestVerifiedWaveReadIdentityByAddress, + deleteLatestVerifiedWaveReadIdentityIfCurrent, + enqueuePendingWaveReadRequest, + flushPendingClearedWaveReadRequests, + flushPendingWaveReadRequests, + getLatestVerifiedWaveReadIdentityByAddress, + getStaleAddressEpochWaveReadResult, + getVerifiedProxyRoleIdentityKey, + markWaveReadIdentityCleared, + markWaveReadWithAuthHeaders, + setLatestVerifiedWaveReadIdentityByAddress, +} from "@/hooks/useMarkWaveNotificationsRead.requests"; +import type { + MarkWaveNotificationsReadOptions, + MarkWaveNotificationsReadResult, + WaveReadAddressEpoch, + WaveReadCacheRefs, + WaveReadSendIntent, + WaveReadSendRetryContext, +} from "@/hooks/useMarkWaveNotificationsRead.types"; +import { useLayoutEffect, useMemo, useRef } from "react"; + +let mountedWaveNotificationsReadMarkerHookCount = 0; +let clearWaveNotificationsReadStateTimeout: { + readonly timeout: ReturnType; + readonly addressKey: string | null; +} | null = null; + +export const useWaveReadCacheRefs = ({ + addressEpoch, + identityKey, + temporaryProxyRoleIdentity, + verifiedIdentity, + invalidateNotifications, +}: { + readonly addressEpoch: WaveReadAddressEpoch; + readonly identityKey: string; + readonly temporaryProxyRoleIdentity: + | WaveReadTemporaryProxyRoleIdentity + | undefined; + readonly verifiedIdentity: WaveReadVerifiedIdentity | undefined; + readonly invalidateNotifications: () => void; +}): WaveReadCacheRefs => { + const invalidateNotificationsRef = useRef(invalidateNotifications); + const latestAddressEpochRef = useRef(addressEpoch); + const authByIdentityRef = useRef>( + new Map( + verifiedIdentity ? [[identityKey, verifiedIdentity]] : [] + ) + ); + const temporaryProxyRoleIdentityByIdentityRef = useRef< + Map + >( + new Map( + temporaryProxyRoleIdentity + ? [[identityKey, temporaryProxyRoleIdentity]] + : [] + ) + ); + const latestVerifiedIdentityByAddressRef = useRef< + Map + >( + new Map( + verifiedIdentity ? [[verifiedIdentity.addressKey, verifiedIdentity]] : [] + ) + ); + const latestVerifiedIdentityByProxyRoleRef = useRef< + Map + >(new Map()); + const clearedIdentityKeysRef = useRef>(new Set()); + + useLayoutEffect(() => { + invalidateNotificationsRef.current = invalidateNotifications; + }, [invalidateNotifications]); + + useLayoutEffect(() => { + latestAddressEpochRef.current = addressEpoch; + }, [addressEpoch]); + + useLayoutEffect(() => { + if (temporaryProxyRoleIdentity) { + temporaryProxyRoleIdentityByIdentityRef.current.set( + identityKey, + temporaryProxyRoleIdentity + ); + return; + } + + temporaryProxyRoleIdentityByIdentityRef.current.delete(identityKey); + }, [identityKey, temporaryProxyRoleIdentity]); + + return useMemo( + () => ({ + invalidateNotificationsRef, + latestAddressEpochRef, + authByIdentityRef, + temporaryProxyRoleIdentityByIdentityRef, + latestVerifiedIdentityByAddressRef, + latestVerifiedIdentityByProxyRoleRef, + clearedIdentityKeysRef, + }), + [ + authByIdentityRef, + clearedIdentityKeysRef, + invalidateNotificationsRef, + latestAddressEpochRef, + latestVerifiedIdentityByAddressRef, + latestVerifiedIdentityByProxyRoleRef, + temporaryProxyRoleIdentityByIdentityRef, + ] + ); +}; + +export const useSyncWaveReadVerifiedIdentityCaches = ({ + walletAuth, + verifiedIdentity, + cacheRefs, +}: { + readonly walletAuth: string | null; + readonly verifiedIdentity: WaveReadVerifiedIdentity | undefined; + readonly cacheRefs: WaveReadCacheRefs; +}): void => { + const { + authByIdentityRef, + clearedIdentityKeysRef, + invalidateNotificationsRef, + latestVerifiedIdentityByAddressRef, + latestVerifiedIdentityByProxyRoleRef, + } = cacheRefs; + + useLayoutEffect(() => { + if (walletAuth === null) { + for (const identity of authByIdentityRef.current.values()) { + const proxyRoleIdentityKey = getVerifiedProxyRoleIdentityKey(identity); + markWaveReadIdentityCleared(identity); + clearedIdentityKeysRef.current.add(identity.identityKey); + latestVerifiedIdentityByAddressRef.current.delete(identity.addressKey); + deleteLatestVerifiedWaveReadIdentityByAddress(identity.addressKey); + if (proxyRoleIdentityKey !== null) { + latestVerifiedIdentityByProxyRoleRef.current.delete( + proxyRoleIdentityKey + ); + } + } + authByIdentityRef.current.clear(); + return; + } + + if (verifiedIdentity) { + authByIdentityRef.current.set( + verifiedIdentity.identityKey, + verifiedIdentity + ); + latestVerifiedIdentityByAddressRef.current.set( + verifiedIdentity.addressKey, + verifiedIdentity + ); + setLatestVerifiedWaveReadIdentityByAddress(verifiedIdentity); + const proxyRoleIdentityKey = + getVerifiedProxyRoleIdentityKey(verifiedIdentity); + if (proxyRoleIdentityKey !== null) { + latestVerifiedIdentityByProxyRoleRef.current.set( + proxyRoleIdentityKey, + verifiedIdentity + ); + } + flushPendingWaveReadRequests({ + verifiedIdentity, + invalidateNotificationsRef, + }); + flushPendingClearedWaveReadRequests({ + verifiedIdentity, + invalidateNotificationsRef, + }); + } + }, [ + authByIdentityRef, + clearedIdentityKeysRef, + invalidateNotificationsRef, + latestVerifiedIdentityByAddressRef, + latestVerifiedIdentityByProxyRoleRef, + verifiedIdentity, + walletAuth, + ]); +}; + +const evictExpiredWaveReadIdentity = ({ + identity, + cacheRefs, +}: { + readonly identity: WaveReadVerifiedIdentity; + readonly cacheRefs: WaveReadCacheRefs; +}): void => { + if ( + cacheRefs.authByIdentityRef.current.get(identity.identityKey) === identity + ) { + cacheRefs.authByIdentityRef.current.delete(identity.identityKey); + } + + if ( + cacheRefs.latestVerifiedIdentityByAddressRef.current.get( + identity.addressKey + ) === identity + ) { + cacheRefs.latestVerifiedIdentityByAddressRef.current.delete( + identity.addressKey + ); + } + + const proxyRoleIdentityKey = getVerifiedProxyRoleIdentityKey(identity); + if ( + proxyRoleIdentityKey !== null && + cacheRefs.latestVerifiedIdentityByProxyRoleRef.current.get( + proxyRoleIdentityKey + ) === identity + ) { + cacheRefs.latestVerifiedIdentityByProxyRoleRef.current.delete( + proxyRoleIdentityKey + ); + } + + deleteLatestVerifiedWaveReadIdentityIfCurrent(identity); +}; + +const getUsableCachedWaveReadIdentity = ({ + identity, + cacheRefs, +}: { + readonly identity: WaveReadVerifiedIdentity | undefined; + readonly cacheRefs: WaveReadCacheRefs; +}): WaveReadVerifiedIdentity | undefined => { + if (!identity) { + return undefined; + } + + if (!isWaveReadJwtExpired(identity.jwtExpiresAt)) { + return identity; + } + + evictExpiredWaveReadIdentity({ identity, cacheRefs }); + return undefined; +}; + +const getUsableLatestClearedWaveReadIdentity = ({ + addressKey, + identityKey, + cacheRefs, +}: { + readonly addressKey: string; + readonly identityKey: string; + readonly cacheRefs: WaveReadCacheRefs; +}): WaveReadVerifiedIdentity | undefined => { + if (!cacheRefs.clearedIdentityKeysRef.current.has(identityKey)) { + return undefined; + } + + const localLatestIdentity = getUsableCachedWaveReadIdentity({ + identity: + cacheRefs.latestVerifiedIdentityByAddressRef.current.get(addressKey), + cacheRefs, + }); + if (localLatestIdentity) { + return localLatestIdentity; + } + + return getUsableCachedWaveReadIdentity({ + identity: getLatestVerifiedWaveReadIdentityByAddress(addressKey), + cacheRefs, + }); +}; + +export const useClearWaveReadStateOnAddressChange = ( + addressKey: string | null +): void => { + const previousAddressKeyRef = useRef(addressKey); + + useLayoutEffect(() => { + const previousAddressKey = previousAddressKeyRef.current; + if (previousAddressKey !== null && previousAddressKey !== addressKey) { + clearPendingWaveReadsForAddress(previousAddressKey); + } + + previousAddressKeyRef.current = addressKey; + }, [addressKey]); +}; + +export const useClearWaveReadStateOnLastUnmount = ( + addressKey: string | null +): void => { + const latestAddressKeyRef = useRef(addressKey); + + useLayoutEffect(() => { + latestAddressKeyRef.current = addressKey; + }, [addressKey]); + + useLayoutEffect(() => { + if (clearWaveNotificationsReadStateTimeout !== null) { + const deferredCleanup = clearWaveNotificationsReadStateTimeout; + globalThis.clearTimeout(deferredCleanup.timeout); + clearWaveNotificationsReadStateTimeout = null; + + if ( + deferredCleanup.addressKey !== null && + deferredCleanup.addressKey !== latestAddressKeyRef.current + ) { + clearPendingWaveReadsForAddress(deferredCleanup.addressKey); + } + } + + mountedWaveNotificationsReadMarkerHookCount += 1; + + return () => { + mountedWaveNotificationsReadMarkerHookCount -= 1; + + if (mountedWaveNotificationsReadMarkerHookCount > 0) { + return; + } + + mountedWaveNotificationsReadMarkerHookCount = 0; + const cleanupAddressKey = latestAddressKeyRef.current; + const timeout = globalThis.setTimeout(() => { + if (clearWaveNotificationsReadStateTimeout?.timeout === timeout) { + clearWaveNotificationsReadStateTimeout = null; + } + + if (mountedWaveNotificationsReadMarkerHookCount === 0) { + clearAllWaveReadState(); + } + }, 0); + clearWaveNotificationsReadStateTimeout = { + timeout, + addressKey: cleanupAddressKey, + }; + }; + }, []); +}; + +const createWaveReadSendIntent = ({ + shouldSend, + retryContext, +}: { + readonly shouldSend: MarkWaveNotificationsReadOptions["shouldSend"]; + readonly retryContext: WaveReadSendRetryContext | undefined; +}): WaveReadSendIntent => ({ + shouldSend, + retryContext, +}); + +const getWaveReadSendRetryContext = ({ + addressKey, + activeProfileProxyId, + proxyCreatorId, + identityKey, + requestKey, + waveId, + addressEpoch, + cacheRefs, + queueIfBlocked, +}: { + readonly addressKey: string; + readonly activeProfileProxyId: string | null; + readonly proxyCreatorId: string | null; + readonly identityKey: string; + readonly requestKey: string; + readonly waveId: string; + readonly addressEpoch: WaveReadAddressEpoch; + readonly cacheRefs: WaveReadCacheRefs; + readonly queueIfBlocked: boolean; +}): WaveReadSendRetryContext | undefined => { + if (!queueIfBlocked) { + return undefined; + } + + return { + addressKey, + activeProfileProxyId, + proxyCreatorId, + identityKey, + requestKey, + waveId, + addressEpoch, + latestAddressEpochRef: cacheRefs.latestAddressEpochRef, + }; +}; + +const markTemporaryProxyRoleWaveRead = ({ + waveId, + addressKey, + addressEpoch, + temporaryProxyRoleIdentity, + cacheRefs, + options, +}: { + readonly waveId: string; + readonly addressKey: string; + readonly addressEpoch: WaveReadAddressEpoch; + readonly temporaryProxyRoleIdentity: WaveReadTemporaryProxyRoleIdentity; + readonly cacheRefs: WaveReadCacheRefs; + readonly options: MarkWaveNotificationsReadOptions | undefined; +}): Promise => { + const latestVerifiedProxyRoleIdentity = getUsableCachedWaveReadIdentity({ + identity: cacheRefs.latestVerifiedIdentityByProxyRoleRef.current.get( + temporaryProxyRoleIdentity.identityKey + ), + cacheRefs, + }); + if (latestVerifiedProxyRoleIdentity) { + const loadedProxyRequestKey = getWaveReadRequestKey({ + addressKey: latestVerifiedProxyRoleIdentity.addressKey, + activeProfileProxyId: + latestVerifiedProxyRoleIdentity.activeProfileProxyId, + waveId, + }); + + return markWaveReadWithAuthHeaders({ + waveId, + addressKey: latestVerifiedProxyRoleIdentity.addressKey, + requestKey: loadedProxyRequestKey, + authHeaders: latestVerifiedProxyRoleIdentity.authHeaders, + jwtExpiresAt: latestVerifiedProxyRoleIdentity.jwtExpiresAt, + invalidateNotificationsRef: cacheRefs.invalidateNotificationsRef, + sendIntents: [ + createWaveReadSendIntent({ + shouldSend: options?.shouldSend, + retryContext: getWaveReadSendRetryContext({ + addressKey: latestVerifiedProxyRoleIdentity.addressKey, + activeProfileProxyId: + latestVerifiedProxyRoleIdentity.activeProfileProxyId, + proxyCreatorId: null, + identityKey: latestVerifiedProxyRoleIdentity.identityKey, + requestKey: loadedProxyRequestKey, + waveId, + addressEpoch, + cacheRefs, + queueIfBlocked: options?.queueIfBlocked ?? true, + }), + }), + ], + }); + } + + const proxyRoleRequestKey = getWaveReadProxyRoleRequestKey({ + addressKey, + proxyCreatorId: temporaryProxyRoleIdentity.proxyCreatorId, + waveId, + }); + + return enqueuePendingWaveReadRequest({ + addressKey, + activeProfileProxyId: null, + proxyCreatorId: temporaryProxyRoleIdentity.proxyCreatorId, + identityKey: temporaryProxyRoleIdentity.identityKey, + requestKey: proxyRoleRequestKey, + waveId, + addressEpoch, + latestAddressEpochRef: cacheRefs.latestAddressEpochRef, + shouldSend: options?.shouldSend, + queueIfBlocked: options?.queueIfBlocked ?? true, + }); +}; + +export const markWaveReadFromCache = ({ + waveId, + addressKey, + activeProfileProxyId, + identityKey, + addressEpoch, + cacheRefs, + options, +}: { + readonly waveId: string; + readonly addressKey: string | null; + readonly activeProfileProxyId: string | null; + readonly identityKey: string; + readonly addressEpoch: WaveReadAddressEpoch; + readonly cacheRefs: WaveReadCacheRefs; + readonly options: MarkWaveNotificationsReadOptions | undefined; +}): Promise => { + if (addressKey === null) { + return Promise.resolve("skipped"); + } + + const queueIfBlocked = options?.queueIfBlocked ?? true; + const staleAddressEpochResult = getStaleAddressEpochWaveReadResult({ + addressEpoch, + latestAddressEpochRef: cacheRefs.latestAddressEpochRef, + queueIfBlocked, + }); + if (staleAddressEpochResult) { + return staleAddressEpochResult; + } + + const requestKey = getWaveReadRequestKey({ + addressKey, + activeProfileProxyId, + waveId, + }); + const verifiedCachedIdentity = getUsableCachedWaveReadIdentity({ + identity: cacheRefs.authByIdentityRef.current.get(identityKey), + cacheRefs, + }); + if (verifiedCachedIdentity) { + return markWaveReadWithAuthHeaders({ + waveId, + addressKey: verifiedCachedIdentity.addressKey, + requestKey, + authHeaders: verifiedCachedIdentity.authHeaders, + jwtExpiresAt: verifiedCachedIdentity.jwtExpiresAt, + invalidateNotificationsRef: cacheRefs.invalidateNotificationsRef, + sendIntents: [ + createWaveReadSendIntent({ + shouldSend: options?.shouldSend, + retryContext: getWaveReadSendRetryContext({ + addressKey: verifiedCachedIdentity.addressKey, + activeProfileProxyId, + proxyCreatorId: null, + identityKey: verifiedCachedIdentity.identityKey, + requestKey, + waveId, + addressEpoch, + cacheRefs, + queueIfBlocked, + }), + }), + ], + }); + } + + const latestVerifiedIdentity = getUsableLatestClearedWaveReadIdentity({ + addressKey, + identityKey, + cacheRefs, + }); + if (latestVerifiedIdentity?.identityKey === identityKey) { + return markWaveReadWithAuthHeaders({ + waveId, + addressKey: latestVerifiedIdentity.addressKey, + requestKey, + authHeaders: latestVerifiedIdentity.authHeaders, + jwtExpiresAt: latestVerifiedIdentity.jwtExpiresAt, + invalidateNotificationsRef: cacheRefs.invalidateNotificationsRef, + sendIntents: [ + createWaveReadSendIntent({ + shouldSend: options?.shouldSend, + retryContext: getWaveReadSendRetryContext({ + addressKey: latestVerifiedIdentity.addressKey, + activeProfileProxyId, + proxyCreatorId: null, + identityKey: latestVerifiedIdentity.identityKey, + requestKey, + waveId, + addressEpoch, + cacheRefs, + queueIfBlocked, + }), + }), + ], + }); + } + + const temporaryProxyRoleIdentity = + cacheRefs.temporaryProxyRoleIdentityByIdentityRef.current.get(identityKey); + if (temporaryProxyRoleIdentity) { + return markTemporaryProxyRoleWaveRead({ + waveId, + addressKey, + addressEpoch, + temporaryProxyRoleIdentity, + cacheRefs, + options, + }); + } + + return enqueuePendingWaveReadRequest({ + addressKey, + activeProfileProxyId, + proxyCreatorId: null, + identityKey, + requestKey, + waveId, + addressEpoch, + latestAddressEpochRef: cacheRefs.latestAddressEpochRef, + shouldSend: options?.shouldSend, + queueIfBlocked, + }); +}; diff --git a/hooks/useMarkWaveNotificationsRead.helpers.ts b/hooks/useMarkWaveNotificationsRead.helpers.ts new file mode 100644 index 0000000000..28930ae9a8 --- /dev/null +++ b/hooks/useMarkWaveNotificationsRead.helpers.ts @@ -0,0 +1,89 @@ +import { + markWaveReadFromCache, + useClearWaveReadStateOnAddressChange, + useClearWaveReadStateOnLastUnmount, + useSyncWaveReadVerifiedIdentityCaches, + useWaveReadCacheRefs, +} from "@/hooks/useMarkWaveNotificationsRead.cache"; +import { useWaveReadIdentityState } from "@/hooks/useMarkWaveNotificationsRead.identity"; +import type { + MarkWaveNotificationsReadOptions, + MarkWaveNotificationsReadResult, + WaveNotificationsReadMarkerConfig, + WaveNotificationsReadMarkerState, + WaveReadAddressEpoch, +} from "@/hooks/useMarkWaveNotificationsRead.types"; +import { useCallback, useMemo } from "react"; + +export type { + MarkWaveNotificationsReadOptions, + MarkWaveNotificationsReadResult, + WaveNotificationsReadMarkerState, +} from "@/hooks/useMarkWaveNotificationsRead.types"; + +export const useWaveNotificationsReadMarkerState = ({ + address, + activeProfileProxyId, + activeProfileProxyCreatorId, + walletAuth, + invalidateNotifications, +}: WaveNotificationsReadMarkerConfig): WaveNotificationsReadMarkerState => { + const identityState = useWaveReadIdentityState({ + address, + activeProfileProxyId, + activeProfileProxyCreatorId, + walletAuth, + }); + const { + addressKey, + activeProfileProxyId: currentProfileProxyId, + identityKey, + proxyRoleIdentityKey, + temporaryProxyRoleIdentity, + verifiedIdentity, + } = identityState; + const addressEpoch = useMemo( + () => ({ addressKey }), + [addressKey] + ); + const cacheRefs = useWaveReadCacheRefs({ + addressEpoch, + identityKey, + temporaryProxyRoleIdentity, + verifiedIdentity, + invalidateNotifications, + }); + useSyncWaveReadVerifiedIdentityCaches({ + walletAuth, + verifiedIdentity, + cacheRefs, + }); + useClearWaveReadStateOnAddressChange(addressKey); + useClearWaveReadStateOnLastUnmount(addressKey); + + const markWaveNotificationsRead = useCallback( + ( + waveId: string, + options?: MarkWaveNotificationsReadOptions + ): Promise => + markWaveReadFromCache({ + waveId, + addressKey, + activeProfileProxyId: currentProfileProxyId, + identityKey, + addressEpoch, + cacheRefs, + options, + }), + [addressEpoch, addressKey, cacheRefs, currentProfileProxyId, identityKey] + ); + + return useMemo( + () => ({ + markWaveNotificationsRead, + identityKey, + proxyRoleIdentityKey, + }), + [identityKey, markWaveNotificationsRead, proxyRoleIdentityKey] + ); +}; diff --git a/hooks/useMarkWaveNotificationsRead.identity.ts b/hooks/useMarkWaveNotificationsRead.identity.ts new file mode 100644 index 0000000000..13880bb055 --- /dev/null +++ b/hooks/useMarkWaveNotificationsRead.identity.ts @@ -0,0 +1,321 @@ +import { jwtDecode } from "jwt-decode"; +import { useMemo } from "react"; + +export type AuthHeaders = Record; + +interface WaveReadJwtPayload { + readonly sub?: string | undefined; + readonly role?: string | null | undefined; + readonly exp?: number | undefined; +} + +export interface WaveReadVerifiedIdentity { + readonly addressKey: string; + readonly activeProfileProxyId: string | null; + readonly activeProfileProxyCreatorId: string | null; + readonly identityKey: string; + readonly jwtExpiresAt: number; + readonly authHeaders: AuthHeaders; +} + +interface WaveReadJwtIdentity { + readonly addressKey: string; + readonly proxyCreatorId: string | null; + readonly jwtExpiresAt: number; +} + +export interface WaveReadTemporaryProxyRoleIdentity { + readonly addressKey: string; + readonly proxyCreatorId: string; + readonly identityKey: string; +} + +export interface WaveReadIdentityConfig { + readonly address: string | undefined; + readonly activeProfileProxyId: string | null; + readonly activeProfileProxyCreatorId: string | null; + readonly walletAuth: string | null; +} + +interface WaveReadIdentityState { + readonly addressKey: string | null; + readonly activeProfileProxyId: string | null; + readonly identityKey: string; + readonly proxyRoleIdentityKey: string | null; + readonly temporaryProxyRoleIdentity: + | WaveReadTemporaryProxyRoleIdentity + | undefined; + readonly verifiedIdentity: WaveReadVerifiedIdentity | undefined; +} + +const getAddressKey = (address: string | undefined): string | null => + address?.toLowerCase() ?? null; + +export const getWaveReadIdentityKey = ({ + addressKey, + activeProfileProxyId, +}: { + readonly addressKey: string | null; + readonly activeProfileProxyId: string | null; +}): string => JSON.stringify([addressKey, activeProfileProxyId]); + +export const getWaveReadProxyRoleIdentityKey = ({ + addressKey, + proxyCreatorId, +}: { + readonly addressKey: string; + readonly proxyCreatorId: string; +}): string => JSON.stringify([addressKey, "proxy-role", proxyCreatorId]); + +export const getWaveReadRequestKey = ({ + addressKey, + activeProfileProxyId, + waveId, +}: { + readonly addressKey: string | null; + readonly activeProfileProxyId: string | null; + readonly waveId: string; +}): string => JSON.stringify([addressKey, activeProfileProxyId, waveId]); + +export const getWaveReadProxyRoleRequestKey = ({ + addressKey, + proxyCreatorId, + waveId, +}: { + readonly addressKey: string; + readonly proxyCreatorId: string; + readonly waveId: string; +}): string => + JSON.stringify([addressKey, "proxy-role", proxyCreatorId, waveId]); + +const getAuthHeaders = (walletAuth: string): AuthHeaders => ({ + Authorization: `Bearer ${walletAuth}`, +}); + +export const isWaveReadJwtExpired = (jwtExpiresAt: number): boolean => + jwtExpiresAt <= Math.floor(Date.now() / 1000); + +const decodeWaveReadJwtIdentity = ( + walletAuth: string | null +): WaveReadJwtIdentity | undefined => { + if (!walletAuth) { + return undefined; + } + + try { + const decodedJwt = jwtDecode(walletAuth); + const expiresAt = decodedJwt.exp; + if ( + typeof expiresAt !== "number" || + !Number.isFinite(expiresAt) || + isWaveReadJwtExpired(expiresAt) + ) { + return undefined; + } + + const addressKey = decodedJwt.sub?.toLowerCase() ?? null; + if (!addressKey) { + return undefined; + } + + return { + addressKey, + proxyCreatorId: + typeof decodedJwt.role === "string" && decodedJwt.role.length > 0 + ? decodedJwt.role + : null, + jwtExpiresAt: expiresAt, + }; + } catch { + return undefined; + } +}; + +const getVerifiedAuthHeaders = ({ + walletAuth, + addressKey, + jwtIdentity, + activeProfileProxyCreatorId, +}: { + readonly walletAuth: string | null; + readonly addressKey: string | null; + readonly jwtIdentity: WaveReadJwtIdentity | undefined; + readonly activeProfileProxyCreatorId: string | null; +}): AuthHeaders | undefined => { + if (!walletAuth || !addressKey || !jwtIdentity) { + return undefined; + } + + if (jwtIdentity.addressKey !== addressKey) { + return undefined; + } + + if (jwtIdentity.proxyCreatorId !== activeProfileProxyCreatorId) { + return undefined; + } + + return getAuthHeaders(walletAuth); +}; + +const getTemporaryProxyRoleIdentity = ({ + addressKey, + activeProfileProxyId, + jwtIdentity, +}: { + readonly addressKey: string | null; + readonly activeProfileProxyId: string | null; + readonly jwtIdentity: WaveReadJwtIdentity | undefined; +}): WaveReadTemporaryProxyRoleIdentity | undefined => { + if (addressKey === null || activeProfileProxyId !== null) { + return undefined; + } + + const proxyCreatorId = + jwtIdentity?.addressKey === addressKey ? jwtIdentity.proxyCreatorId : null; + + if (proxyCreatorId === null) { + return undefined; + } + + return { + addressKey, + proxyCreatorId, + identityKey: getWaveReadProxyRoleIdentityKey({ + addressKey, + proxyCreatorId, + }), + }; +}; + +const getVerifiedIdentity = ({ + addressKey, + activeProfileProxyId, + activeProfileProxyCreatorId, + identityKey, + jwtIdentity, + verifiedAuthHeaders, +}: { + readonly addressKey: string | null; + readonly activeProfileProxyId: string | null; + readonly activeProfileProxyCreatorId: string | null; + readonly identityKey: string; + readonly jwtIdentity: WaveReadJwtIdentity | undefined; + readonly verifiedAuthHeaders: AuthHeaders | undefined; +}): WaveReadVerifiedIdentity | undefined => { + if (!verifiedAuthHeaders || addressKey === null || !jwtIdentity) { + return undefined; + } + + if (isWaveReadJwtExpired(jwtIdentity.jwtExpiresAt)) { + return undefined; + } + + return { + addressKey, + activeProfileProxyId, + activeProfileProxyCreatorId, + identityKey, + jwtExpiresAt: jwtIdentity.jwtExpiresAt, + authHeaders: verifiedAuthHeaders, + }; +}; + +const getProxyRoleIdentityKey = ({ + addressKey, + activeProfileProxyCreatorId, + temporaryProxyRoleIdentity, +}: { + readonly addressKey: string | null; + readonly activeProfileProxyCreatorId: string | null; + readonly temporaryProxyRoleIdentity: + | WaveReadTemporaryProxyRoleIdentity + | undefined; +}): string | null => { + if (temporaryProxyRoleIdentity) { + return temporaryProxyRoleIdentity.identityKey; + } + + if (addressKey === null || activeProfileProxyCreatorId === null) { + return null; + } + + return getWaveReadProxyRoleIdentityKey({ + addressKey, + proxyCreatorId: activeProfileProxyCreatorId, + }); +}; + +export const useWaveReadIdentityState = ({ + address, + activeProfileProxyId, + activeProfileProxyCreatorId, + walletAuth, +}: WaveReadIdentityConfig): WaveReadIdentityState => { + const addressKey = getAddressKey(address); + const jwtIdentity = useMemo( + () => decodeWaveReadJwtIdentity(walletAuth), + [walletAuth] + ); + const verifiedAuthHeaders = useMemo( + () => + getVerifiedAuthHeaders({ + walletAuth, + addressKey, + jwtIdentity, + activeProfileProxyCreatorId, + }), + [walletAuth, addressKey, jwtIdentity, activeProfileProxyCreatorId] + ); + const temporaryProxyRoleIdentity = useMemo( + () => + getTemporaryProxyRoleIdentity({ + addressKey, + activeProfileProxyId, + jwtIdentity, + }), + [activeProfileProxyId, addressKey, jwtIdentity] + ); + const identityKey = + temporaryProxyRoleIdentity?.identityKey ?? + getWaveReadIdentityKey({ + addressKey, + activeProfileProxyId, + }); + const proxyRoleIdentityKey = useMemo( + () => + getProxyRoleIdentityKey({ + addressKey, + activeProfileProxyCreatorId, + temporaryProxyRoleIdentity, + }), + [activeProfileProxyCreatorId, addressKey, temporaryProxyRoleIdentity] + ); + const verifiedIdentity = useMemo( + () => + getVerifiedIdentity({ + addressKey, + activeProfileProxyId, + activeProfileProxyCreatorId, + identityKey, + jwtIdentity, + verifiedAuthHeaders, + }), + [ + activeProfileProxyCreatorId, + activeProfileProxyId, + addressKey, + identityKey, + jwtIdentity, + verifiedAuthHeaders, + ] + ); + + return { + addressKey, + activeProfileProxyId, + identityKey, + proxyRoleIdentityKey, + temporaryProxyRoleIdentity, + verifiedIdentity, + }; +}; diff --git a/hooks/useMarkWaveNotificationsRead.requests.ts b/hooks/useMarkWaveNotificationsRead.requests.ts new file mode 100644 index 0000000000..7a50cbf5c1 --- /dev/null +++ b/hooks/useMarkWaveNotificationsRead.requests.ts @@ -0,0 +1,678 @@ +import { + getWaveReadIdentityKey, + getWaveReadProxyRoleIdentityKey, + getWaveReadRequestKey, + isWaveReadJwtExpired, +} from "@/hooks/useMarkWaveNotificationsRead.identity"; +import type { + AuthHeaders, + WaveReadVerifiedIdentity, +} from "@/hooks/useMarkWaveNotificationsRead.identity"; +import type { + InvalidateNotificationsRef, + MarkWaveNotificationsReadResult, + PendingWaveReadRequestState, + WaveReadAddressEpoch, + WaveReadRequestState, + WaveReadSendIntent, + WaveReadSendRetryContext, + WaveReadShouldSend, +} from "@/hooks/useMarkWaveNotificationsRead.types"; +import { commonApiPostWithoutBodyAndResponse } from "@/services/api/common-api"; +import type { RefObject } from "react"; + +const inFlightWaveReadRequests = new Map(); +const pendingWaveReadRequests = new Map(); +const clearedWaveReadIdentityKeysByAddress = new Map>(); +const latestVerifiedWaveReadIdentityByAddress = new Map< + string, + WaveReadVerifiedIdentity +>(); + +const createClearedWaveReadStateError = (reason: string): Error => + new Error(`Pending wave notification read cleared: ${reason}.`); + +export const getStaleAddressEpochWaveReadResult = ({ + addressEpoch, + latestAddressEpochRef, + queueIfBlocked, +}: { + readonly addressEpoch: WaveReadAddressEpoch; + readonly latestAddressEpochRef: RefObject; + readonly queueIfBlocked: boolean; +}): Promise | undefined => { + if (addressEpoch === latestAddressEpochRef.current) { + return undefined; + } + + if (!queueIfBlocked) { + return Promise.resolve("skipped"); + } + + return Promise.reject( + createClearedWaveReadStateError("wallet address changed or disconnected") + ); +}; + +const clearInFlightWaveReadState = (state: WaveReadRequestState): void => { + state.pendingSendIntents = []; +}; + +const isCurrentInFlightWaveReadRequest = ( + state: WaveReadRequestState +): boolean => inFlightWaveReadRequests.get(state.requestKey) === state; + +export const clearPendingWaveReadsForAddress = (addressKey: string): void => { + for (const [requestKey, state] of pendingWaveReadRequests) { + if (state.addressKey !== addressKey) { + continue; + } + + pendingWaveReadRequests.delete(requestKey); + state.reject( + createClearedWaveReadStateError("wallet address changed or disconnected") + ); + } + + for (const [requestKey, state] of inFlightWaveReadRequests) { + if (state.addressKey !== addressKey) { + continue; + } + + clearInFlightWaveReadState(state); + inFlightWaveReadRequests.delete(requestKey); + } + + clearedWaveReadIdentityKeysByAddress.delete(addressKey); + latestVerifiedWaveReadIdentityByAddress.delete(addressKey); +}; + +export const clearAllWaveReadState = (): void => { + for (const state of pendingWaveReadRequests.values()) { + state.reject( + createClearedWaveReadStateError("no marker hooks are mounted") + ); + } + + pendingWaveReadRequests.clear(); + + for (const state of inFlightWaveReadRequests.values()) { + clearInFlightWaveReadState(state); + } + + inFlightWaveReadRequests.clear(); + clearedWaveReadIdentityKeysByAddress.clear(); + latestVerifiedWaveReadIdentityByAddress.clear(); +}; + +const hasConsistentPendingWaveReadIdentity = ( + state: PendingWaveReadRequestState +): boolean => { + if (state.proxyCreatorId !== null) { + return ( + state.identityKey === + getWaveReadProxyRoleIdentityKey({ + addressKey: state.addressKey, + proxyCreatorId: state.proxyCreatorId, + }) + ); + } + + return ( + state.identityKey === + getWaveReadIdentityKey({ + addressKey: state.addressKey, + activeProfileProxyId: state.activeProfileProxyId, + }) + ); +}; + +const shouldSendWaveRead = (sendIntent: WaveReadSendIntent): boolean => + sendIntent.shouldSend?.() ?? true; + +const hasSendableWaveRead = ( + sendIntents: readonly WaveReadSendIntent[] +): boolean => sendIntents.some(shouldSendWaveRead); + +const mergeWaveReadResults = ( + first: MarkWaveNotificationsReadResult, + second: MarkWaveNotificationsReadResult +): MarkWaveNotificationsReadResult => + first === "sent" || second === "sent" ? "sent" : "skipped"; + +type WaveReadSendRequestResult = + | MarkWaveNotificationsReadResult + | "auth-expired"; + +const createWaveReadSendIntent = ({ + shouldSend, + retryContext, +}: { + readonly shouldSend: WaveReadShouldSend; + readonly retryContext: WaveReadSendRetryContext | undefined; +}): WaveReadSendIntent => ({ + shouldSend, + retryContext, +}); + +const requeueExpiredWaveReadIntents = ( + sendIntents: readonly WaveReadSendIntent[], + invalidateNotificationsRef: InvalidateNotificationsRef +): Promise => { + const requeuedAddressKeys = new Set(); + const retryPromises = sendIntents + .filter(shouldSendWaveRead) + .map((sendIntent) => { + const retryContext = sendIntent.retryContext; + if (!retryContext) { + return Promise.resolve("skipped"); + } + + if ( + retryContext.addressEpoch === retryContext.latestAddressEpochRef.current + ) { + requeuedAddressKeys.add(retryContext.addressKey); + } + + return enqueuePendingWaveReadRequest({ + ...retryContext, + shouldSend: sendIntent.shouldSend, + queueIfBlocked: true, + }); + }); + + if (retryPromises.length === 0) { + return Promise.resolve("skipped"); + } + + for (const addressKey of requeuedAddressKeys) { + const verifiedIdentity = + latestVerifiedWaveReadIdentityByAddress.get(addressKey); + if ( + !verifiedIdentity || + isWaveReadJwtExpired(verifiedIdentity.jwtExpiresAt) + ) { + continue; + } + + flushPendingWaveReadRequests({ + verifiedIdentity, + invalidateNotificationsRef, + }); + } + + return Promise.all(retryPromises).then((results) => + results.reduce( + mergeWaveReadResults, + "skipped" + ) + ); +}; + +const sendWaveReadRequest = async ( + waveId: string, + authHeaders: AuthHeaders, + jwtExpiresAt: number, + invalidateNotificationsRef: InvalidateNotificationsRef, + sendIntents: readonly WaveReadSendIntent[] +): Promise => { + if (!hasSendableWaveRead(sendIntents)) { + return "skipped"; + } + + if (isWaveReadJwtExpired(jwtExpiresAt)) { + return "auth-expired"; + } + + await commonApiPostWithoutBodyAndResponse({ + endpoint: `notifications/wave/${waveId}/read`, + headers: authHeaders, + }); + invalidateNotificationsRef.current(); + return "sent"; +}; + +const startWaveReadRequest = async ( + waveId: string, + state: WaveReadRequestState, + invalidateNotificationsRef: InvalidateNotificationsRef +): Promise => { + let requestError: unknown; + let hasRequestError = false; + let requestResult: MarkWaveNotificationsReadResult = "skipped"; + + try { + const sendResult = await sendWaveReadRequest( + waveId, + state.authHeaders, + state.jwtExpiresAt, + invalidateNotificationsRef, + state.sendIntents + ); + + if (sendResult === "auth-expired") { + if (!isCurrentInFlightWaveReadRequest(state)) { + requestResult = "skipped"; + } else { + const expiredSendIntents = [ + ...state.sendIntents, + ...state.pendingSendIntents, + ]; + state.pendingSendIntents = []; + inFlightWaveReadRequests.delete(state.requestKey); + requestResult = await requeueExpiredWaveReadIntents( + expiredSendIntents, + invalidateNotificationsRef + ); + } + } else { + requestResult = sendResult; + } + } catch (error) { + requestError = error; + hasRequestError = true; + } + + try { + if ( + state.pendingSendIntents.length > 0 && + isCurrentInFlightWaveReadRequest(state) + ) { + state.sendIntents = state.pendingSendIntents; + state.pendingSendIntents = []; + state.promise = startWaveReadRequest( + waveId, + state, + invalidateNotificationsRef + ); + + let trailingResult: MarkWaveNotificationsReadResult; + try { + trailingResult = await state.promise; + } catch (trailingError) { + if (hasRequestError) { + throw requestError; + } + + throw trailingError; + } + + if (hasRequestError && trailingResult !== "sent") { + throw requestError; + } + + return mergeWaveReadResults(requestResult, trailingResult); + } + + if (hasRequestError) { + throw requestError; + } + + return requestResult; + } finally { + if (isCurrentInFlightWaveReadRequest(state)) { + inFlightWaveReadRequests.delete(state.requestKey); + } + } +}; + +export const markWaveReadWithAuthHeaders = ({ + waveId, + addressKey, + requestKey, + authHeaders, + jwtExpiresAt, + invalidateNotificationsRef, + sendIntents, +}: { + readonly waveId: string; + readonly addressKey: string; + readonly requestKey: string; + readonly authHeaders: AuthHeaders; + readonly jwtExpiresAt: number; + readonly invalidateNotificationsRef: InvalidateNotificationsRef; + readonly sendIntents: readonly WaveReadSendIntent[]; +}): Promise => { + const existingState = inFlightWaveReadRequests.get(requestKey); + if (existingState) { + existingState.authHeaders = authHeaders; + existingState.jwtExpiresAt = jwtExpiresAt; + existingState.pendingSendIntents.push(...sendIntents); + return existingState.promise; + } + + const state: WaveReadRequestState = { + promise: Promise.resolve("skipped"), + addressKey, + requestKey, + authHeaders, + jwtExpiresAt, + sendIntents: [...sendIntents], + pendingSendIntents: [], + }; + inFlightWaveReadRequests.set(requestKey, state); + state.promise = startWaveReadRequest( + waveId, + state, + invalidateNotificationsRef + ); + return state.promise; +}; + +export const markWaveReadIdentityCleared = ( + identity: WaveReadVerifiedIdentity +): void => { + const clearedIdentityKeys = + clearedWaveReadIdentityKeysByAddress.get(identity.addressKey) ?? + new Set(); + clearedIdentityKeys.add(identity.identityKey); + clearedWaveReadIdentityKeysByAddress.set( + identity.addressKey, + clearedIdentityKeys + ); +}; + +export const getVerifiedProxyRoleIdentityKey = ( + identity: WaveReadVerifiedIdentity +): string | null => + identity.activeProfileProxyCreatorId !== null + ? getWaveReadProxyRoleIdentityKey({ + addressKey: identity.addressKey, + proxyCreatorId: identity.activeProfileProxyCreatorId, + }) + : null; + +export function enqueuePendingWaveReadRequest({ + addressKey, + activeProfileProxyId, + proxyCreatorId, + identityKey, + requestKey, + waveId, + addressEpoch, + latestAddressEpochRef, + shouldSend, + queueIfBlocked, +}: { + readonly addressKey: string; + readonly activeProfileProxyId: string | null; + readonly proxyCreatorId: string | null; + readonly identityKey: string; + readonly requestKey: string; + readonly waveId: string; + readonly addressEpoch: WaveReadAddressEpoch; + readonly latestAddressEpochRef: RefObject; + readonly shouldSend: WaveReadShouldSend; + readonly queueIfBlocked: boolean; +}): Promise { + const staleAddressEpochResult = getStaleAddressEpochWaveReadResult({ + addressEpoch, + latestAddressEpochRef, + queueIfBlocked, + }); + if (staleAddressEpochResult) { + return staleAddressEpochResult; + } + + if (!queueIfBlocked) { + return Promise.resolve("skipped"); + } + + const retryContext: WaveReadSendRetryContext = { + addressKey, + activeProfileProxyId, + proxyCreatorId, + identityKey, + requestKey, + waveId, + addressEpoch, + latestAddressEpochRef, + }; + const sendIntent = createWaveReadSendIntent({ + shouldSend, + retryContext, + }); + + const existingState = pendingWaveReadRequests.get(requestKey); + if (existingState) { + existingState.sendIntents.push(sendIntent); + return existingState.promise; + } + + let resolveQueuedRequest: ( + result: MarkWaveNotificationsReadResult + ) => void = () => {}; + let rejectQueuedRequest: (error: unknown) => void = () => {}; + const promise = new Promise( + (resolve, reject) => { + resolveQueuedRequest = resolve; + rejectQueuedRequest = reject; + } + ); + + const state: PendingWaveReadRequestState = { + addressKey, + activeProfileProxyId, + proxyCreatorId, + identityKey, + requestKey, + waveId, + addressEpoch, + latestAddressEpochRef, + promise, + resolve: resolveQueuedRequest, + reject: rejectQueuedRequest, + sendIntents: [sendIntent], + }; + pendingWaveReadRequests.set(requestKey, state); + + return promise; +} + +const flushQueuedWaveReadRequests = ({ + queuedRequests, + getRequestKey, + getRetryContext, + authHeaders, + jwtExpiresAt, + invalidateNotificationsRef, +}: { + readonly queuedRequests: readonly PendingWaveReadRequestState[]; + readonly getRequestKey: ( + queuedRequest: PendingWaveReadRequestState + ) => string; + readonly getRetryContext: ( + queuedRequest: PendingWaveReadRequestState, + requestKey: string + ) => WaveReadSendRetryContext; + readonly authHeaders: AuthHeaders; + readonly jwtExpiresAt: number; + readonly invalidateNotificationsRef: InvalidateNotificationsRef; +}): void => { + const queuedRequestsByRequestKey = new Map< + string, + PendingWaveReadRequestState[] + >(); + + for (const queuedRequest of queuedRequests) { + if ( + pendingWaveReadRequests.get(queuedRequest.requestKey) !== queuedRequest + ) { + continue; + } + + if ( + queuedRequest.addressEpoch !== queuedRequest.latestAddressEpochRef.current + ) { + pendingWaveReadRequests.delete(queuedRequest.requestKey); + queuedRequest.reject( + createClearedWaveReadStateError( + "wallet address changed or disconnected" + ) + ); + continue; + } + + const requestKey = getRequestKey(queuedRequest); + pendingWaveReadRequests.delete(queuedRequest.requestKey); + const groupedRequests = queuedRequestsByRequestKey.get(requestKey) ?? []; + groupedRequests.push(queuedRequest); + queuedRequestsByRequestKey.set(requestKey, groupedRequests); + } + + for (const [requestKey, groupedRequests] of queuedRequestsByRequestKey) { + const [firstQueuedRequest] = groupedRequests; + if (!firstQueuedRequest) { + continue; + } + + const sendIntents = groupedRequests.flatMap((queuedRequest) => + queuedRequest.sendIntents.map((sendIntent) => + createWaveReadSendIntent({ + shouldSend: sendIntent.shouldSend, + retryContext: getRetryContext(queuedRequest, requestKey), + }) + ) + ); + + void (async () => { + try { + const result = await markWaveReadWithAuthHeaders({ + waveId: firstQueuedRequest.waveId, + addressKey: firstQueuedRequest.addressKey, + requestKey, + authHeaders, + jwtExpiresAt, + invalidateNotificationsRef, + sendIntents, + }); + for (const queuedRequest of groupedRequests) { + queuedRequest.resolve(result); + } + } catch (error) { + for (const queuedRequest of groupedRequests) { + queuedRequest.reject(error); + } + } + })(); + } +}; + +export function flushPendingWaveReadRequests({ + verifiedIdentity, + invalidateNotificationsRef, +}: { + readonly verifiedIdentity: WaveReadVerifiedIdentity; + readonly invalidateNotificationsRef: InvalidateNotificationsRef; +}): void { + const proxyRoleIdentityKey = + getVerifiedProxyRoleIdentityKey(verifiedIdentity); + const queuedRequests = Array.from(pendingWaveReadRequests.values()).filter( + (state) => + (state.identityKey === verifiedIdentity.identityKey || + state.identityKey === proxyRoleIdentityKey) && + state.addressKey === verifiedIdentity.addressKey && + hasConsistentPendingWaveReadIdentity(state) + ); + + flushQueuedWaveReadRequests({ + queuedRequests, + getRequestKey: (queuedRequest) => { + if (queuedRequest.identityKey !== proxyRoleIdentityKey) { + return queuedRequest.requestKey; + } + + return getWaveReadRequestKey({ + addressKey: verifiedIdentity.addressKey, + activeProfileProxyId: verifiedIdentity.activeProfileProxyId, + waveId: queuedRequest.waveId, + }); + }, + getRetryContext: (queuedRequest, requestKey) => ({ + addressKey: verifiedIdentity.addressKey, + activeProfileProxyId: verifiedIdentity.activeProfileProxyId, + proxyCreatorId: null, + identityKey: verifiedIdentity.identityKey, + requestKey, + waveId: queuedRequest.waveId, + addressEpoch: queuedRequest.addressEpoch, + latestAddressEpochRef: queuedRequest.latestAddressEpochRef, + }), + authHeaders: verifiedIdentity.authHeaders, + jwtExpiresAt: verifiedIdentity.jwtExpiresAt, + invalidateNotificationsRef, + }); +} + +export const flushPendingClearedWaveReadRequests = ({ + verifiedIdentity, + invalidateNotificationsRef, +}: { + readonly verifiedIdentity: WaveReadVerifiedIdentity; + readonly invalidateNotificationsRef: InvalidateNotificationsRef; +}): void => { + const clearedIdentityKeys = clearedWaveReadIdentityKeysByAddress.get( + verifiedIdentity.addressKey + ); + if (!clearedIdentityKeys?.has(verifiedIdentity.identityKey)) { + return; + } + + const queuedRequests = Array.from(pendingWaveReadRequests.values()).filter( + (state) => + state.identityKey === verifiedIdentity.identityKey && + hasConsistentPendingWaveReadIdentity(state) && + state.addressKey === verifiedIdentity.addressKey + ); + + flushQueuedWaveReadRequests({ + queuedRequests, + getRequestKey: (queuedRequest) => queuedRequest.requestKey, + getRetryContext: (queuedRequest, requestKey) => ({ + addressKey: verifiedIdentity.addressKey, + activeProfileProxyId: verifiedIdentity.activeProfileProxyId, + proxyCreatorId: null, + identityKey: verifiedIdentity.identityKey, + requestKey, + waveId: queuedRequest.waveId, + addressEpoch: queuedRequest.addressEpoch, + latestAddressEpochRef: queuedRequest.latestAddressEpochRef, + }), + authHeaders: verifiedIdentity.authHeaders, + jwtExpiresAt: verifiedIdentity.jwtExpiresAt, + invalidateNotificationsRef, + }); + + clearedIdentityKeys.delete(verifiedIdentity.identityKey); + if (clearedIdentityKeys.size === 0) { + clearedWaveReadIdentityKeysByAddress.delete(verifiedIdentity.addressKey); + } +}; + +export const getLatestVerifiedWaveReadIdentityByAddress = ( + addressKey: string +): WaveReadVerifiedIdentity | undefined => + latestVerifiedWaveReadIdentityByAddress.get(addressKey); + +export const setLatestVerifiedWaveReadIdentityByAddress = ( + identity: WaveReadVerifiedIdentity +): void => { + latestVerifiedWaveReadIdentityByAddress.set(identity.addressKey, identity); +}; + +export const deleteLatestVerifiedWaveReadIdentityByAddress = ( + addressKey: string +): void => { + latestVerifiedWaveReadIdentityByAddress.delete(addressKey); +}; + +export const deleteLatestVerifiedWaveReadIdentityIfCurrent = ( + identity: WaveReadVerifiedIdentity +): void => { + if ( + latestVerifiedWaveReadIdentityByAddress.get(identity.addressKey) === + identity + ) { + latestVerifiedWaveReadIdentityByAddress.delete(identity.addressKey); + } +}; diff --git a/hooks/useMarkWaveNotificationsRead.ts b/hooks/useMarkWaveNotificationsRead.ts new file mode 100644 index 0000000000..e1b271815d --- /dev/null +++ b/hooks/useMarkWaveNotificationsRead.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useAuth } from "@/components/auth/Auth"; +import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { + useWaveNotificationsReadMarkerState as useWaveNotificationsReadMarkerStateFromConfig, + type MarkWaveNotificationsReadResult, + type MarkWaveNotificationsReadOptions, + type WaveNotificationsReadMarkerState, +} from "@/hooks/useMarkWaveNotificationsRead.helpers"; +import { getAuthJwt } from "@/services/auth/auth.utils"; +import { useContext } from "react"; + +export function useWaveNotificationsReadMarkerState(): WaveNotificationsReadMarkerState { + const { invalidateNotifications } = useContext(ReactQueryWrapperContext); + const { address } = useSeizeConnectContext(); + const { activeProfileProxy } = useAuth(); + const activeProfileProxyId = activeProfileProxy?.id ?? null; + const activeProfileProxyCreatorId = activeProfileProxy + ? activeProfileProxy.created_by.id + : null; + + return useWaveNotificationsReadMarkerStateFromConfig({ + address, + activeProfileProxyId, + activeProfileProxyCreatorId, + walletAuth: getAuthJwt(), + invalidateNotifications, + }); +} + +export function useMarkWaveNotificationsRead(): ( + waveId: string, + options?: MarkWaveNotificationsReadOptions +) => Promise { + return useWaveNotificationsReadMarkerState().markWaveNotificationsRead; +} diff --git a/hooks/useMarkWaveNotificationsRead.types.ts b/hooks/useMarkWaveNotificationsRead.types.ts new file mode 100644 index 0000000000..237ce4e4b6 --- /dev/null +++ b/hooks/useMarkWaveNotificationsRead.types.ts @@ -0,0 +1,92 @@ +import type { + AuthHeaders, + WaveReadIdentityConfig, + WaveReadTemporaryProxyRoleIdentity, + WaveReadVerifiedIdentity, +} from "@/hooks/useMarkWaveNotificationsRead.identity"; +import type { RefObject } from "react"; + +export type MarkWaveNotificationsReadResult = "sent" | "skipped"; + +export interface MarkWaveNotificationsReadOptions { + readonly shouldSend?: () => boolean; + readonly queueIfBlocked?: boolean; +} + +export interface WaveNotificationsReadMarkerState { + readonly markWaveNotificationsRead: ( + waveId: string, + options?: MarkWaveNotificationsReadOptions + ) => Promise; + readonly identityKey: string; + readonly proxyRoleIdentityKey: string | null; +} + +export interface WaveNotificationsReadMarkerConfig extends WaveReadIdentityConfig { + readonly invalidateNotifications: () => void; +} + +export type WaveReadAddressEpoch = object; + +export type WaveReadShouldSend = (() => boolean) | undefined; + +export type InvalidateNotificationsRef = Readonly<{ + current: () => void; +}>; + +export interface WaveReadSendRetryContext { + readonly addressKey: string; + readonly activeProfileProxyId: string | null; + readonly proxyCreatorId: string | null; + readonly identityKey: string; + readonly requestKey: string; + readonly waveId: string; + readonly addressEpoch: WaveReadAddressEpoch; + readonly latestAddressEpochRef: RefObject; +} + +export interface WaveReadSendIntent { + readonly shouldSend: WaveReadShouldSend; + readonly retryContext: WaveReadSendRetryContext | undefined; +} + +export interface WaveReadRequestState { + promise: Promise; + readonly addressKey: string; + readonly requestKey: string; + authHeaders: AuthHeaders; + jwtExpiresAt: number; + sendIntents: WaveReadSendIntent[]; + pendingSendIntents: WaveReadSendIntent[]; +} + +export interface PendingWaveReadRequestState { + readonly addressKey: string; + readonly activeProfileProxyId: string | null; + readonly proxyCreatorId: string | null; + readonly identityKey: string; + readonly requestKey: string; + readonly waveId: string; + readonly addressEpoch: WaveReadAddressEpoch; + readonly latestAddressEpochRef: RefObject; + readonly promise: Promise; + readonly resolve: (result: MarkWaveNotificationsReadResult) => void; + readonly reject: (error: unknown) => void; + readonly sendIntents: WaveReadSendIntent[]; +} + +export interface WaveReadCacheRefs { + readonly invalidateNotificationsRef: InvalidateNotificationsRef; + readonly latestAddressEpochRef: RefObject; + readonly authByIdentityRef: RefObject>; + readonly temporaryProxyRoleIdentityByIdentityRef: RefObject< + Map + >; + readonly latestVerifiedIdentityByAddressRef: RefObject< + Map + >; + readonly latestVerifiedIdentityByProxyRoleRef: RefObject< + Map + >; + readonly clearedIdentityKeysRef: RefObject>; +}