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