mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
perf(app): reduce redundant data fetching
Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware. Add focused regression coverage, client freshness documentation, and repository agent role configuration.
This commit is contained in:
1 parent
9957fa43f2
commit
c7ee8a4851
81 files changed
+4456
-1400
No files matched your search
@@ -17,13 +17,19 @@ jest.mock("expo-router", () => ({
|
||||
jest.mock("@/store", () => {
|
||||
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
|
||||
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
|
||||
const packState = { ...packs, invalidatePacks: jest.fn() };
|
||||
const responses = { invalidateResponses: jest.fn() };
|
||||
const useAuthStore = () => auth;
|
||||
useAuthStore.getState = () => auth.user;
|
||||
const usePacksStore = () => packs;
|
||||
usePacksStore.getState = () => packState;
|
||||
const useResponsesStore = () => responses;
|
||||
useResponsesStore.getState = () => responses;
|
||||
// Read lazily: the factory runs before this module's own consts are initialised.
|
||||
const streak = { fetchStreak: () => mockFetchStreak() };
|
||||
const useStreakStore = () => streak;
|
||||
useStreakStore.getState = () => streak;
|
||||
return { useAuthStore, usePacksStore: () => packs, useStreakStore };
|
||||
return { useAuthStore, usePacksStore, useResponsesStore, useStreakStore };
|
||||
});
|
||||
|
||||
jest.mock("@/lib/skippedQuestions", () => ({
|
||||
@@ -45,17 +51,19 @@ const questions = [
|
||||
{ id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" },
|
||||
];
|
||||
|
||||
const futureResetAt = "2099-08-29T00:00:00.000Z";
|
||||
|
||||
const openLimit = {
|
||||
responses_today: 4,
|
||||
limit_value: 10,
|
||||
remaining: 6,
|
||||
reset_at: "2026-08-29T00:00:00.000Z",
|
||||
reset_at: futureResetAt,
|
||||
is_blocked: false,
|
||||
};
|
||||
|
||||
const dailyLimit429 = () => new ApiError("Daily response limit reached", 429, {
|
||||
error: { code: "daily_limit", message: "Daily response limit reached" },
|
||||
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: "2026-08-29T00:00:00.000Z" },
|
||||
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: futureResetAt },
|
||||
});
|
||||
|
||||
// clearMocks wipes implementations between tests, so re-arm the whole surface each time.
|
||||
@@ -92,7 +100,7 @@ describe("useSwipeScreen daily limit", () => {
|
||||
responses_today: 10,
|
||||
limit_value: 10,
|
||||
remaining: 0,
|
||||
reset_at: "2026-08-29T00:00:00.000Z",
|
||||
reset_at: futureResetAt,
|
||||
is_blocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
import { useCoupleSync, SYNC_INTERVAL_MS } from '@/hooks/useCoupleSync';
|
||||
import { appDataApi, type SyncSummary } from '@/lib/appDataApi';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useMatchStore } from '@/store/matchStore';
|
||||
import { useMessageStore } from '@/store/messageStore';
|
||||
import { usePacksStore } from '@/store/packsStore';
|
||||
import { useStreakStore } from '@/store/streakStore';
|
||||
|
||||
jest.mock('@/lib/appDataApi', () => ({ appDataApi: { syncSummary: jest.fn() } }));
|
||||
|
||||
const mockForeground = { value: true };
|
||||
const mockListeners = new Set<(foreground: boolean) => void>();
|
||||
jest.mock('@/lib/appForeground', () => ({
|
||||
isForeground: () => mockForeground.value,
|
||||
subscribeToForeground: (listener: (foreground: boolean) => void) => {
|
||||
mockListeners.add(listener);
|
||||
return () => { mockListeners.delete(listener); };
|
||||
},
|
||||
}));
|
||||
|
||||
const baseline: SyncSummary = {
|
||||
server_time: '2026-08-27T12:00:00.000Z',
|
||||
couple_id: 'couple',
|
||||
profile_updated_at: '2026-08-01T00:00:00.000Z',
|
||||
partner_id: 'partner',
|
||||
partner_updated_at: '2026-08-01T00:00:00.000Z',
|
||||
match_count: 3,
|
||||
new_match_count: 0,
|
||||
latest_match_at: '2026-08-20T00:00:00.000Z',
|
||||
pending_yours: 1,
|
||||
pending_theirs: 2,
|
||||
unread_total: 0,
|
||||
enabled_packs_fingerprint: 'digest-a',
|
||||
match_state_fingerprint: 'state-a',
|
||||
match_unread_fingerprint: 'unread-a',
|
||||
streak_updated_at: '2026-08-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
|
||||
const tick = async () => {
|
||||
await flush();
|
||||
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS); });
|
||||
await flush();
|
||||
};
|
||||
|
||||
/** Replaces every domain refresh the sync can dispatch with a counter. */
|
||||
function stubRefreshers() {
|
||||
const fetchUser = jest.fn(async () => undefined);
|
||||
const fetchCouple = jest.fn(async () => undefined);
|
||||
const fetchMatches = jest.fn(async () => undefined);
|
||||
const fetchPendingQuestions = jest.fn(async () => undefined);
|
||||
const fetchTheirTurnQuestions = jest.fn(async () => undefined);
|
||||
const fetchEnabledPacks = jest.fn(async () => undefined);
|
||||
const refreshStreak = jest.fn(async () => undefined);
|
||||
useAuthStore.setState({ fetchUser, fetchCouple } as never);
|
||||
useMatchStore.setState({ fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions } as never);
|
||||
usePacksStore.setState({ fetchEnabledPacks } as never);
|
||||
useStreakStore.setState({ refreshStreak } as never);
|
||||
return { fetchUser, fetchCouple, fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions, fetchEnabledPacks, refreshStreak };
|
||||
}
|
||||
|
||||
describe('useCoupleSync', () => {
|
||||
let refreshers: ReturnType<typeof stubRefreshers>;
|
||||
const originals = {
|
||||
auth: { fetchUser: useAuthStore.getState().fetchUser, fetchCouple: useAuthStore.getState().fetchCouple },
|
||||
match: {
|
||||
fetchMatches: useMatchStore.getState().fetchMatches,
|
||||
fetchPendingQuestions: useMatchStore.getState().fetchPendingQuestions,
|
||||
fetchTheirTurnQuestions: useMatchStore.getState().fetchTheirTurnQuestions,
|
||||
},
|
||||
packs: { fetchEnabledPacks: usePacksStore.getState().fetchEnabledPacks },
|
||||
streak: { refreshStreak: useStreakStore.getState().refreshStreak },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
mockForeground.value = true;
|
||||
mockListeners.clear();
|
||||
useMessageStore.setState({ unreadCount: 0 });
|
||||
// Every view starts loaded: this hook must refresh only what is on screen,
|
||||
// and the "not loaded" case is asserted separately below.
|
||||
useMatchStore.setState({ loadedAt: { active: 1, pending: 1, their_turn: 1 } });
|
||||
usePacksStore.setState({ enabledPacksLoadedAt: Date.now() });
|
||||
refreshers = stubRefreshers();
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
useAuthStore.setState(originals.auth as never);
|
||||
useMatchStore.setState({ ...originals.match, loadedAt: {} } as never);
|
||||
usePacksStore.setState({ ...originals.packs, enabledPacksLoadedAt: null } as never);
|
||||
useStreakStore.setState(originals.streak as never);
|
||||
});
|
||||
|
||||
const answer = (...summaries: SyncSummary[]) => {
|
||||
const mock = appDataApi.syncSummary as jest.Mock;
|
||||
mock.mockReset();
|
||||
for (const summary of summaries) mock.mockResolvedValueOnce(summary);
|
||||
mock.mockResolvedValue(summaries[summaries.length - 1]);
|
||||
};
|
||||
|
||||
it('spends one request per interval and refreshes nothing while the markers hold still', async () => {
|
||||
answer(baseline);
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
|
||||
|
||||
await tick();
|
||||
await tick();
|
||||
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(3);
|
||||
// Three polls, three requests in total: the baseline poll must not trigger
|
||||
// the refresh of five domains that the old five-second sweep performed.
|
||||
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies the unread total from the summary without a second request', async () => {
|
||||
answer(baseline, { ...baseline, unread_total: 4 });
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(4);
|
||||
});
|
||||
|
||||
it('refreshes only the domain whose marker moved', async () => {
|
||||
answer(baseline, { ...baseline, latest_match_at: '2026-08-28T00:00:00.000Z', match_count: 4 });
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
|
||||
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchUser).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes each remaining marker to its own domain', async () => {
|
||||
answer(baseline, {
|
||||
...baseline,
|
||||
pending_yours: 2,
|
||||
pending_theirs: 3,
|
||||
enabled_packs_fingerprint: 'digest-b',
|
||||
partner_updated_at: '2026-08-29T00:00:00.000Z',
|
||||
streak_updated_at: '2026-08-29T00:00:00.000Z',
|
||||
});
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchPendingQuestions).toHaveBeenCalledWith({ silent: true });
|
||||
expect(refreshers.fetchTheirTurnQuestions).toHaveBeenCalledWith({ silent: true });
|
||||
expect(refreshers.fetchEnabledPacks).toHaveBeenCalledTimes(1);
|
||||
expect(refreshers.refreshStreak).toHaveBeenCalledTimes(1);
|
||||
// A partner profile change is a couple-level read; the whole user is only
|
||||
// re-read when the user's own profile or couple membership moved.
|
||||
expect(refreshers.fetchCouple).toHaveBeenCalledTimes(1);
|
||||
expect(refreshers.fetchUser).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes a loaded Matches view when the match-state fingerprint moves in place', async () => {
|
||||
// Counts and latest-created-at do not move for an in-place match_type/
|
||||
// response_summary edit; only the state fingerprint does.
|
||||
answer(baseline, { ...baseline, match_state_fingerprint: 'state-b' });
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
|
||||
});
|
||||
|
||||
it('refreshes a loaded Matches view when unread redistributes without changing the total', async () => {
|
||||
// One match is read while another receives a message: unread_total holds
|
||||
// steady but the per-match fingerprint (and unread-first ordering) moves.
|
||||
answer(baseline, { ...baseline, unread_total: baseline.unread_total, match_unread_fingerprint: 'unread-b' });
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
|
||||
});
|
||||
|
||||
it('does not refresh enabled packs when that domain has never been opened', async () => {
|
||||
usePacksStore.setState({ enabledPacksLoadedAt: null });
|
||||
answer(baseline, { ...baseline, enabled_packs_fingerprint: 'digest-b' });
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets the baseline when the account identity changes, instead of diffing against the prior account', async () => {
|
||||
answer({ ...baseline, match_count: 3 });
|
||||
const { rerender } = renderHook(
|
||||
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
|
||||
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
|
||||
);
|
||||
await flush();
|
||||
await tick(); // establishes alice's baseline (match_count: 3)
|
||||
|
||||
// Switch accounts: bob's couple has a completely different match_count.
|
||||
// Without a baseline reset this would look like a 12-count jump and
|
||||
// trigger a refresh purely from comparing across accounts.
|
||||
answer({ ...baseline, couple_id: 'couple-b', match_count: 15 });
|
||||
rerender({ userId: 'bob', coupleId: 'couple-b' });
|
||||
await flush();
|
||||
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
|
||||
|
||||
await tick();
|
||||
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-reads the user once when the profile moves, instead of the couple as well', async () => {
|
||||
answer(baseline, {
|
||||
...baseline,
|
||||
profile_updated_at: '2026-08-29T00:00:00.000Z',
|
||||
partner_updated_at: '2026-08-29T00:00:00.000Z',
|
||||
});
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchUser).toHaveBeenCalledTimes(1);
|
||||
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves views the user has never opened unloaded', async () => {
|
||||
useMatchStore.setState({ loadedAt: {} });
|
||||
answer(baseline, {
|
||||
...baseline,
|
||||
match_count: 9,
|
||||
latest_match_at: '2026-08-29T00:00:00.000Z',
|
||||
pending_yours: 7,
|
||||
pending_theirs: 8,
|
||||
});
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops entirely for a signed-out user and while the app is backgrounded', async () => {
|
||||
answer(baseline);
|
||||
const { unmount } = renderHook(() => useCoupleSync(undefined, null));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(appDataApi.syncSummary).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
mockForeground.value = false;
|
||||
for (const listener of [...mockListeners]) listener(false);
|
||||
});
|
||||
await tick();
|
||||
await tick();
|
||||
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('discards a summary that lands after the account it belongs to was switched away', async () => {
|
||||
// Alice's poll is still open when Bob signs in. Its answer describes a
|
||||
// couple Bob is not in, so none of it may land: not his badge, not the
|
||||
// baseline his next poll is diffed against, not a refresh decision.
|
||||
let answerAlice: (summary: SyncSummary) => void = () => undefined;
|
||||
const mock = appDataApi.syncSummary as jest.Mock;
|
||||
mock.mockReset();
|
||||
mock.mockReturnValueOnce(new Promise<SyncSummary>(resolve => { answerAlice = resolve; }));
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
|
||||
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
|
||||
);
|
||||
await flush();
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const bobBaseline: SyncSummary = { ...baseline, couple_id: 'couple-b', unread_total: 1, match_count: 2 };
|
||||
mock.mockResolvedValue(bobBaseline);
|
||||
rerender({ userId: 'bob', coupleId: 'couple-b' });
|
||||
await flush();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||
|
||||
// Alice's request finally answers, long after she stopped being the user.
|
||||
await act(async () => {
|
||||
answerAlice({ ...baseline, couple_id: 'couple-a', unread_total: 9, match_count: 42 });
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flush();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||
|
||||
// Bob's baseline is still Bob's: a poll identical to his own first one has
|
||||
// nothing to refresh. Had Alice's answer been recorded as the baseline,
|
||||
// this would read as a 40-match jump and refresh the Matches view.
|
||||
await tick();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never runs two summaries at once', async () => {
|
||||
let release: (summary: SyncSummary) => void = () => undefined;
|
||||
(appDataApi.syncSummary as jest.Mock).mockReset();
|
||||
(appDataApi.syncSummary as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
(appDataApi.syncSummary as jest.Mock).mockResolvedValue(baseline);
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS * 4); });
|
||||
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { release(baseline); await Promise.resolve(); });
|
||||
await tick();
|
||||
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react-native";
|
||||
import { useDares } from "@/features/dares/hooks/useDares";
|
||||
import { ApiError, apiClient } from "@/lib/apiClient";
|
||||
import type { UseDaresReturn } from "@/features/dares/hooks/useDares";
|
||||
import type { SentDare } from "@/features/dares/types";
|
||||
|
||||
const incoming: SentDare = {
|
||||
@@ -90,3 +91,80 @@ describe("useDares", () => {
|
||||
expect(result.current.error).toBe("offline");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDares polling cost", () => {
|
||||
/** Counts requests per endpoint and lets the active payload be swapped mid-test. */
|
||||
function mockCounting(active: () => SentDare[]) {
|
||||
const calls = { active: 0, history: 0, catalog: 0, stats: 0 };
|
||||
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
|
||||
if (path.startsWith("/v1/dares?filter=active")) { calls.active += 1; return { dares: active() } as never; }
|
||||
if (path.startsWith("/v1/dares?filter=history")) { calls.history += 1; return { dares: [] } as never; }
|
||||
if (path === "/v1/dares/packs") { calls.catalog += 1; return catalog as never; }
|
||||
if (path === "/v1/dares/stats") { calls.stats += 1; return stats as never; }
|
||||
throw new Error(`unexpected path ${path}`);
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
|
||||
const tick = async () => {
|
||||
await flush();
|
||||
await act(async () => { jest.advanceTimersByTime(5_000); });
|
||||
await flush();
|
||||
};
|
||||
|
||||
beforeEach(() => { jest.restoreAllMocks(); jest.useFakeTimers(); });
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it("polls the active list alone and re-reads the static endpoints only when it changes", async () => {
|
||||
let current: SentDare[] = [incoming];
|
||||
const calls = mockCounting(() => current);
|
||||
const { result } = renderHook(() => useDares({ isFocused: true }));
|
||||
await flush();
|
||||
expect(result.current.loading).toBe(false);
|
||||
// The first pass is a full load: nothing is cached yet.
|
||||
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
|
||||
|
||||
await tick();
|
||||
await tick();
|
||||
// Two quiet polls cost one request each, not four. The catalogue and the
|
||||
// stats cannot move while the active set is byte-for-byte identical.
|
||||
expect(calls).toEqual({ active: 3, history: 1, catalog: 1, stats: 1 });
|
||||
|
||||
current = [{ ...incoming, status: "active" }];
|
||||
await tick();
|
||||
expect(calls).toEqual({ active: 4, history: 2, catalog: 2, stats: 2 });
|
||||
expect(result.current.active[0]?.status).toBe("active");
|
||||
|
||||
await tick();
|
||||
expect(calls).toEqual({ active: 5, history: 2, catalog: 2, stats: 2 });
|
||||
});
|
||||
|
||||
it("issues nothing at all while the screen is not on top", async () => {
|
||||
const calls = mockCounting(() => [incoming]);
|
||||
const { rerender } = renderHook<UseDaresReturn, { isFocused: boolean }>(
|
||||
({ isFocused }) => useDares({ isFocused }),
|
||||
{ initialProps: { isFocused: false } },
|
||||
);
|
||||
await tick();
|
||||
await tick();
|
||||
expect(calls).toEqual({ active: 0, history: 0, catalog: 0, stats: 0 });
|
||||
|
||||
rerender({ isFocused: true });
|
||||
await flush();
|
||||
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
|
||||
});
|
||||
|
||||
it("never overlaps two polls", async () => {
|
||||
let release: (payload: { dares: SentDare[] }) => void = () => undefined;
|
||||
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
|
||||
if (path.startsWith("/v1/dares?filter=active")) return new Promise(resolve => { release = resolve; }) as never;
|
||||
return { dares: [], ...catalog, ...stats } as never;
|
||||
});
|
||||
renderHook(() => useDares({ isFocused: true }));
|
||||
await flush();
|
||||
await act(async () => { jest.advanceTimersByTime(20_000); });
|
||||
expect((apiClient.get as jest.Mock).mock.calls.filter(([path]) => String(path).includes("filter=active"))).toHaveLength(1);
|
||||
await act(async () => { release({ dares: [] }); await Promise.resolve(); });
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,60 @@ describe('useDrawingSync API polling', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDrawingSync focus gating', () => {
|
||||
const handlers = {
|
||||
onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(), onStrokeEnd: jest.fn(),
|
||||
onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(),
|
||||
};
|
||||
beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('reads the canvas once on mount and then polls only while the screen is on top', async () => {
|
||||
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
|
||||
const onLoadStrokes = jest.fn();
|
||||
const { rerender } = renderHook<unknown, { isFocused: boolean }>(({ isFocused }) => useDrawingSync({
|
||||
coupleId: 'couple', userId, onLoadStrokes, isFocused, ...handlers,
|
||||
}), { initialProps: { isFocused: false } });
|
||||
|
||||
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
|
||||
// A canvas nobody is looking at costs nothing beyond the state it loaded with.
|
||||
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
|
||||
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ isFocused: true });
|
||||
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
|
||||
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
|
||||
|
||||
rerender({ isFocused: false });
|
||||
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
|
||||
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never polls over its own write in flight', async () => {
|
||||
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
|
||||
let release: (state: unknown) => void = () => undefined;
|
||||
(appDataApi.putLiveDraw as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
|
||||
const onLoadStrokes = jest.fn();
|
||||
const { result } = renderHook(() => useDrawingSync({
|
||||
coupleId: 'couple', userId, onLoadStrokes, isFocused: true, ...handlers,
|
||||
}));
|
||||
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
|
||||
|
||||
const pending = result.current.persistStrokes([stroke]);
|
||||
await act(async () => { jest.advanceTimersByTime(750 * 4); await Promise.resolve(); });
|
||||
// The write returns the authoritative state; reading over the top of it would
|
||||
// only race a revision we are about to replace.
|
||||
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
release({ strokes: [stroke], revision: 2, updated_at: 'two', updated_by: userId });
|
||||
await pending;
|
||||
});
|
||||
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
|
||||
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDrawingSync stroke deduplication', () => {
|
||||
const at = (id: string, points: number, userId = 'u1') => ({
|
||||
id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })),
|
||||
|
||||
@@ -4,85 +4,183 @@ import { chatApi } from '@/lib/chatApi';
|
||||
|
||||
jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } }));
|
||||
|
||||
const message = (id: string, user_id = 'partner') => ({ id, match_id: 'match1', user_id, content: id } as any);
|
||||
const message = (id: string, user_id = 'partner', overrides: Record<string, unknown> = {}) => ({
|
||||
id, match_id: 'match1', user_id, content: id,
|
||||
created_at: '2026-08-27T12:00:00Z', read_at: null, ...overrides,
|
||||
} as any);
|
||||
|
||||
describe('useMessageSubscription API polling', () => {
|
||||
const snapshot = (messages: any[], server_time = 'cursor-1') => ({ messages, removed_ids: [], server_time, complete: true });
|
||||
const delta = (messages: any[], options: { removed?: string[]; server_time?: string; typing?: boolean } = {}) => ({
|
||||
messages,
|
||||
removed_ids: options.removed ?? [],
|
||||
server_time: options.server_time ?? 'cursor-2',
|
||||
complete: false,
|
||||
...(options.typing === undefined ? {} : { typing: { typing: options.typing, expires_at: null } }),
|
||||
});
|
||||
|
||||
const listMessages = chatApi.listMessages as jest.Mock;
|
||||
const markRead = chatApi.markRead as jest.Mock;
|
||||
|
||||
describe('useMessageSubscription incremental polling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [] });
|
||||
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: 'now' });
|
||||
listMessages.mockResolvedValue(snapshot([]));
|
||||
markRead.mockResolvedValue({ updated: 0, read_at: 'now' });
|
||||
});
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('fails closed without both a match and authenticated user', async () => {
|
||||
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
|
||||
const mount = (config: Partial<Parameters<typeof useMessageSubscription>[0]> = {}) =>
|
||||
renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, ...config }));
|
||||
|
||||
it('fails closed without both match and authenticated user', () => {
|
||||
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
|
||||
({ matchId, userId }) => useMessageSubscription({ matchId, userId }),
|
||||
{ initialProps: { matchId: undefined as string | undefined, userId: 'me' as string | undefined } },
|
||||
);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(chatApi.listMessages).not.toHaveBeenCalled();
|
||||
expect(listMessages).not.toHaveBeenCalled();
|
||||
rerender({ matchId: 'match1', userId: undefined });
|
||||
expect(chatApi.listMessages).not.toHaveBeenCalled();
|
||||
expect(listMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads initial messages without treating them as newly arrived', async () => {
|
||||
it('loads a snapshot first, then requests only changes since the server cursor', async () => {
|
||||
listMessages.mockResolvedValueOnce(snapshot([message('m1')], 'cursor-1'));
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(listMessages).toHaveBeenNthCalledWith(1, 'match1', { typing: true });
|
||||
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
||||
expect(listMessages).toHaveBeenNthCalledWith(2, 'match1', { since: 'cursor-1', typing: true });
|
||||
});
|
||||
|
||||
it('keeps cached messages, and their identity, when a delta carries nothing', async () => {
|
||||
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
|
||||
listMessages.mockResolvedValue(delta([]));
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(result.current.messages).toHaveLength(1));
|
||||
const cached = result.current.messages;
|
||||
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.messages).toBe(cached);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('merges delta rows by id, applies self-deletions, and keeps newest first', async () => {
|
||||
listMessages.mockResolvedValueOnce(snapshot([
|
||||
message('m2', 'me', { created_at: '2026-08-27T12:00:02Z' }),
|
||||
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen' }),
|
||||
]));
|
||||
listMessages.mockResolvedValueOnce(delta([
|
||||
message('m3', 'partner', { created_at: '2026-08-27T12:00:03Z', read_at: 'seen' }),
|
||||
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen', deleted_at: 'gone' }),
|
||||
], { removed: ['m2'] }));
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(result.current.messages).toHaveLength(2));
|
||||
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(result.current.messages).toHaveLength(2));
|
||||
expect(result.current.messages.map(m => m.id)).toEqual(['m3', 'm1']);
|
||||
expect(result.current.messages[1]?.deleted_at).toBe('gone');
|
||||
});
|
||||
|
||||
it('marks read only when the response holds an unread partner message', async () => {
|
||||
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner')]));
|
||||
listMessages.mockResolvedValueOnce(delta([message('m2', 'me')]));
|
||||
listMessages.mockResolvedValueOnce(delta([message('m1', 'partner', { read_at: 'seen' })]));
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(markRead).toHaveBeenCalledTimes(1));
|
||||
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(3));
|
||||
expect(markRead).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('issues no request at all while the chat is not focused', async () => {
|
||||
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { focused: boolean }>(
|
||||
({ focused }) => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, isFocused: focused }),
|
||||
{ initialProps: { focused: false } },
|
||||
);
|
||||
await act(async () => { jest.advanceTimersByTime(2_000); });
|
||||
expect(listMessages).not.toHaveBeenCalled();
|
||||
expect(markRead).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ focused: true });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender({ focused: false });
|
||||
const callsAtBlur = listMessages.mock.calls.length;
|
||||
await act(async () => { jest.advanceTimersByTime(2_000); });
|
||||
expect(listMessages).toHaveBeenCalledTimes(callsAtBlur);
|
||||
});
|
||||
|
||||
it('reports partner arrivals once, and never for the initial load or own messages', async () => {
|
||||
const onNewMessage = jest.fn();
|
||||
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [message('m1')] });
|
||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage }));
|
||||
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
|
||||
listMessages.mockResolvedValueOnce(delta([message('mine', 'me')]));
|
||||
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner')]));
|
||||
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner', { read_at: 'seen' })]));
|
||||
const { result } = mount({ onNewMessage });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.messages).toEqual([message('m1')]);
|
||||
expect(onNewMessage).not.toHaveBeenCalled();
|
||||
expect(chatApi.markRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes on the polling interval, marks focused messages read, and reports new partner messages', async () => {
|
||||
const onNewMessage = jest.fn();
|
||||
(chatApi.listMessages as jest.Mock)
|
||||
.mockResolvedValueOnce({ messages: [message('m1')] })
|
||||
.mockResolvedValueOnce({ messages: [message('m2'), message('m1')] });
|
||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => { result.current.isFocusedRef.current = true; });
|
||||
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
|
||||
await waitFor(() => expect(result.current.messages[0]?.id).toBe('m2'));
|
||||
expect(chatApi.markRead).toHaveBeenCalledWith('match1');
|
||||
for (const expected of [2, 3, 4]) {
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(expected));
|
||||
}
|
||||
expect(onNewMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not notify for the current user own newly polled message', async () => {
|
||||
const onNewMessage = jest.fn();
|
||||
(chatApi.listMessages as jest.Mock)
|
||||
.mockResolvedValueOnce({ messages: [] })
|
||||
.mockResolvedValueOnce({ messages: [message('mine', 'me')] });
|
||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
|
||||
expect(onNewMessage).not.toHaveBeenCalled();
|
||||
it('reads partner typing state out of the message response', async () => {
|
||||
listMessages.mockResolvedValueOnce({ ...snapshot([]), typing: { typing: true, expires_at: 'later' } });
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(result.current.partnerTyping).toBe(true));
|
||||
|
||||
listMessages.mockResolvedValueOnce(delta([], { typing: false }));
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(result.current.partnerTyping).toBe(false));
|
||||
});
|
||||
|
||||
it('surfaces loading completion and preserves a usable empty state after an API error', async () => {
|
||||
(chatApi.listMessages as jest.Mock).mockRejectedValueOnce(new Error('forbidden'));
|
||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
|
||||
listMessages.mockRejectedValueOnce(new Error('forbidden'));
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('prevents overlapping polls and clears the interval on unmount', async () => {
|
||||
let resolveFirst!: (value: { messages: any[] }) => void;
|
||||
(chatApi.listMessages as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
|
||||
const { unmount } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500 }));
|
||||
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
|
||||
act(() => { jest.advanceTimersByTime(1_500); });
|
||||
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
|
||||
await act(async () => resolveFirst({ messages: [] }));
|
||||
it('prevents overlapping polls and stops on unmount', async () => {
|
||||
let resolveFirst!: (value: unknown) => void;
|
||||
listMessages.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
|
||||
const { unmount } = mount();
|
||||
expect(listMessages).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { jest.advanceTimersByTime(1_500); });
|
||||
expect(listMessages).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => { resolveFirst(snapshot([])); });
|
||||
unmount();
|
||||
act(() => { jest.advanceTimersByTime(2_000); });
|
||||
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { jest.advanceTimersByTime(2_000); });
|
||||
expect(listMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restarts immediately, and without a stale cursor, for a different match', async () => {
|
||||
listMessages.mockResolvedValue(snapshot([message('m1')], 'cursor-1'));
|
||||
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string }>(
|
||||
({ matchId }) => useMessageSubscription({ matchId, userId: 'me', pollInterval: 500 }),
|
||||
{ initialProps: { matchId: 'match1' } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.messages).toHaveLength(1));
|
||||
|
||||
rerender({ matchId: 'match2' });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
||||
expect(listMessages).toHaveBeenNthCalledWith(2, 'match2', { typing: true });
|
||||
});
|
||||
|
||||
it('supports optimistic external updates', async () => {
|
||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => result.current.setMessages([message('optimistic', 'me')]));
|
||||
expect(result.current.messages[0]?.id).toBe('optimistic');
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
import { usePolling } from '@/hooks/usePolling';
|
||||
|
||||
const mockForeground = { value: true };
|
||||
const mockListeners = new Set<(foreground: boolean) => void>();
|
||||
|
||||
jest.mock('@/lib/appForeground', () => ({
|
||||
isForeground: () => mockForeground.value,
|
||||
subscribeToForeground: (listener: (foreground: boolean) => void) => {
|
||||
mockListeners.add(listener);
|
||||
return () => { mockListeners.delete(listener); };
|
||||
},
|
||||
}));
|
||||
|
||||
const setForeground = async (value: boolean) => {
|
||||
await act(async () => {
|
||||
mockForeground.value = value;
|
||||
for (const listener of [...mockListeners]) listener(value);
|
||||
});
|
||||
};
|
||||
// The next run is scheduled once the previous one settles, so let pending
|
||||
// promises land before and after moving the clock.
|
||||
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
|
||||
const tick = async (ms: number) => {
|
||||
await flush();
|
||||
await act(async () => { jest.advanceTimersByTime(ms); });
|
||||
await flush();
|
||||
};
|
||||
|
||||
describe('usePolling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
mockForeground.value = true;
|
||||
mockListeners.clear();
|
||||
});
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('runs once immediately and then on the interval', async () => {
|
||||
const task = jest.fn(async () => undefined);
|
||||
renderHook(() => usePolling(task, { intervalMs: 100 }));
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('skips the leading run when asked to', async () => {
|
||||
const task = jest.fn(async () => undefined);
|
||||
renderHook(() => usePolling(task, { intervalMs: 100, leading: false }));
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('issues nothing while disabled and starts once enabled', async () => {
|
||||
const task = jest.fn(async () => undefined);
|
||||
const { rerender } = renderHook<void, { enabled: boolean }>(({ enabled }) => usePolling(task, { intervalMs: 100, enabled }), {
|
||||
initialProps: { enabled: false },
|
||||
});
|
||||
await tick(1_000);
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
rerender({ enabled: true });
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops while the app is backgrounded and catches up on return', async () => {
|
||||
const task = jest.fn(async () => undefined);
|
||||
renderHook(() => usePolling(task, { intervalMs: 100 }));
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
await setForeground(false);
|
||||
await tick(1_000);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
await setForeground(true);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('never runs two tasks concurrently', async () => {
|
||||
let release!: () => void;
|
||||
const task = jest.fn(() => new Promise<void>(resolve => { release = resolve; }));
|
||||
renderHook(() => usePolling(task, { intervalMs: 100 }));
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
await tick(500);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => { release(); });
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('backs off after consecutive failures and recovers after a success', async () => {
|
||||
const task = jest.fn(async (): Promise<void> => { throw new Error('offline'); });
|
||||
renderHook(() => usePolling(task, { intervalMs: 100, maxIntervalMs: 400 }));
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
|
||||
await tick(399);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
await tick(1);
|
||||
expect(task).toHaveBeenCalledTimes(3);
|
||||
|
||||
task.mockImplementation(async () => undefined);
|
||||
await tick(400);
|
||||
expect(task).toHaveBeenCalledTimes(4);
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('restarts immediately when the reset key changes', async () => {
|
||||
const task = jest.fn(async () => undefined);
|
||||
const { rerender } = renderHook<void, { resetKey: string }>(({ resetKey }) => usePolling(task, { intervalMs: 100, resetKey }), {
|
||||
initialProps: { resetKey: 'a' },
|
||||
});
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
rerender({ resetKey: 'b' });
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
await tick(50);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops and unsubscribes on unmount', async () => {
|
||||
const task = jest.fn(async () => undefined);
|
||||
const { unmount } = renderHook(() => usePolling(task, { intervalMs: 100 }));
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
unmount();
|
||||
expect(mockListeners.size).toBe(0);
|
||||
await tick(1_000);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -76,4 +76,22 @@ describe('useTypingIndicator API polling', () => {
|
||||
act(() => { jest.advanceTimersByTime(2_000); });
|
||||
expect(chatApi.getTyping).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
it('stops polling and mirrors typing state supplied by the message poll', async () => {
|
||||
const { result, rerender } = renderHook<ReturnType<typeof useTypingIndicator>, { externalTyping: boolean | undefined }>(
|
||||
({ externalTyping }) => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', pollInterval: 500, externalTyping }),
|
||||
{ initialProps: { externalTyping: undefined as boolean | undefined } },
|
||||
);
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
const callsBeforeFolding = (chatApi.getTyping as jest.Mock).mock.calls.length;
|
||||
|
||||
rerender({ externalTyping: true });
|
||||
expect(result.current.partnerTyping).toBe(true);
|
||||
await act(async () => { jest.advanceTimersByTime(5_000); });
|
||||
expect(chatApi.getTyping).toHaveBeenCalledTimes(callsBeforeFolding);
|
||||
|
||||
act(() => result.current.clearTypingIndicator());
|
||||
expect(result.current.partnerTyping).toBe(false);
|
||||
rerender({ externalTyping: false });
|
||||
expect(result.current.partnerTyping).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AppState } from 'react-native';
|
||||
import { isForeground, isForegroundState, subscribeToForeground } from '@/lib/appForeground';
|
||||
|
||||
describe('appForeground', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('treats only background and inactive as away', () => {
|
||||
expect(isForegroundState('active')).toBe(true);
|
||||
expect(isForegroundState('background')).toBe(false);
|
||||
expect(isForegroundState('inactive')).toBe(false);
|
||||
// Unknown states — including whatever a test harness leaves behind — must
|
||||
// fail towards running, never towards disabling recurring work forever.
|
||||
expect(isForegroundState(undefined)).toBe(true);
|
||||
expect(isForegroundState('extension')).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the current state and reports transitions until unsubscribed', () => {
|
||||
let emit: (state: string) => void = () => undefined;
|
||||
const remove = jest.fn();
|
||||
jest.spyOn(AppState, 'addEventListener').mockImplementation(((_: string, handler: (state: string) => void) => {
|
||||
emit = handler;
|
||||
return { remove } as never;
|
||||
}) as never);
|
||||
|
||||
expect(isForeground()).toBe(isForegroundState(AppState.currentState));
|
||||
|
||||
const seen: boolean[] = [];
|
||||
const unsubscribe = subscribeToForeground(value => seen.push(value));
|
||||
emit('background');
|
||||
emit('active');
|
||||
expect(seen).toEqual([false, true]);
|
||||
|
||||
unsubscribe();
|
||||
expect(remove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,18 @@ describe('chatApi', () => {
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/typing');
|
||||
});
|
||||
|
||||
it('encodes the delta cursor and folded typing request into the message query', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ messages: [], removed_ids: [], server_time: 'now', complete: false });
|
||||
|
||||
await chatApi.listMessages('match1', { typing: true });
|
||||
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000+01:00' });
|
||||
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000Z', typing: true });
|
||||
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/matches/match1/messages?typing=true');
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000%2B01%3A00');
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(3, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000Z&typing=true');
|
||||
});
|
||||
|
||||
it('uses authenticated unread, receipt, deletion and reporting routes', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ total: 0, by_match: {} });
|
||||
(apiClient.post as jest.Mock).mockResolvedValue({});
|
||||
|
||||
@@ -45,9 +45,18 @@ describe('matchStore API behavior', () => {
|
||||
});
|
||||
|
||||
it('guards concurrent loads and exhausted pagination', async () => {
|
||||
useMatchStore.setState({ isLoading: true });
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
useMatchStore.setState({ isLoading: false, hasMore: false });
|
||||
// Overlap protection cannot key off the loading flags any more: a silent
|
||||
// refresh raises none of them and still must not issue a second request.
|
||||
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const inFlight = useMatchStore.getState().fetchMatches(true);
|
||||
await useMatchStore.getState().fetchMatches(true, { silent: true });
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
release({ matches: [], totalCount: 0 });
|
||||
await inFlight;
|
||||
|
||||
(apiClient.get as jest.Mock).mockClear();
|
||||
useMatchStore.setState({ hasMore: false });
|
||||
await useMatchStore.getState().fetchMatches(false);
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -147,7 +156,10 @@ describe('matchStore API behavior', () => {
|
||||
it('toggles archived visibility and triggers an initial fetch only', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 });
|
||||
useMatchStore.getState().toggleShowArchived();
|
||||
await Promise.resolve();
|
||||
// Let the floating fetch settle: its in-flight guard is module-scoped and
|
||||
// is only released in a finally, so a half-run request would leak into
|
||||
// the next test and silently suppress its archived fetch.
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
expect(useMatchStore.getState().showArchived).toBe(true);
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
useMatchStore.getState().toggleShowArchived();
|
||||
@@ -178,11 +190,19 @@ describe('matchStore API behavior', () => {
|
||||
});
|
||||
|
||||
it('updates current view and delegates an empty view fetch', () => {
|
||||
const real = useMatchStore.getState().fetchArchivedMatches;
|
||||
const fetchArchivedMatches = jest.fn(async () => undefined);
|
||||
useMatchStore.setState({ fetchArchivedMatches });
|
||||
useMatchStore.getState().setCurrentView('archived');
|
||||
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
|
||||
expect(fetchArchivedMatches).toHaveBeenCalled();
|
||||
try {
|
||||
useMatchStore.getState().setCurrentView('archived');
|
||||
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
|
||||
expect(fetchArchivedMatches).toHaveBeenCalled();
|
||||
} finally {
|
||||
// Actions live in the same store as the data, and clearMatches does not
|
||||
// reset them: leaving the stub in place would silence every later test's
|
||||
// archived fetch.
|
||||
useMatchStore.setState({ fetchArchivedMatches: real });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,6 +245,222 @@ describe('matchStore API behavior', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('view switching and cache freshness', () => {
|
||||
/** Answers each of the four view endpoints with an empty payload. */
|
||||
const emptyViews = () => (apiClient.get as jest.Mock).mockImplementation(async (path: string) => {
|
||||
if (path.startsWith('/v1/matches?archived=true')) return { matches: [], totalCount: 0 };
|
||||
if (path.startsWith('/v1/matches')) return { matches: [], totalCount: 0, hasMore: false };
|
||||
return { questions: [] };
|
||||
});
|
||||
|
||||
it('switches between loaded views without a single extra request, even when every view is empty', async () => {
|
||||
emptyViews();
|
||||
const views = ['pending', 'their_turn', 'active', 'archived'] as const;
|
||||
|
||||
// First visit to each view loads it once.
|
||||
for (const view of views) {
|
||||
useMatchStore.getState().setCurrentView(view);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
}
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(views.length);
|
||||
expect(useMatchStore.getState()).toMatchObject({
|
||||
matches: [], pendingQuestions: [], theirTurnQuestions: [], archivedMatches: [],
|
||||
});
|
||||
|
||||
// Every subsequent switch is served from cache. An empty view is loaded,
|
||||
// not unloaded: this is the bug where `array.length === 0` meant "never
|
||||
// fetched" and made every visit pay for the same empty answer.
|
||||
(apiClient.get as jest.Mock).mockClear();
|
||||
for (const view of [...views, ...views].reverse()) {
|
||||
useMatchStore.getState().setCurrentView(view);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
}
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps cached rows and raises no foreground spinner during a silent refresh', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
|
||||
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const refreshing = useMatchStore.getState().fetchMatches(true, { silent: true });
|
||||
|
||||
// Mid-flight: the user still sees the rows they had, and neither the
|
||||
// full-screen loader nor the pull-to-refresh spinner is showing.
|
||||
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: false });
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
|
||||
|
||||
release({ matches: [match('fresh', false)], totalCount: 1, hasMore: false });
|
||||
await refreshing;
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
|
||||
});
|
||||
|
||||
it('shows the pull-to-refresh spinner for a user-initiated refresh but never the initial loader', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const refreshing = useMatchStore.getState().fetchMatches(true);
|
||||
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: true });
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
|
||||
release({ matches: [], totalCount: 0, hasMore: false });
|
||||
await refreshing;
|
||||
expect(useMatchStore.getState()).toMatchObject({ isRefreshing: false });
|
||||
});
|
||||
|
||||
it('keeps a silent list refresh out of the pending and their-turn loaders', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ questions: [pending('p1')] });
|
||||
await useMatchStore.getState().fetchPendingQuestions();
|
||||
await useMatchStore.getState().fetchTheirTurnQuestions();
|
||||
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
|
||||
const quiet = Promise.all([
|
||||
useMatchStore.getState().fetchPendingQuestions({ silent: true }),
|
||||
useMatchStore.getState().fetchTheirTurnQuestions({ silent: true }),
|
||||
]);
|
||||
expect(useMatchStore.getState()).toMatchObject({ isLoadingPending: false, isLoadingTheirTurn: false });
|
||||
expect(useMatchStore.getState().pendingQuestions).toHaveLength(1);
|
||||
release({ questions: [] });
|
||||
await quiet;
|
||||
});
|
||||
});
|
||||
|
||||
describe('account isolation (generation tokens)', () => {
|
||||
it('does not let a stale fetchMatches response populate the store after sign-out, and lets the next account start immediately', async () => {
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleFetch = useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
// Simulates sign-out/account switch while the request is still in flight.
|
||||
useMatchStore.getState().clearMatches();
|
||||
release({ matches: [match('stale')], totalCount: 1, hasMore: false });
|
||||
await staleFetch;
|
||||
|
||||
expect(useMatchStore.getState().matches).toEqual([]);
|
||||
|
||||
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('fresh')], totalCount: 1, hasMore: false });
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
|
||||
});
|
||||
|
||||
it('does not let a stale seen-marker write over the next account\'s list', async () => {
|
||||
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
|
||||
let release: () => void = () => undefined;
|
||||
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
|
||||
const stale = useMatchStore.getState().markAsSeen('theirs');
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
useMatchStore.setState({ matches: [match('mine')], newMatchesCount: 1 });
|
||||
const untouched = useMatchStore.getState().matches;
|
||||
release();
|
||||
await stale;
|
||||
|
||||
// Not merely "the same ids": the previous account's response must not
|
||||
// rewrite the array at all, because the recount it derives is only true
|
||||
// of the rows that account had loaded.
|
||||
expect(useMatchStore.getState().matches).toBe(untouched);
|
||||
expect(useMatchStore.getState().newMatchesCount).toBe(1);
|
||||
});
|
||||
|
||||
it('does not let a stale mark-all-seen clear the next account\'s new badges', async () => {
|
||||
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
|
||||
let release: () => void = () => undefined;
|
||||
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
|
||||
const stale = useMatchStore.getState().markAllAsSeen();
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
useMatchStore.setState({ matches: [match('mine', true), match('mine2', true)], newMatchesCount: 2 });
|
||||
release();
|
||||
await stale;
|
||||
|
||||
expect(useMatchStore.getState().matches.every(item => item.is_new)).toBe(true);
|
||||
expect(useMatchStore.getState().newMatchesCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not roll a failed archive or unarchive back into the next account', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
let fail: (error: Error) => void = () => undefined;
|
||||
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
|
||||
useMatchStore.setState({ matches: [match('theirs')], archivedMatches: [], archivedMatchIds: new Set<string>(), totalCount: 1 });
|
||||
const staleArchive = useMatchStore.getState().archiveMatch('theirs');
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
useMatchStore.setState({ matches: [match('mine')], totalCount: 1 });
|
||||
fail(new Error('forbidden'));
|
||||
await staleArchive;
|
||||
|
||||
// The revert would otherwise put one couple's match into another's list.
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['mine']);
|
||||
expect(useMatchStore.getState().totalCount).toBe(1);
|
||||
|
||||
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
|
||||
useMatchStore.setState({ archivedMatches: [match('theirsArchived')], archivedMatchIds: new Set(['theirsArchived']) });
|
||||
const staleUnarchive = useMatchStore.getState().unarchiveMatch('theirsArchived');
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
useMatchStore.setState({ archivedMatches: [match('mineArchived')], archivedMatchIds: new Set(['mineArchived']), totalCount: 0 });
|
||||
fail(new Error('forbidden'));
|
||||
await staleUnarchive;
|
||||
|
||||
expect(useMatchStore.getState().archivedMatches.map(item => item.id)).toEqual(['mineArchived']);
|
||||
expect(useMatchStore.getState().totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does not carry a nudge cooldown across an account switch', async () => {
|
||||
let release: (data: unknown) => void = () => undefined;
|
||||
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleSend = useMatchStore.getState().sendNudge();
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
release({ success: true, notification_sent: true, next_nudge_available_at: '2026-08-28T00:00:00Z' });
|
||||
await expect(staleSend).resolves.toEqual({ success: false, notificationSent: false });
|
||||
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
|
||||
|
||||
// The 429 branch writes a cooldown too, and must be gated the same way.
|
||||
let fail: (error: Error) => void = () => undefined;
|
||||
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
|
||||
const staleLimit = useMatchStore.getState().sendNudge();
|
||||
useMatchStore.getState().clearMatches();
|
||||
fail(new ApiError('rate limited', 429, { cooldown_remaining_seconds: 60 }));
|
||||
await staleLimit;
|
||||
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
|
||||
});
|
||||
|
||||
it('does not let a stale cooldown read clear the next account\'s cooldown', async () => {
|
||||
let release: (profile: unknown) => void = () => undefined;
|
||||
(appDataApi.nudgeStatus as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const stale = useMatchStore.getState().checkNudgeCooldown();
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
const mine = new Date('2026-08-27T18:00:00Z');
|
||||
useMatchStore.setState({ nudgeCooldownUntil: mine });
|
||||
// The previous account not being in cooldown says nothing about this one.
|
||||
release({ last_nudge_sent_at: null });
|
||||
await stale;
|
||||
expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(mine);
|
||||
});
|
||||
|
||||
it('does not let a stale fetchPendingQuestions response populate the store after sign-out, and lets the next account start immediately', async () => {
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleFetch = useMatchStore.getState().fetchPendingQuestions();
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
release({ questions: [pending('stale')] });
|
||||
await staleFetch;
|
||||
|
||||
expect(useMatchStore.getState().pendingQuestions).toEqual([]);
|
||||
|
||||
(apiClient.get as jest.Mock).mockResolvedValueOnce({ questions: [pending('fresh')] });
|
||||
await useMatchStore.getState().fetchPendingQuestions();
|
||||
expect(useMatchStore.getState().pendingQuestions.map(item => item.id)).toEqual(['fresh']);
|
||||
});
|
||||
});
|
||||
|
||||
it('clearMatches resets all user-scoped state', () => {
|
||||
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')], pendingQuestions: [pending('p')], theirTurnQuestions: [pending('t')], currentView: 'archived', showArchived: true, isNudging: true });
|
||||
useMatchStore.getState().clearMatches();
|
||||
|
||||
@@ -47,15 +47,25 @@ describe('messageStore API state', () => {
|
||||
expect(useMessageStore.getState()).toMatchObject({ unreadCount: 1, lastMessage: { id: 'new' } });
|
||||
});
|
||||
|
||||
it('marks partner messages read then reconciles global and per-match counts', async () => {
|
||||
it('marks partner messages read and subtracts the cleared count without re-reading the total', async () => {
|
||||
useMessageStore.setState({ unreadCount: 3 });
|
||||
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 2, read_at: '2026-08-27T00:00:00.000Z' });
|
||||
(chatApi.unread as jest.Mock).mockResolvedValue({ total: 1, by_match: { m2: 1 } });
|
||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||
expect(chatApi.markRead).toHaveBeenCalledWith('m1');
|
||||
expect(chatApi.unread).not.toHaveBeenCalled();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('spends no request and touches no count when the read API cleared nothing', async () => {
|
||||
useMessageStore.setState({ unreadCount: 3 });
|
||||
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: null });
|
||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||
expect(chatApi.unread).not.toHaveBeenCalled();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(3);
|
||||
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
|
||||
});
|
||||
|
||||
it('does not mark read without an authenticated user', async () => {
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||
@@ -69,6 +79,42 @@ describe('messageStore API state', () => {
|
||||
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
|
||||
});
|
||||
|
||||
describe('account isolation (generation tokens)', () => {
|
||||
it('does not let a stale unread total become the next account\'s badge', async () => {
|
||||
let release: (value: unknown) => void = () => undefined;
|
||||
(chatApi.unread as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleFetch = useMessageStore.getState().fetchUnreadCount();
|
||||
|
||||
// Sign-out or leaving a couple, while the request is still open.
|
||||
useMessageStore.getState().clearMessages();
|
||||
useMessageStore.setState({ unreadCount: 2 });
|
||||
|
||||
release({ total: 9, by_match: { m1: 9 } });
|
||||
await staleFetch;
|
||||
|
||||
expect(useMessageStore.getState().unreadCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not subtract a stale read receipt from the next account\'s badge', async () => {
|
||||
useMessageStore.setState({ unreadCount: 3 });
|
||||
let release: (value: unknown) => void = () => undefined;
|
||||
(chatApi.markRead as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleRead = useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||
|
||||
useMessageStore.getState().clearMessages();
|
||||
useMessageStore.setState({ unreadCount: 2 });
|
||||
useMatchStore.setState({ matches: [{ id: 'm1', unreadCount: 2 } as any] });
|
||||
|
||||
release({ updated: 3, read_at: '2026-08-27T00:00:00.000Z' });
|
||||
await staleRead;
|
||||
|
||||
// The badge is wrong whatever the match id; reusing 'm1' also makes the
|
||||
// second half of the damage — the per-match count — visible.
|
||||
expect(useMessageStore.getState().unreadCount).toBe(2);
|
||||
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears transient message state', () => {
|
||||
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'last' } as any, activeMatchId: 'm1' });
|
||||
useMessageStore.getState().clearLastMessage();
|
||||
|
||||
@@ -64,4 +64,69 @@ describe('packsStore standalone API integration', () => {
|
||||
expect(getSpy).not.toHaveBeenCalled();
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
||||
});
|
||||
|
||||
describe('account isolation (generation tokens)', () => {
|
||||
it('does not let a stale fetchEnabledPacks response populate the store after clearPacks, and lets the next account start immediately', async () => {
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleFetch = usePacksStore.getState().fetchEnabledPacks();
|
||||
|
||||
// Simulates sign-out/account switch while the request is still in flight.
|
||||
usePacksStore.getState().clearPacks();
|
||||
release({ enabledPackIds: ['stale-pack'] });
|
||||
await staleFetch;
|
||||
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
||||
|
||||
getSpy.mockResolvedValueOnce({ enabledPackIds: ['fresh-pack'] });
|
||||
await usePacksStore.getState().fetchEnabledPacks();
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual(['fresh-pack']);
|
||||
});
|
||||
|
||||
it('does not let a stale fetchPackProgress response populate the store after clearPacks, and lets the next account start immediately', async () => {
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleFetch = usePacksStore.getState().fetchPackProgress();
|
||||
|
||||
usePacksStore.getState().clearPacks();
|
||||
release({ progress: [{ packId: 'stale', totalQuestions: 1, answeredQuestions: 1 }] });
|
||||
await staleFetch;
|
||||
|
||||
expect(usePacksStore.getState().packProgress.size).toBe(0);
|
||||
|
||||
getSpy.mockResolvedValueOnce({ progress: [{ packId: 'fresh', totalQuestions: 2, answeredQuestions: 1 }] });
|
||||
await usePacksStore.getState().fetchPackProgress();
|
||||
expect(usePacksStore.getState().packProgress.get('fresh')).toEqual({ totalQuestions: 2, answeredQuestions: 1 });
|
||||
});
|
||||
|
||||
it('does not let a stale toggle write the previous couple\'s pack selection', async () => {
|
||||
usePacksStore.setState({ enabledPackIds: [] } as any);
|
||||
let release: (value: unknown) => void = () => undefined;
|
||||
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleToggle = usePacksStore.getState().togglePack('pack1');
|
||||
|
||||
usePacksStore.getState().clearPacks();
|
||||
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
|
||||
|
||||
release({ enabledPackIds: ['pack1'] });
|
||||
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
|
||||
});
|
||||
|
||||
it('does not roll a failed toggle back into the next couple\'s selection', async () => {
|
||||
usePacksStore.setState({ enabledPackIds: ['pack1'] } as any);
|
||||
let reject: (reason: unknown) => void = () => undefined;
|
||||
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail; }));
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const staleToggle = usePacksStore.getState().togglePack('pack1');
|
||||
|
||||
usePacksStore.getState().clearPacks();
|
||||
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
|
||||
|
||||
reject(new Error('offline'));
|
||||
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
|
||||
// The rollback would have re-added pack1, which this couple never enabled.
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -265,6 +265,53 @@ describe('responsesStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('account isolation (generation tokens)', () => {
|
||||
it('does not let a stale fetchResponses response populate the store after clearResponses, and lets the next account start immediately', async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||
const staleFetch = useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
// Simulates sign-out/account switch while the request is still in flight.
|
||||
useResponsesStore.getState().clearResponses();
|
||||
resolve({ responses: [response({ id: 'stale' })], totalCount: 1 });
|
||||
await staleFetch;
|
||||
|
||||
expect(useResponsesStore.getState().responses).toEqual([]);
|
||||
|
||||
apiGet.mockResolvedValueOnce({ responses: [response({ id: 'fresh' })], totalCount: 1 });
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
expect(useResponsesStore.getState().responses.map((r) => r.id)).toEqual(['fresh']);
|
||||
});
|
||||
|
||||
it('does not let a stale edit rewrite the next account\'s row or refresh its matches', async () => {
|
||||
// Question ids come from the shared catalogue, so the same id names a real
|
||||
// row in both accounts' lists: a stale edit would land on one of them.
|
||||
const fetchMatches = jest.fn();
|
||||
useMatchStore.setState({ fetchMatches } as any);
|
||||
useResponsesStore.setState({
|
||||
responses: [response({ id: 'alice', answer: 'maybe', has_match: false, match_id: undefined })],
|
||||
});
|
||||
|
||||
let resolve!: (value: unknown) => void;
|
||||
apiPatch.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||
const staleEdit = useResponsesStore.getState().updateResponse('q1', 'yes');
|
||||
|
||||
useResponsesStore.getState().clearResponses();
|
||||
const next = [response({ id: 'bob', answer: 'no', has_match: false, match_id: undefined })];
|
||||
useResponsesStore.setState({ responses: next });
|
||||
|
||||
resolve({ success: true, new_match: { id: 'm2' } });
|
||||
// The caller still hears what the server did for the account that asked.
|
||||
await expect(staleEdit).resolves.toMatchObject({ success: true });
|
||||
|
||||
expect(useResponsesStore.getState().responses).toBe(next);
|
||||
expect(useResponsesStore.getState().responses[0]).toMatchObject({
|
||||
id: 'bob', answer: 'no', has_match: false,
|
||||
});
|
||||
expect(fetchMatches).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state controls', () => {
|
||||
it('sets and toggles grouping options', () => {
|
||||
useResponsesStore.getState().setGroupBy('pack');
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { type CoupleStreak, useStreakStore } from '@/store/streakStore';
|
||||
|
||||
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn() } }));
|
||||
|
||||
const apiGet = apiClient.get as jest.Mock;
|
||||
|
||||
function streak(overrides: Partial<CoupleStreak> = {}): CoupleStreak {
|
||||
return {
|
||||
couple_id: 'couple1', current_streak: 1, longest_streak: 1,
|
||||
last_active_date: '2026-08-27', last_completed_date: '2026-08-27',
|
||||
you_answered_today: true, partner_answered_today: false,
|
||||
partner_name: 'Partner', timezone: 'UTC', streak_celebrated_at: 0,
|
||||
created_at: '2026-08-01T00:00:00.000Z', updated_at: '2026-08-27T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('streakStore account isolation (generation tokens)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useStreakStore.setState({ streak: null, isLoading: false, error: null, loadedAt: null, generation: 0 });
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
||||
});
|
||||
|
||||
it('does not let a stale fetchStreak response populate the store after clearStreak, and lets the next account start immediately', async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||
const staleFetch = useStreakStore.getState().fetchStreak();
|
||||
|
||||
// Simulates sign-out/account switch while the request is still in flight.
|
||||
useStreakStore.getState().clearStreak();
|
||||
resolve({ streak: streak({ couple_id: 'stale-couple' }) });
|
||||
await staleFetch;
|
||||
|
||||
expect(useStreakStore.getState().streak).toBeNull();
|
||||
expect(useStreakStore.getState().loadedAt).toBeNull();
|
||||
|
||||
apiGet.mockResolvedValueOnce({ streak: streak({ couple_id: 'fresh-couple' }) });
|
||||
await useStreakStore.getState().fetchStreak();
|
||||
expect(useStreakStore.getState().streak?.couple_id).toBe('fresh-couple');
|
||||
expect(useStreakStore.getState().loadedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('refreshStreak silently reloads only an already-loaded streak', async () => {
|
||||
await useStreakStore.getState().refreshStreak();
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
|
||||
apiGet.mockResolvedValueOnce({ streak: streak() });
|
||||
await useStreakStore.getState().fetchStreak();
|
||||
expect(useStreakStore.getState().loadedAt).not.toBeNull();
|
||||
|
||||
apiGet.mockResolvedValueOnce({ streak: streak({ current_streak: 2 }) });
|
||||
await useStreakStore.getState().refreshStreak();
|
||||
expect(useStreakStore.getState().streak?.current_streak).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,227 +1,227 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
|
||||
import { useStreakStore } from '../store';
|
||||
import type { CoupleStreak } from '../store';
|
||||
import { colors, spacing, radius, typography, shadows } from '../theme';
|
||||
|
||||
interface StreakDisplayProps {
|
||||
/** Whether to show the "longest streak" indicator */
|
||||
showLongest?: boolean;
|
||||
/** Animation delay for entry animation */
|
||||
delay?: number;
|
||||
/** Compact mode for smaller displays */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The count is deliberately framed as a couple's, not a personal score: a shared streak
|
||||
* is the thing worth keeping. The status line is the working half of that framing - it
|
||||
* says who the day is waiting on - and the copy stays an invitation, because guilt
|
||||
* between partners is the failure mode for this mechanic rather than the goal.
|
||||
*/
|
||||
export function streakStatus(streak: CoupleStreak): { headline: string; status: string } {
|
||||
const named = streak.partner_name?.trim() || null;
|
||||
// The same fallback reads wrong in both positions, so keep a sentence-start form.
|
||||
const subject = named ?? 'Your partner';
|
||||
const object = named ?? 'your partner';
|
||||
const days = streak.current_streak;
|
||||
const both = streak.you_answered_today && streak.partner_answered_today;
|
||||
|
||||
const headline = days > 0
|
||||
? `You and ${object} — ${days} day${days === 1 ? '' : 's'} in a row`
|
||||
: `You and ${object}`;
|
||||
|
||||
if (both) return { headline, status: days > 0 ? "You've both answered today" : 'Answered together today' };
|
||||
if (streak.partner_answered_today) return { headline, status: `${subject} has answered today — your turn` };
|
||||
if (streak.you_answered_today) return { headline, status: `Answered. Waiting on ${object}` };
|
||||
return { headline, status: days > 0 ? 'One question each keeps it going' : 'Answer together to start a streak' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the couple's shared streak. The flame pulses while the streak is alive.
|
||||
*/
|
||||
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
|
||||
const { streak, fetchStreak } = useStreakStore();
|
||||
|
||||
// Fetch streak on mount
|
||||
useEffect(() => {
|
||||
fetchStreak();
|
||||
}, [fetchStreak]);
|
||||
|
||||
// Flame pulse animation
|
||||
const scale = useSharedValue(1);
|
||||
const hasStreak = (streak?.current_streak ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasStreak) {
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
scale.value = 1;
|
||||
}
|
||||
}, [hasStreak, scale]);
|
||||
|
||||
const flameStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
if (!streak) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A dormant couple gets no zero-state card. Showing "0 days" to a pair who simply
|
||||
// have not played yet reads as a scolding for a streak they never started.
|
||||
if (!hasStreak && !streak.you_answered_today && !streak.partner_answered_today) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { headline, status } = streakStatus(streak);
|
||||
const currentStreak = streak.current_streak;
|
||||
const longestStreak = streak.longest_streak;
|
||||
// The partner having moved first is the one state the couple can still act on today.
|
||||
const awaitingYou = streak.partner_answered_today && !streak.you_answered_today;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
|
||||
<View style={[styles.compactContainer, awaitingYou && styles.compactAwaiting]}>
|
||||
<Animated.View style={flameStyle}>
|
||||
<Ionicons
|
||||
name="flame"
|
||||
size={16}
|
||||
color={hasStreak ? colors.warning : colors.textTertiary}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Text style={[styles.compactCount, !hasStreak && styles.inactiveText]}>
|
||||
{currentStreak}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.card, shadows.sm, awaitingYou && styles.cardAwaiting]}>
|
||||
<View style={styles.content}>
|
||||
<Animated.View style={[styles.iconContainer, flameStyle]}>
|
||||
<Ionicons
|
||||
name="flame"
|
||||
size={28}
|
||||
color={hasStreak ? colors.warning : colors.textTertiary}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.headline} numberOfLines={2}>
|
||||
{headline}
|
||||
</Text>
|
||||
<Text style={[styles.status, awaitingYou && styles.statusAwaiting]} numberOfLines={2}>
|
||||
{status}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showLongest && longestStreak > 0 && (
|
||||
<View style={styles.longest}>
|
||||
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
|
||||
<Text style={styles.longestText}>
|
||||
Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.lg,
|
||||
padding: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
cardAwaiting: {
|
||||
borderColor: colors.warning,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.warningLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
},
|
||||
headline: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
status: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
statusAwaiting: {
|
||||
color: colors.warning,
|
||||
},
|
||||
inactiveText: {
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
longest: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
longestText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
// Compact styles
|
||||
compactContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.warningLight,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
compactAwaiting: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.warning,
|
||||
},
|
||||
compactCount: {
|
||||
...typography.caption1,
|
||||
fontWeight: '600',
|
||||
color: colors.warning,
|
||||
},
|
||||
});
|
||||
|
||||
export default StreakDisplay;
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
|
||||
import { useStreakStore } from '../store';
|
||||
import type { CoupleStreak } from '../store';
|
||||
import { colors, spacing, radius, typography, shadows } from '../theme';
|
||||
|
||||
interface StreakDisplayProps {
|
||||
/** Whether to show the "longest streak" indicator */
|
||||
showLongest?: boolean;
|
||||
/** Animation delay for entry animation */
|
||||
delay?: number;
|
||||
/** Compact mode for smaller displays */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The count is deliberately framed as a couple's, not a personal score: a shared streak
|
||||
* is the thing worth keeping. The status line is the working half of that framing - it
|
||||
* says who the day is waiting on - and the copy stays an invitation, because guilt
|
||||
* between partners is the failure mode for this mechanic rather than the goal.
|
||||
*/
|
||||
export function streakStatus(streak: CoupleStreak): { headline: string; status: string } {
|
||||
const named = streak.partner_name?.trim() || null;
|
||||
// The same fallback reads wrong in both positions, so keep a sentence-start form.
|
||||
const subject = named ?? 'Your partner';
|
||||
const object = named ?? 'your partner';
|
||||
const days = streak.current_streak;
|
||||
const both = streak.you_answered_today && streak.partner_answered_today;
|
||||
|
||||
const headline = days > 0
|
||||
? `You and ${object} — ${days} day${days === 1 ? '' : 's'} in a row`
|
||||
: `You and ${object}`;
|
||||
|
||||
if (both) return { headline, status: days > 0 ? "You've both answered today" : 'Answered together today' };
|
||||
if (streak.partner_answered_today) return { headline, status: `${subject} has answered today — your turn` };
|
||||
if (streak.you_answered_today) return { headline, status: `Answered. Waiting on ${object}` };
|
||||
return { headline, status: days > 0 ? 'One question each keeps it going' : 'Answer together to start a streak' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the couple's shared streak. The flame pulses while the streak is alive.
|
||||
*/
|
||||
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
|
||||
const { streak, ensureStreakLoaded } = useStreakStore();
|
||||
|
||||
// Fetch streak on mount
|
||||
useEffect(() => {
|
||||
void ensureStreakLoaded();
|
||||
}, [ensureStreakLoaded]);
|
||||
|
||||
// Flame pulse animation
|
||||
const scale = useSharedValue(1);
|
||||
const hasStreak = (streak?.current_streak ?? 0) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasStreak) {
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
} else {
|
||||
scale.value = 1;
|
||||
}
|
||||
}, [hasStreak, scale]);
|
||||
|
||||
const flameStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
if (!streak) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A dormant couple gets no zero-state card. Showing "0 days" to a pair who simply
|
||||
// have not played yet reads as a scolding for a streak they never started.
|
||||
if (!hasStreak && !streak.you_answered_today && !streak.partner_answered_today) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { headline, status } = streakStatus(streak);
|
||||
const currentStreak = streak.current_streak;
|
||||
const longestStreak = streak.longest_streak;
|
||||
// The partner having moved first is the one state the couple can still act on today.
|
||||
const awaitingYou = streak.partner_answered_today && !streak.you_answered_today;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
|
||||
<View style={[styles.compactContainer, awaitingYou && styles.compactAwaiting]}>
|
||||
<Animated.View style={flameStyle}>
|
||||
<Ionicons
|
||||
name="flame"
|
||||
size={16}
|
||||
color={hasStreak ? colors.warning : colors.textTertiary}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Text style={[styles.compactCount, !hasStreak && styles.inactiveText]}>
|
||||
{currentStreak}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.card, shadows.sm, awaitingYou && styles.cardAwaiting]}>
|
||||
<View style={styles.content}>
|
||||
<Animated.View style={[styles.iconContainer, flameStyle]}>
|
||||
<Ionicons
|
||||
name="flame"
|
||||
size={28}
|
||||
color={hasStreak ? colors.warning : colors.textTertiary}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.headline} numberOfLines={2}>
|
||||
{headline}
|
||||
</Text>
|
||||
<Text style={[styles.status, awaitingYou && styles.statusAwaiting]} numberOfLines={2}>
|
||||
{status}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showLongest && longestStreak > 0 && (
|
||||
<View style={styles.longest}>
|
||||
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
|
||||
<Text style={styles.longestText}>
|
||||
Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.lg,
|
||||
padding: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
cardAwaiting: {
|
||||
borderColor: colors.warning,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.warningLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
},
|
||||
headline: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
status: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
statusAwaiting: {
|
||||
color: colors.warning,
|
||||
},
|
||||
inactiveText: {
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
longest: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
longestText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
// Compact styles
|
||||
compactContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.warningLight,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
compactAwaiting: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.warning,
|
||||
},
|
||||
compactCount: {
|
||||
...typography.caption1,
|
||||
fontWeight: '600',
|
||||
color: colors.warning,
|
||||
},
|
||||
});
|
||||
|
||||
export default StreakDisplay;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||
@@ -60,20 +60,31 @@ export const ChatScreen: React.FC = () => {
|
||||
|
||||
const isFocused = useIsFocused();
|
||||
|
||||
// Typing indicator hook
|
||||
// Each hook needs a value the other produces: the message poll reports typing
|
||||
// state, and an arriving message clears the indicator. The ref breaks the cycle.
|
||||
const clearTypingRef = useRef<() => void>(() => undefined);
|
||||
const handleNewMessage = useCallback(() => clearTypingRef.current(), []);
|
||||
|
||||
// Message subscription hook
|
||||
const { messages, setMessages, partnerTyping: syncedTyping } = useMessageSubscription({
|
||||
matchId,
|
||||
userId: user?.id,
|
||||
onNewMessage: handleNewMessage,
|
||||
isFocused,
|
||||
});
|
||||
|
||||
// Typing indicator hook, reading the state the message poll already fetched
|
||||
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
|
||||
channelName: `chat:${matchId}`,
|
||||
matchId,
|
||||
userId: user?.id,
|
||||
isFocused,
|
||||
externalTyping: syncedTyping,
|
||||
});
|
||||
|
||||
// Message subscription hook
|
||||
const { messages, setMessages, isFocusedRef } = useMessageSubscription({
|
||||
matchId,
|
||||
userId: user?.id,
|
||||
onNewMessage: clearTypingIndicator,
|
||||
});
|
||||
useEffect(() => {
|
||||
clearTypingRef.current = clearTypingIndicator;
|
||||
}, [clearTypingIndicator]);
|
||||
|
||||
|
||||
// Message actions hook for deletion and reporting
|
||||
@@ -174,16 +185,12 @@ export const ChatScreen: React.FC = () => {
|
||||
// Track active chat to prevent notifications for current chat
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
isFocusedRef.current = true;
|
||||
if (matchId) {
|
||||
setActiveMatchId(matchId);
|
||||
markMatchMessagesAsRead(matchId);
|
||||
Events.matchViewed();
|
||||
}
|
||||
return () => {
|
||||
isFocusedRef.current = false;
|
||||
setActiveMatchId(null);
|
||||
};
|
||||
return () => setActiveMatchId(null);
|
||||
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { GradientBackground } from "../../components/ui";
|
||||
import { Paywall } from "../../components/paywall";
|
||||
@@ -20,7 +21,8 @@ const TABS: { key: Tab; label: string }[] = [
|
||||
];
|
||||
|
||||
export function DaresScreen() {
|
||||
const dares = useDares();
|
||||
const isFocused = useIsFocused();
|
||||
const dares = useDares({ isFocused });
|
||||
const [tab, setTab] = useState<Tab>("active");
|
||||
const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { daresApi } from "../../../lib/daresApi";
|
||||
import { ApiError } from "../../../lib/apiClient";
|
||||
import { usePolling } from "../../../hooks/usePolling";
|
||||
import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types";
|
||||
|
||||
/** Matches the chat poll cadence; the API has no realtime channel. */
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
export interface UseDaresConfig {
|
||||
/**
|
||||
* Supplied by the screen rather than read from navigation here, so the hook
|
||||
* stays renderable without a navigator. Polling stops when the screen is not
|
||||
* on top; `usePolling` additionally stops it when the app backgrounds.
|
||||
*/
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
export interface UseDaresReturn {
|
||||
active: SentDare[];
|
||||
history: SentDare[];
|
||||
@@ -25,7 +35,19 @@ export interface UseDaresReturn {
|
||||
cancel: (dareId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDares(): UseDaresReturn {
|
||||
/**
|
||||
* Identity of the active set as the user experiences it. History, stats and the
|
||||
* remaining-send allowance only move when a dare enters or leaves this set or
|
||||
* changes status, so this is the signal that says the slower endpoints are worth
|
||||
* re-reading.
|
||||
*/
|
||||
const activeSignature = (dares: SentDare[]): string =>
|
||||
dares
|
||||
.map(dare => `${dare.id}:${dare.status}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
|
||||
export function useDares({ isFocused = true }: UseDaresConfig = {}): UseDaresReturn {
|
||||
const [active, setActive] = useState<SentDare[]>([]);
|
||||
const [history, setHistory] = useState<SentDare[]>([]);
|
||||
const [catalog, setCatalog] = useState<DareCatalog | null>(null);
|
||||
@@ -36,42 +58,63 @@ export function useDares(): UseDaresReturn {
|
||||
const [paywallReason, setPaywallReason] = useState<string | null>(null);
|
||||
const fetchingRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const signatureRef = useRef<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* `full` reads everything; the polling path reads only the active list and
|
||||
* follows up on the rest when that list actually changed. A failure updates
|
||||
* the UI's error state either way, but is only rethrown for the poll path
|
||||
* (`throwOnError`): `usePolling` needs the rejection to drive its backoff,
|
||||
* while pull-to-refresh and post-mutation callers already surface the error
|
||||
* themselves and must not turn it into an unhandled rejection.
|
||||
*/
|
||||
const load = useCallback(async (full: boolean, throwOnError = false) => {
|
||||
if (fetchingRef.current) return;
|
||||
fetchingRef.current = true;
|
||||
try {
|
||||
const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([
|
||||
daresApi.list("active"),
|
||||
daresApi.list("history"),
|
||||
daresApi.catalog(),
|
||||
daresApi.stats(),
|
||||
]);
|
||||
const activeResult = await daresApi.list("active");
|
||||
if (!mountedRef.current) return;
|
||||
setActive(activeResult.dares);
|
||||
setHistory(historyResult.dares);
|
||||
setCatalog(catalogResult);
|
||||
setStats(statsResult);
|
||||
|
||||
const signature = activeSignature(activeResult.dares);
|
||||
const changed = signature !== signatureRef.current;
|
||||
signatureRef.current = signature;
|
||||
|
||||
if (full || changed) {
|
||||
const [historyResult, catalogResult, statsResult] = await Promise.all([
|
||||
daresApi.list("history"),
|
||||
daresApi.catalog(),
|
||||
daresApi.stats(),
|
||||
]);
|
||||
if (!mountedRef.current) return;
|
||||
setHistory(historyResult.dares);
|
||||
setCatalog(catalogResult);
|
||||
setStats(statsResult);
|
||||
}
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
if (mountedRef.current) {
|
||||
setError(cause instanceof ApiError ? cause.message : "Could not load dares");
|
||||
}
|
||||
if (throwOnError) throw cause;
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void refresh();
|
||||
const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [refresh]);
|
||||
/** Pull-to-refresh and post-mutation reloads always read the slow endpoints too. */
|
||||
const refresh = useCallback(() => load(true), [load]);
|
||||
const poll = useCallback(() => load(false, true), [load]);
|
||||
|
||||
usePolling(poll, { intervalMs: POLL_INTERVAL_MS, enabled: isFocused });
|
||||
|
||||
/** Premium refusals steer to the paywall; everything else surfaces inline. */
|
||||
const handleFailure = useCallback((cause: unknown) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
@@ -35,6 +36,7 @@ export const LiveDrawScreen: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { user } = useAuthStore();
|
||||
const coupleId = coupleIdParam || user?.couple_id;
|
||||
const isFocused = useIsFocused();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]);
|
||||
@@ -95,6 +97,7 @@ export const LiveDrawScreen: React.FC = () => {
|
||||
const sync = useDrawingSync({
|
||||
coupleId: coupleId!,
|
||||
userId: user?.id,
|
||||
isFocused,
|
||||
onStrokeStart: handleStrokeStartFromPartner,
|
||||
onStrokeContinue: handleStrokeContinueFromPartner,
|
||||
onStrokeEnd: handleStrokeEndFromPartner,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ApiError } from '../../../lib/apiClient';
|
||||
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
|
||||
import { usePolling } from '../../../hooks/usePolling';
|
||||
import type { StrokeSegment, StrokePoint } from '../types';
|
||||
import { updateWidget } from '../utils/widgetBridge';
|
||||
|
||||
/** Fast enough for a shared canvas to feel live, and only while one is on screen. */
|
||||
const POLL_INTERVAL_MS = 750;
|
||||
|
||||
interface UseDrawingSyncConfig {
|
||||
coupleId: string;
|
||||
userId: string | undefined;
|
||||
@@ -14,6 +18,12 @@ interface UseDrawingSyncConfig {
|
||||
onUndo: (strokeId: string) => void;
|
||||
onRedo: (stroke: StrokeSegment) => void;
|
||||
onLoadStrokes: (strokes: StrokeSegment[]) => void;
|
||||
/**
|
||||
* Supplied by the screen rather than read from navigation here, so the hook
|
||||
* stays renderable without a navigator. A canvas nobody is looking at polls
|
||||
* nothing; `usePolling` also stops it when the app goes to the background.
|
||||
*/
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
interface UseDrawingSyncReturn {
|
||||
@@ -52,7 +62,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen
|
||||
}
|
||||
|
||||
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
|
||||
const { coupleId, userId, onLoadStrokes } = config;
|
||||
const { coupleId, userId, onLoadStrokes, isFocused = true } = config;
|
||||
const revisionRef = useRef(0);
|
||||
const baselineRef = useRef<StrokeSegment[]>([]);
|
||||
const workingRef = useRef<StrokeSegment[]>([]);
|
||||
@@ -132,19 +142,25 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
|
||||
void appDataApi.getLiveDraw().then(acceptState).catch(error => console.error('Failed to load live drawing:', error));
|
||||
}, [coupleId, acceptState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coupleId || !userId) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
if (pendingWritesRef.current > 0) return;
|
||||
try {
|
||||
const state = await appDataApi.getLiveDraw();
|
||||
if (!cancelled && state.revision > revisionRef.current) acceptState(state);
|
||||
} catch (error) { console.error('Failed to poll live drawing:', error); }
|
||||
};
|
||||
const timer = setInterval(() => void poll(), 750);
|
||||
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
|
||||
}, [coupleId, userId, acceptState]);
|
||||
// Our own writes already return the authoritative state, so reading over the top
|
||||
// of one in flight would only race it back to a revision we are about to replace.
|
||||
const poll = useCallback(async () => {
|
||||
if (pendingWritesRef.current > 0) return;
|
||||
const state = await appDataApi.getLiveDraw();
|
||||
if (state.revision > revisionRef.current) acceptState(state);
|
||||
}, [acceptState]);
|
||||
|
||||
// `leading: false` because the mount effect above already read the initial state;
|
||||
// usePolling adds the overlap guard, backoff and background pause the raw
|
||||
// interval had none of.
|
||||
usePolling(poll, {
|
||||
intervalMs: POLL_INTERVAL_MS,
|
||||
enabled: Boolean(coupleId) && Boolean(userId) && isFocused,
|
||||
leading: false,
|
||||
resetKey: `${coupleId}:${userId ?? ''}`,
|
||||
});
|
||||
|
||||
useEffect(() => () => { if (throttleRef.current) clearTimeout(throttleRef.current); }, []);
|
||||
|
||||
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
|
||||
if (throttleRef.current) clearTimeout(throttleRef.current);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
|
||||
import { useAuthStore, usePacksStore, useStreakStore } from "../../../store";
|
||||
import { useAuthStore, usePacksStore, useResponsesStore, useStreakStore } from "../../../store";
|
||||
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
|
||||
import { ApiError, apiClient } from "../../../lib/apiClient";
|
||||
import { Events } from "../../../lib/analytics";
|
||||
@@ -352,6 +352,12 @@ export const useSwipeScreen = () => {
|
||||
// wrong partner state for the rest of the session. Refreshing it must never
|
||||
// interfere with recording the answer, so it stays off the critical path.
|
||||
void useStreakStore.getState().fetchStreak().catch(() => undefined);
|
||||
|
||||
// This answer changed pack progress and the answer history. Marking their
|
||||
// caches stale costs no request: whichever screen the user opens next
|
||||
// reloads once, and the ones they do not open stay quiet.
|
||||
usePacksStore.getState().invalidatePacks();
|
||||
useResponsesStore.getState().invalidateResponses();
|
||||
} catch (error) {
|
||||
// A 429 means the server rejected this answer outright. Do not advance the
|
||||
// index, or the question is silently skipped and lost for the day.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export { useAmbientOrbAnimation } from './useAmbientOrbAnimation';
|
||||
export { usePolling } from './usePolling';
|
||||
export type { UsePollingOptions } from './usePolling';
|
||||
export { useCoupleSync, SYNC_INTERVAL_MS } from './useCoupleSync';
|
||||
export type { AmbientOrbConfig } from './useAmbientOrbAnimation';
|
||||
export { useFeatureInterest } from './useFeatureInterest';
|
||||
export { useMediaPicker } from './useMediaPicker';
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { appDataApi, type SyncSummary } from '../lib/appDataApi';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useMatchStore } from '../store/matchStore';
|
||||
import { useMessageStore } from '../store/messageStore';
|
||||
import { usePacksStore } from '../store/packsStore';
|
||||
import { useStreakStore } from '../store/streakStore';
|
||||
import { usePolling } from './usePolling';
|
||||
|
||||
/**
|
||||
* Slower than the five-second full refresh it replaces, because the request it
|
||||
* makes is a change summary rather than five payloads: the app reacts to a
|
||||
* partner's action within this window, but pays one small query for it.
|
||||
*/
|
||||
export const SYNC_INTERVAL_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Keeps cached domain data honest without re-fetching it. Each poll reads one
|
||||
* summary of the couple's state and refreshes only the domains whose markers
|
||||
* moved — and only the ones already on screen, so a view nobody has opened is
|
||||
* not loaded in the background.
|
||||
*/
|
||||
export function useCoupleSync(userId: string | undefined, coupleId: string | null | undefined): void {
|
||||
const previousRef = useRef<SyncSummary | null>(null);
|
||||
const identityRef = useRef<string>('');
|
||||
// `sync` is memoized on a stable callback, so `taskRef` inside usePolling
|
||||
// never has to restart the schedule on every render; it reads the current
|
||||
// identity through this ref instead of closing over the arguments from
|
||||
// whichever render created it.
|
||||
const identityValuesRef = useRef({ userId, coupleId });
|
||||
identityValuesRef.current = { userId, coupleId };
|
||||
|
||||
const latestIdentity = useCallback(() => {
|
||||
const { userId, coupleId } = identityValuesRef.current;
|
||||
return `${userId ?? ''}:${coupleId ?? ''}`;
|
||||
}, []);
|
||||
|
||||
const sync = useCallback(async () => {
|
||||
const identity = latestIdentity();
|
||||
if (identity !== identityRef.current) {
|
||||
// A new account's first summary must not be diffed against the prior
|
||||
// account's markers, or every domain would silently "refresh" on login.
|
||||
identityRef.current = identity;
|
||||
previousRef.current = null;
|
||||
}
|
||||
|
||||
const summary = await appDataApi.syncSummary();
|
||||
// The account can change while this request is in flight. The response
|
||||
// describes the identity that asked for it, so once that is no longer the
|
||||
// current one it may not touch anything: not the badge, not the baseline
|
||||
// the next account will diff against, and not a single refresh decision.
|
||||
// Both checks matter — `identityRef` catches a newer sync that has already
|
||||
// started, and `latestIdentity()` catches a switch that has only rendered.
|
||||
if (identityRef.current !== identity || latestIdentity() !== identity) return;
|
||||
|
||||
const previous = previousRef.current;
|
||||
previousRef.current = summary;
|
||||
|
||||
// The badge count comes straight out of the summary, so keeping it current
|
||||
// costs nothing beyond this request.
|
||||
if (summary.unread_total !== previous?.unread_total) {
|
||||
useMessageStore.getState().setUnreadCount(summary.unread_total);
|
||||
}
|
||||
// The first poll only establishes the baseline: every screen loads its own
|
||||
// data on mount, and refreshing it again here would undo the saving.
|
||||
if (!previous) return;
|
||||
|
||||
const auth = useAuthStore.getState();
|
||||
const matchStore = useMatchStore.getState();
|
||||
const refreshes: Promise<unknown>[] = [];
|
||||
|
||||
if (summary.profile_updated_at !== previous.profile_updated_at || summary.couple_id !== previous.couple_id) {
|
||||
// fetchUser also refreshes the couple when one is set.
|
||||
refreshes.push(auth.fetchUser());
|
||||
} else if (summary.partner_id !== previous.partner_id || summary.partner_updated_at !== previous.partner_updated_at) {
|
||||
refreshes.push(auth.fetchCouple());
|
||||
}
|
||||
|
||||
if (matchStore.loadedAt.active !== undefined && (
|
||||
summary.match_count !== previous.match_count ||
|
||||
summary.new_match_count !== previous.new_match_count ||
|
||||
summary.latest_match_at !== previous.latest_match_at ||
|
||||
// Neither count moves when a partner edits an existing match's type or
|
||||
// response summary through response editing.
|
||||
summary.match_state_fingerprint !== previous.match_state_fingerprint ||
|
||||
// `unread_total` can hold steady while unread redistributes between
|
||||
// matches (one read, another receives a message), which reorders the
|
||||
// unread-first list without moving the total.
|
||||
summary.match_unread_fingerprint !== previous.match_unread_fingerprint)) {
|
||||
refreshes.push(matchStore.fetchMatches(true, { silent: true }));
|
||||
}
|
||||
if (matchStore.loadedAt.pending !== undefined && summary.pending_yours !== previous.pending_yours) {
|
||||
refreshes.push(matchStore.fetchPendingQuestions({ silent: true }));
|
||||
}
|
||||
if (matchStore.loadedAt.their_turn !== undefined && summary.pending_theirs !== previous.pending_theirs) {
|
||||
refreshes.push(matchStore.fetchTheirTurnQuestions({ silent: true }));
|
||||
}
|
||||
// Only refresh a domain the user has actually opened, matching the rule
|
||||
// that unopened domains stay unloaded.
|
||||
if (usePacksStore.getState().enabledPacksLoadedAt !== null
|
||||
&& summary.enabled_packs_fingerprint !== previous.enabled_packs_fingerprint) {
|
||||
refreshes.push(usePacksStore.getState().fetchEnabledPacks());
|
||||
}
|
||||
if (summary.streak_updated_at !== previous.streak_updated_at) {
|
||||
refreshes.push(useStreakStore.getState().refreshStreak());
|
||||
}
|
||||
|
||||
await Promise.allSettled(refreshes);
|
||||
}, [latestIdentity]);
|
||||
|
||||
usePolling(sync, {
|
||||
intervalMs: SYNC_INTERVAL_MS,
|
||||
enabled: Boolean(userId),
|
||||
resetKey: `${userId ?? ''}:${coupleId ?? ''}`,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { chatApi } from '../lib/chatApi';
|
||||
import { chatApi, type MessagesPage } from '../lib/chatApi';
|
||||
import { usePolling } from './usePolling';
|
||||
import type { Message } from '../features/chat/types';
|
||||
|
||||
export interface UseMessageSubscriptionConfig {
|
||||
@@ -7,51 +8,115 @@ export interface UseMessageSubscriptionConfig {
|
||||
userId: string | undefined;
|
||||
onNewMessage?: () => void;
|
||||
pollInterval?: number;
|
||||
/** Whether the chat is on screen. Polling and read receipts stop when it is not. */
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
export interface UseMessageSubscriptionReturn {
|
||||
messages: Message[];
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||
loading: boolean;
|
||||
isFocusedRef: React.MutableRefObject<boolean>;
|
||||
/** Partner typing state folded into the message poll; undefined until the server reports it. */
|
||||
partnerTyping: boolean | undefined;
|
||||
/** Fetches the next delta now, e.g. straight after sending. */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Authenticated API polling replaces Supabase Realtime for self-hosted chat. */
|
||||
export const useMessageSubscription = ({ matchId, userId, onNewMessage, pollInterval = 1_500 }: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
|
||||
const timeOf = (message: Message): number => {
|
||||
const parsed = message.created_at ? Date.parse(message.created_at) : Number.NaN;
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
/** Newest first, matching the order the API returns a snapshot in. */
|
||||
const byNewest = (a: Message, b: Message): number => {
|
||||
const delta = timeOf(b) - timeOf(a);
|
||||
if (delta !== 0) return delta;
|
||||
return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the previous array unchanged when a delta carries nothing, so an idle
|
||||
* poll re-renders neither this hook's consumers nor the message list.
|
||||
*/
|
||||
export const mergeMessagePage = (previous: Message[], page: MessagesPage): Message[] => {
|
||||
if (page.complete !== false) return page.messages;
|
||||
const removed = page.removed_ids ?? [];
|
||||
if (page.messages.length === 0 && removed.length === 0) return previous;
|
||||
|
||||
const byId = new Map(previous.map(message => [message.id, message]));
|
||||
for (const message of page.messages) byId.set(message.id, message);
|
||||
for (const id of removed) byId.delete(id);
|
||||
return [...byId.values()].sort(byNewest);
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticated API polling replaces Supabase Realtime for self-hosted chat.
|
||||
*
|
||||
* Each poll asks only for what changed since the cursor the server issued last
|
||||
* time, so an idle conversation costs an all-but-empty response instead of the
|
||||
* five hundred messages the screen already has. Read receipts are written only
|
||||
* when the response actually contains an unread message from the partner, which
|
||||
* on an idle chat is never.
|
||||
*/
|
||||
export const useMessageSubscription = ({
|
||||
matchId,
|
||||
userId,
|
||||
onNewMessage,
|
||||
pollInterval = 2_000,
|
||||
isFocused = true,
|
||||
}: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const isFocusedRef = useRef(false);
|
||||
const [loading, setLoading] = useState(Boolean(matchId && userId));
|
||||
const [partnerTyping, setPartnerTyping] = useState<boolean | undefined>(undefined);
|
||||
const sinceRef = useRef<string | null>(null);
|
||||
const knownIdsRef = useRef<Set<string>>(new Set());
|
||||
const fetchingRef = useRef(false);
|
||||
const loadingRef = useRef(true);
|
||||
const notifyRef = useRef(onNewMessage);
|
||||
notifyRef.current = onNewMessage;
|
||||
|
||||
// A different conversation shares none of this one's state, and the poll below
|
||||
// restarts on the same key so the new one loads immediately.
|
||||
useEffect(() => {
|
||||
sinceRef.current = null;
|
||||
knownIdsRef.current = new Set();
|
||||
loadingRef.current = Boolean(matchId && userId);
|
||||
setMessages([]);
|
||||
setPartnerTyping(undefined);
|
||||
setLoading(loadingRef.current);
|
||||
}, [matchId, userId]);
|
||||
|
||||
const refresh = useCallback(async (initial = false) => {
|
||||
if (!matchId || !userId || fetchingRef.current) return;
|
||||
fetchingRef.current = true;
|
||||
const refresh = useCallback(async () => {
|
||||
if (!matchId || !userId) return;
|
||||
const since = sinceRef.current;
|
||||
try {
|
||||
const response = await chatApi.listMessages(matchId);
|
||||
const partnerArrived = !initial && response.messages.some(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
|
||||
knownIdsRef.current = new Set(response.messages.map(message => message.id));
|
||||
setMessages(response.messages);
|
||||
if (isFocusedRef.current) await chatApi.markRead(matchId);
|
||||
if (partnerArrived) onNewMessage?.();
|
||||
const page = await chatApi.listMessages(matchId, { ...(since ? { since } : {}), typing: true });
|
||||
// Without a cursor the server cannot answer with deltas, so keep asking
|
||||
// for snapshots rather than silently freezing on a stale page.
|
||||
sinceRef.current = page.server_time ?? null;
|
||||
if (page.typing) setPartnerTyping(page.typing.typing);
|
||||
|
||||
const arrivals = page.messages.filter(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
|
||||
for (const message of page.messages) knownIdsRef.current.add(message.id);
|
||||
for (const id of page.removed_ids ?? []) knownIdsRef.current.delete(id);
|
||||
setMessages(previous => mergeMessagePage(previous, page));
|
||||
|
||||
if (isFocused && page.messages.some(message => message.user_id !== userId && !message.read_at)) {
|
||||
await chatApi.markRead(matchId);
|
||||
}
|
||||
if (!loadingRef.current && arrivals.length > 0) notifyRef.current?.();
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
if (initial) setLoading(false);
|
||||
if (loadingRef.current) {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [matchId, userId, onNewMessage]);
|
||||
}, [matchId, userId, isFocused]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchId || !userId) {
|
||||
setMessages([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
knownIdsRef.current = new Set();
|
||||
void refresh(true).catch(() => undefined);
|
||||
const timer = setInterval(() => void refresh(false).catch(() => undefined), pollInterval);
|
||||
return () => clearInterval(timer);
|
||||
}, [matchId, userId, pollInterval, refresh]);
|
||||
usePolling(refresh, {
|
||||
intervalMs: pollInterval,
|
||||
enabled: Boolean(matchId && userId && isFocused),
|
||||
resetKey: `${matchId ?? ''}:${userId ?? ''}`,
|
||||
});
|
||||
|
||||
return { messages, setMessages, loading, isFocusedRef };
|
||||
return { messages, setMessages, loading, partnerTyping, refresh };
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { isForeground, subscribeToForeground } from '../lib/appForeground';
|
||||
|
||||
export interface UsePollingOptions {
|
||||
intervalMs: number;
|
||||
/** Caller-owned relevance: screen focus, a loaded id, a paired couple. */
|
||||
enabled?: boolean;
|
||||
/** Run once as soon as polling becomes active. Default true. */
|
||||
leading?: boolean;
|
||||
/** Ceiling for the exponential backoff applied after consecutive failures. */
|
||||
maxIntervalMs?: number;
|
||||
/**
|
||||
* Restarts the schedule when it changes, so a caller that switches subject —
|
||||
* a different chat, a different couple — gets an immediate run instead of
|
||||
* waiting out the interval left over from the previous one.
|
||||
*/
|
||||
resetKey?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A recurring task with the three properties every poller in this app needs and
|
||||
* none of them had: it stops while the app is backgrounded, it never runs twice
|
||||
* concurrently, and it backs off instead of hammering an API that is failing.
|
||||
*
|
||||
* The next run is scheduled after the previous one settles rather than on a fixed
|
||||
* `setInterval`, so a slow response delays the next request instead of queueing
|
||||
* one behind it. `task` is read through a ref, so a caller may pass a fresh
|
||||
* closure every render without restarting the schedule.
|
||||
*/
|
||||
export function usePolling(task: () => Promise<void>, options: UsePollingOptions): void {
|
||||
const { intervalMs, enabled = true, leading = true, maxIntervalMs = intervalMs * 8, resetKey } = options;
|
||||
const taskRef = useRef(task);
|
||||
taskRef.current = task;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
let running = false;
|
||||
let failures = 0;
|
||||
let foreground = isForeground();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clear = () => {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
if (cancelled || !foreground || timer !== null) return;
|
||||
const delay = Math.min(intervalMs * 2 ** failures, maxIntervalMs);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
void run();
|
||||
}, delay);
|
||||
};
|
||||
const run = async () => {
|
||||
if (cancelled || running || !foreground) return;
|
||||
running = true;
|
||||
try {
|
||||
await taskRef.current();
|
||||
failures = 0;
|
||||
} catch {
|
||||
failures += 1;
|
||||
} finally {
|
||||
running = false;
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
const unsubscribe = subscribeToForeground((next) => {
|
||||
if (next === foreground) return;
|
||||
foreground = next;
|
||||
if (!foreground) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
// Back from the background: the cached data is as stale as the absence
|
||||
// was long, so catch up now rather than after another full interval.
|
||||
failures = 0;
|
||||
void run();
|
||||
});
|
||||
|
||||
if (leading) void run();
|
||||
else schedule();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clear();
|
||||
unsubscribe();
|
||||
};
|
||||
}, [enabled, intervalMs, leading, maxIntervalMs, resetKey]);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { chatApi } from '../lib/chatApi';
|
||||
import { usePolling } from './usePolling';
|
||||
|
||||
export interface UseTypingIndicatorConfig {
|
||||
channelName: string;
|
||||
@@ -9,6 +10,12 @@ export interface UseTypingIndicatorConfig {
|
||||
isFocused?: boolean;
|
||||
throttleInterval?: number;
|
||||
pollInterval?: number;
|
||||
/**
|
||||
* Typing state already known from another request. When supplied this hook
|
||||
* stops polling for it, which is how the chat screen gets typing state for
|
||||
* free out of the message poll instead of a second request every interval.
|
||||
*/
|
||||
externalTyping?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface UseTypingIndicatorReturn {
|
||||
@@ -17,31 +24,43 @@ export interface UseTypingIndicatorReturn {
|
||||
clearTypingIndicator: () => void;
|
||||
}
|
||||
|
||||
export const useTypingIndicator = ({ channelName, userId, matchId = channelName.replace(/^chat:/, ''), isFocused = true, throttleInterval = 2_000, pollInterval = 1_500 }: UseTypingIndicatorConfig): UseTypingIndicatorReturn => {
|
||||
export const useTypingIndicator = ({
|
||||
channelName,
|
||||
userId,
|
||||
matchId = channelName.replace(/^chat:/, ''),
|
||||
isFocused = true,
|
||||
throttleInterval = 2_000,
|
||||
pollInterval = 1_500,
|
||||
externalTyping,
|
||||
}: UseTypingIndicatorConfig): UseTypingIndicatorReturn => {
|
||||
const [partnerTyping, setPartnerTyping] = useState(false);
|
||||
const lastTypingEventRef = useRef(0);
|
||||
const external = externalTyping !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchId || !userId || !isFocused) {
|
||||
if (externalTyping !== undefined) setPartnerTyping(externalTyping);
|
||||
}, [externalTyping]);
|
||||
|
||||
// Losing the subject stops both sources of truth, so drop the stale indicator
|
||||
// rather than leaving the partner typing forever in a chat nobody is watching.
|
||||
useEffect(() => {
|
||||
if (!matchId || !userId || !isFocused) setPartnerTyping(false);
|
||||
}, [matchId, userId, isFocused]);
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
const state = await chatApi.getTyping(matchId);
|
||||
setPartnerTyping(state.typing);
|
||||
} catch {
|
||||
setPartnerTyping(false);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const state = await chatApi.getTyping(matchId);
|
||||
if (active) setPartnerTyping(state.typing);
|
||||
} catch {
|
||||
if (active) setPartnerTyping(false);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), pollInterval);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [matchId, userId, isFocused, pollInterval]);
|
||||
}, [matchId]);
|
||||
|
||||
usePolling(poll, {
|
||||
intervalMs: pollInterval,
|
||||
enabled: !external && Boolean(matchId) && Boolean(userId) && isFocused,
|
||||
resetKey: matchId,
|
||||
});
|
||||
|
||||
const sendTypingEvent = useCallback(() => {
|
||||
if (!matchId || !userId) return;
|
||||
|
||||
@@ -10,7 +10,36 @@ export interface LiveDrawState {
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A change summary, not a payload: markers the client compares against the ones
|
||||
* it saw last time to decide which domain, if any, is worth refetching. It
|
||||
* replaces a periodic full refresh of profile, couple, matches, pending
|
||||
* questions, packs and unread counts with one small request.
|
||||
*/
|
||||
export interface SyncSummary {
|
||||
server_time: string;
|
||||
couple_id: string | null;
|
||||
profile_updated_at: string | null;
|
||||
partner_id: string | null;
|
||||
partner_updated_at: string | null;
|
||||
match_count: number;
|
||||
new_match_count: number;
|
||||
latest_match_at: string | null;
|
||||
pending_yours: number;
|
||||
pending_theirs: number;
|
||||
unread_total: number;
|
||||
/** couple_packs carries no timestamp, so equal-and-opposite toggles are caught by a digest. */
|
||||
enabled_packs_fingerprint: string | null;
|
||||
/** Digest of each visible match's type and response summary; moves on an in-place response edit that the count/latest-at markers miss. */
|
||||
match_state_fingerprint: string;
|
||||
/** Digest of each visible match's unread count; moves when unread redistributes between matches without changing the total. */
|
||||
match_unread_fingerprint: string;
|
||||
/** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */
|
||||
streak_updated_at: string | null;
|
||||
}
|
||||
|
||||
export const appDataApi = {
|
||||
syncSummary: () => apiClient.get<SyncSummary>('/v1/me/sync'),
|
||||
packContext: (packId: string) => apiClient.get<{ pack: { id: string; name: string; icon: string | null } }>(`/v1/packs/${encodeURIComponent(packId)}/context`),
|
||||
packQuestions: (packId: string) => apiClient.get<{ questions: Question[] }>(`/v1/packs/${encodeURIComponent(packId)}/questions`),
|
||||
packTeaser: (packId: string) => apiClient.get<{ questions: Array<{ id: string; text: string; intensity: number }> }>(`/v1/packs/${encodeURIComponent(packId)}/teaser`),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AppState, type AppStateStatus } from 'react-native';
|
||||
|
||||
/**
|
||||
* `background` is a real suspension and `inactive` is the moment behind the app
|
||||
* switcher, a call, or a system prompt. Anything else — including states a test
|
||||
* harness never sets — counts as foreground, so recurring work fails towards
|
||||
* running rather than towards being silently disabled forever.
|
||||
*/
|
||||
export function isForegroundState(state: AppStateStatus | string | undefined): boolean {
|
||||
return state !== 'background' && state !== 'inactive';
|
||||
}
|
||||
|
||||
export function isForeground(): boolean {
|
||||
return isForegroundState(AppState.currentState as AppStateStatus | undefined);
|
||||
}
|
||||
|
||||
/** Reports the new foreground flag on every transition. Returns an unsubscribe. */
|
||||
export function subscribeToForeground(onChange: (foreground: boolean) => void): () => void {
|
||||
const subscription = AppState.addEventListener('change', (state) => onChange(isForegroundState(state)));
|
||||
return () => subscription.remove();
|
||||
}
|
||||
@@ -1,8 +1,40 @@
|
||||
import { apiClient } from './apiClient';
|
||||
import type { Message, ReportReason } from '../features/chat/types';
|
||||
|
||||
export interface PartnerTyping {
|
||||
typing: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A snapshot when `complete`, otherwise only what changed since the cursor the
|
||||
* previous page handed back. Delta rows are whole messages, not patches, so a
|
||||
* caller merges them by id without a second shape to understand.
|
||||
*/
|
||||
export interface MessagesPage {
|
||||
messages: Message[];
|
||||
/** Messages the caller deleted for themselves since the cursor. */
|
||||
removed_ids?: string[];
|
||||
/** Cursor to send as `since` on the next poll. */
|
||||
server_time?: string;
|
||||
complete?: boolean;
|
||||
/** Present when requested, saving a separate typing request. */
|
||||
typing?: PartnerTyping;
|
||||
}
|
||||
|
||||
export interface ListMessagesOptions {
|
||||
since?: string;
|
||||
typing?: boolean;
|
||||
}
|
||||
|
||||
export const chatApi = {
|
||||
listMessages: (matchId: string) => apiClient.get<{ messages: Message[] }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`),
|
||||
listMessages: (matchId: string, options: ListMessagesOptions = {}) => {
|
||||
const query: string[] = [];
|
||||
if (options.since) query.push(`since=${encodeURIComponent(options.since)}`);
|
||||
if (options.typing) query.push('typing=true');
|
||||
const suffix = query.length > 0 ? `?${query.join('&')}` : '';
|
||||
return apiClient.get<MessagesPage>(`/v1/matches/${encodeURIComponent(matchId)}/messages${suffix}`);
|
||||
},
|
||||
sendText: (matchId: string, content: string) => apiClient.post<{ message: Message }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`, { content }),
|
||||
unread: () => apiClient.get<{ total: number; by_match: Record<string, number> }>('/v1/chat/unread'),
|
||||
markRead: (matchId: string) => apiClient.post<{ updated: number; read_at: string }>(`/v1/matches/${encodeURIComponent(matchId)}/read`),
|
||||
@@ -11,5 +43,5 @@ export const chatApi = {
|
||||
deleteForEveryone: (messageId: string) => apiClient.delete<{ deleted: true; message: Message }>(`/v1/messages/${encodeURIComponent(messageId)}?scope=everyone`),
|
||||
report: (messageId: string, reason: ReportReason) => apiClient.post<{ reported: true }>(`/v1/messages/${encodeURIComponent(messageId)}/reports`, { reason }),
|
||||
setTyping: (matchId: string) => apiClient.put<{ typing: true }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
|
||||
getTyping: (matchId: string) => apiClient.get<{ typing: boolean; expires_at: string | null }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
|
||||
getTyping: (matchId: string) => apiClient.get<PartnerTyping>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
|
||||
};
|
||||
+175
-171
@@ -4,104 +4,106 @@ import { authClient } from "../lib/authClient";
|
||||
import { coupleApi } from "../lib/coupleApi";
|
||||
import { profileSettingsApi } from "../lib/profileSettingsApi";
|
||||
import { syncTimezone } from "../lib/reportedTimezone";
|
||||
import { Events } from "../lib/analytics";
|
||||
import type { Profile, Couple } from "@/types";
|
||||
|
||||
|
||||
interface AuthState {
|
||||
user: Profile | null;
|
||||
couple: Couple | null;
|
||||
partner: Profile | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isAnonymous: boolean;
|
||||
|
||||
|
||||
// Actions
|
||||
fetchUser: () => Promise<void>;
|
||||
fetchCouple: () => Promise<void>;
|
||||
refreshPartner: () => Promise<Profile | null>;
|
||||
signOut: () => Promise<void>;
|
||||
setUser: (user: Profile | null) => void;
|
||||
updateLastActive: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Import other stores lazily to avoid circular dependency issues
|
||||
const getOtherStores = () => {
|
||||
const { useMatchStore } = require("./matchStore");
|
||||
const { usePacksStore } = require("./packsStore");
|
||||
const { useMessageStore } = require("./messageStore");
|
||||
const { useSubscriptionStore } = require("./subscriptionStore");
|
||||
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
|
||||
const { useStreakStore } = require("./streakStore");
|
||||
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore };
|
||||
};
|
||||
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
couple: null,
|
||||
partner: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
|
||||
fetchUser: async () => {
|
||||
try {
|
||||
// First check if we have a session in storage
|
||||
import { Events } from "../lib/analytics";
|
||||
import type { Profile, Couple } from "@/types";
|
||||
|
||||
|
||||
interface AuthState {
|
||||
user: Profile | null;
|
||||
couple: Couple | null;
|
||||
partner: Profile | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isAnonymous: boolean;
|
||||
|
||||
|
||||
// Actions
|
||||
fetchUser: () => Promise<void>;
|
||||
fetchCouple: () => Promise<void>;
|
||||
refreshPartner: () => Promise<Profile | null>;
|
||||
signOut: () => Promise<void>;
|
||||
setUser: (user: Profile | null) => void;
|
||||
updateLastActive: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Import other stores lazily to avoid circular dependency issues
|
||||
const getOtherStores = () => {
|
||||
const { useMatchStore } = require("./matchStore");
|
||||
const { usePacksStore } = require("./packsStore");
|
||||
const { useMessageStore } = require("./messageStore");
|
||||
const { useSubscriptionStore } = require("./subscriptionStore");
|
||||
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
|
||||
const { useStreakStore } = require("./streakStore");
|
||||
const { useResponsesStore } = require("./responsesStore");
|
||||
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore };
|
||||
};
|
||||
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
couple: null,
|
||||
partner: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
|
||||
fetchUser: async () => {
|
||||
try {
|
||||
// First check if we have a session in storage
|
||||
const { data: { session } } = await authClient.auth.getSession();
|
||||
|
||||
if (!session?.user) {
|
||||
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!session?.user) {
|
||||
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// This endpoint verifies the Supabase access token and creates the
|
||||
// product profile on first use, keeping profile data out of Supabase.
|
||||
const { profile } = await apiClient.get<{ profile: Profile }>("/v1/me");
|
||||
|
||||
const isAnonymous = !!(session.user as { is_anonymous?: boolean }).is_anonymous;
|
||||
|
||||
// Only update user if data actually changed to avoid unnecessary re-renders
|
||||
const currentUser = get().user;
|
||||
const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile);
|
||||
if (userChanged) {
|
||||
set({
|
||||
user: profile,
|
||||
isAuthenticated: true,
|
||||
isAnonymous,
|
||||
});
|
||||
} else if (!get().isAuthenticated) {
|
||||
set({ isAuthenticated: true, isAnonymous });
|
||||
}
|
||||
|
||||
// If user has a couple, fetch couple data; otherwise clear couple/partner
|
||||
if (profile?.couple_id) {
|
||||
await get().fetchCouple();
|
||||
} else {
|
||||
set({ couple: null, partner: null });
|
||||
}
|
||||
|
||||
set({ isLoading: false });
|
||||
|
||||
// Only update user if data actually changed to avoid unnecessary re-renders
|
||||
const currentUser = get().user;
|
||||
const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile);
|
||||
if (userChanged) {
|
||||
set({
|
||||
user: profile,
|
||||
isAuthenticated: true,
|
||||
isAnonymous,
|
||||
});
|
||||
} else if (!get().isAuthenticated) {
|
||||
set({ isAuthenticated: true, isAnonymous });
|
||||
}
|
||||
|
||||
// If user has a couple, fetch couple data; otherwise clear couple/partner
|
||||
if (profile?.couple_id) {
|
||||
await get().fetchCouple();
|
||||
} else {
|
||||
set({ couple: null, partner: null });
|
||||
}
|
||||
|
||||
set({ isLoading: false });
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
console.log("[Auth] Session rejected by API, signing out");
|
||||
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
useResponsesStore.getState().clearResponses();
|
||||
await authClient.auth.signOut();
|
||||
return;
|
||||
}
|
||||
console.error("Error fetching user:", error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
fetchCouple: async () => {
|
||||
const user = get().user;
|
||||
if (!user?.couple_id) {
|
||||
@@ -111,100 +113,102 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
const { couple, partner } = await coupleApi.getState();
|
||||
set({ couple, partner });
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh partner's profile data (useful to get latest public_key_jwk for E2EE)
|
||||
* Returns the updated partner profile or null if not found
|
||||
*/
|
||||
refreshPartner: async () => {
|
||||
const user = get().user;
|
||||
if (!user?.couple_id) return null;
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh partner's profile data (useful to get latest public_key_jwk for E2EE)
|
||||
* Returns the updated partner profile or null if not found
|
||||
*/
|
||||
refreshPartner: async () => {
|
||||
const user = get().user;
|
||||
if (!user?.couple_id) return null;
|
||||
|
||||
const { partner } = await coupleApi.getState();
|
||||
|
||||
if (partner) {
|
||||
set({ partner });
|
||||
}
|
||||
return partner;
|
||||
},
|
||||
|
||||
signOut: async () => {
|
||||
Events.signOut();
|
||||
|
||||
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
||||
set({
|
||||
user: null,
|
||||
couple: null,
|
||||
partner: null,
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
isLoading: false,
|
||||
});
|
||||
// Clear other stores
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
|
||||
// Clear badge on sign out
|
||||
const { clearBadge } = require("../lib/badge");
|
||||
await clearBadge();
|
||||
|
||||
// Then try to sign out from Supabase (don't block on this)
|
||||
try {
|
||||
|
||||
if (partner) {
|
||||
set({ partner });
|
||||
}
|
||||
return partner;
|
||||
},
|
||||
|
||||
signOut: async () => {
|
||||
Events.signOut();
|
||||
|
||||
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
||||
set({
|
||||
user: null,
|
||||
couple: null,
|
||||
partner: null,
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
isLoading: false,
|
||||
});
|
||||
// Clear other stores
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
useResponsesStore.getState().clearResponses();
|
||||
|
||||
// Clear badge on sign out
|
||||
const { clearBadge } = require("../lib/badge");
|
||||
await clearBadge();
|
||||
|
||||
// Then try to sign out from Supabase (don't block on this)
|
||||
try {
|
||||
await authClient.auth.signOut();
|
||||
} catch (error) {
|
||||
console.error("Supabase signOut error:", error);
|
||||
}
|
||||
},
|
||||
|
||||
setUser: (user) => {
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
...(user === null && { isAnonymous: false }),
|
||||
isLoading: false,
|
||||
// Clear couple/partner when user is null (signed out)
|
||||
...(user === null && { couple: null, partner: null })
|
||||
});
|
||||
// Clear other stores when user signs out
|
||||
if (user === null) {
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
// Clear badge on sign out
|
||||
const { clearBadge } = require("../lib/badge");
|
||||
clearBadge();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the user's last_active_at timestamp.
|
||||
* Called when the app comes to the foreground to track user activity.
|
||||
* This helps prevent sending notifications to users who are already in the app.
|
||||
*/
|
||||
updateLastActive: async () => {
|
||||
const userId = get().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
} catch (error) {
|
||||
console.error("Supabase signOut error:", error);
|
||||
}
|
||||
},
|
||||
|
||||
setUser: (user) => {
|
||||
set({
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
...(user === null && { isAnonymous: false }),
|
||||
isLoading: false,
|
||||
// Clear couple/partner when user is null (signed out)
|
||||
...(user === null && { couple: null, partner: null })
|
||||
});
|
||||
// Clear other stores when user signs out
|
||||
if (user === null) {
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
useResponsesStore.getState().clearResponses();
|
||||
// Clear badge on sign out
|
||||
const { clearBadge } = require("../lib/badge");
|
||||
clearBadge();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the user's last_active_at timestamp.
|
||||
* Called when the app comes to the foreground to track user activity.
|
||||
* This helps prevent sending notifications to users who are already in the app.
|
||||
*/
|
||||
updateLastActive: async () => {
|
||||
const userId = get().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
await profileSettingsApi.updateLastActive();
|
||||
} catch (error) {
|
||||
// Silently fail - this is not critical
|
||||
console.error("Failed to update last_active_at:", error);
|
||||
}
|
||||
|
||||
// Keep the server's idea of the user's timezone current so the daily
|
||||
// response limit resets at their local midnight, not 00:00 UTC.
|
||||
void syncTimezone(userId);
|
||||
},
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
// Silently fail - this is not critical
|
||||
console.error("Failed to update last_active_at:", error);
|
||||
}
|
||||
|
||||
// Keep the server's idea of the user's timezone current so the daily
|
||||
// response limit resets at their local midnight, not 00:00 UTC.
|
||||
void syncTimezone(userId);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -15,6 +15,20 @@ export interface PendingQuestion {
|
||||
|
||||
type MatchViewType = 'active' | 'archived' | 'pending' | 'their_turn';
|
||||
|
||||
/**
|
||||
* A refresh that must not disturb what the user is looking at: no spinner, and
|
||||
* the cached rows stay on screen until the replacement arrives.
|
||||
*/
|
||||
export interface FetchOptions { silent?: boolean }
|
||||
|
||||
// Overlap protection lives outside the store because the loading flags are now
|
||||
// presentational — a silent refresh sets none of them and still must not run twice.
|
||||
// Keyed to the generation that started the request (see `generation` below) rather
|
||||
// than a bare presence check: a stale generation's entry must not block the next
|
||||
// account's request for the same key, and a stale generation's `finally` must not
|
||||
// clear an entry a newer generation just claimed.
|
||||
const inFlight = new Map<string, number>();
|
||||
|
||||
// Rate limit: 12 hours in milliseconds
|
||||
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
@@ -32,6 +46,8 @@ interface MatchState {
|
||||
newMatchesCount: number;
|
||||
totalCount: number | null;
|
||||
isLoading: boolean;
|
||||
/** A user-initiated refresh over data already on screen; drives pull-to-refresh only. */
|
||||
isRefreshing: boolean;
|
||||
isLoadingMore: boolean;
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
@@ -48,11 +64,23 @@ interface MatchState {
|
||||
theirTurnQuestions: PendingQuestion[];
|
||||
isLoadingTheirTurn: boolean;
|
||||
currentView: MatchViewType;
|
||||
/**
|
||||
* When each view last loaded successfully. Tracked separately from the row
|
||||
* arrays because an empty view is still a loaded view, and revisiting one
|
||||
* must not refetch.
|
||||
*/
|
||||
loadedAt: Partial<Record<MatchViewType, number>>;
|
||||
// Nudge state
|
||||
nudgeCooldownUntil: Date | null;
|
||||
isNudging: boolean;
|
||||
/**
|
||||
* Bumped on every clear (sign-out/account switch). A fetch captures this before
|
||||
* it starts and checks it again before writing its result, so a response that
|
||||
* outlives the clear cannot land in the next account's store.
|
||||
*/
|
||||
generation: number;
|
||||
// Methods
|
||||
fetchMatches: (refresh?: boolean) => Promise<void>;
|
||||
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<void>;
|
||||
markAsSeen: (matchId: string) => Promise<void>;
|
||||
markAllAsSeen: () => Promise<void>;
|
||||
addMatch: (match: Match) => void;
|
||||
@@ -61,13 +89,14 @@ interface MatchState {
|
||||
// Archive methods
|
||||
archiveMatch: (matchId: string) => Promise<void>;
|
||||
unarchiveMatch: (matchId: string) => Promise<void>;
|
||||
fetchArchivedMatches: () => Promise<void>;
|
||||
fetchArchivedMatches: (options?: FetchOptions) => Promise<void>;
|
||||
toggleShowArchived: () => void;
|
||||
isMatchArchived: (matchId: string) => boolean;
|
||||
// Pending methods
|
||||
fetchPendingQuestions: () => Promise<void>;
|
||||
fetchTheirTurnQuestions: () => Promise<void>;
|
||||
fetchPendingQuestions: (options?: FetchOptions) => Promise<void>;
|
||||
fetchTheirTurnQuestions: (options?: FetchOptions) => Promise<void>;
|
||||
setCurrentView: (view: MatchViewType) => void;
|
||||
ensureViewLoaded: (view?: MatchViewType) => void;
|
||||
// Nudge methods
|
||||
sendNudge: () => Promise<{ success: boolean; notificationSent: boolean }>;
|
||||
checkNudgeCooldown: () => Promise<void>;
|
||||
@@ -78,6 +107,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
newMatchesCount: 0,
|
||||
totalCount: null,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
isLoadingMore: false,
|
||||
page: 0,
|
||||
hasMore: true,
|
||||
@@ -94,40 +124,53 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
theirTurnQuestions: [],
|
||||
isLoadingTheirTurn: false,
|
||||
currentView: 'pending' as MatchViewType,
|
||||
loadedAt: {},
|
||||
// Nudge state
|
||||
nudgeCooldownUntil: null,
|
||||
isNudging: false,
|
||||
generation: 0,
|
||||
|
||||
fetchMatches: async (refresh = false) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
fetchMatches: async (refresh = false, options = {}) => {
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
const myGeneration = get().generation;
|
||||
|
||||
// Early return if no couple - user isn't paired yet
|
||||
if (!coupleId) {
|
||||
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false });
|
||||
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = get();
|
||||
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
|
||||
if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return;
|
||||
|
||||
// Nothing on screen yet is the only case that warrants blanking the list
|
||||
// for a spinner; every other refresh leaves the cached rows in place.
|
||||
const initial = state.loadedAt.active === undefined;
|
||||
if (refresh) {
|
||||
set({ isLoading: true, error: null, page: 0, hasMore: true });
|
||||
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
|
||||
} else {
|
||||
if (!state.hasMore) return;
|
||||
set({ isLoadingMore: true });
|
||||
}
|
||||
|
||||
inFlight.set('active', myGeneration);
|
||||
try {
|
||||
const currentPage = refresh ? 0 : state.page;
|
||||
const result = await apiClient.get<{ matches: Match[]; totalCount: number }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
|
||||
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
|
||||
// A sign-out/account switch that happened while this request was in
|
||||
// flight must not let its response populate the next account's store.
|
||||
if (get().generation !== myGeneration) return;
|
||||
const nonArchivedMatches = result.matches;
|
||||
const totalCount = result.totalCount;
|
||||
const archivedMatchIds = state.archivedMatchIds;
|
||||
// The API reports this by reading one row past the page; the length
|
||||
// comparison is only a fallback for an older server.
|
||||
const hasMore = result.hasMore ?? nonArchivedMatches.length === BATCH_SIZE;
|
||||
const loadedAt = { ...state.loadedAt, active: Date.now() };
|
||||
|
||||
if (nonArchivedMatches.length === 0) {
|
||||
if (refresh) {
|
||||
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false, archivedMatchIds });
|
||||
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, archivedMatchIds, loadedAt });
|
||||
} else {
|
||||
set({ isLoadingMore: false, hasMore: false });
|
||||
}
|
||||
@@ -139,7 +182,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
// Calculate new matches count - this is tricky with pagination
|
||||
// Ideally we should have a separate query for count, but we'll just count in current batch
|
||||
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
|
||||
|
||||
|
||||
set((state) => {
|
||||
// Deduplicate matches by ID when appending new pages
|
||||
const mergedMatches = refresh
|
||||
@@ -154,22 +197,34 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
return {
|
||||
matches: mergedMatches,
|
||||
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
|
||||
totalCount: refresh ? totalCount : state.totalCount,
|
||||
// Later pages no longer carry a total, so keep the one the refresh recorded.
|
||||
totalCount: refresh ? totalCount ?? state.totalCount : state.totalCount,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
isLoadingMore: false,
|
||||
page: currentPage + 1,
|
||||
hasMore: nonArchivedMatches.length === BATCH_SIZE,
|
||||
hasMore,
|
||||
archivedMatchIds: refresh ? archivedMatchIds : state.archivedMatchIds,
|
||||
loadedAt,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
console.error("Error fetching matches:", err);
|
||||
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
|
||||
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
|
||||
} finally {
|
||||
// An old generation's cleanup must not delete a newer generation's guard.
|
||||
if (inFlight.get('active') === myGeneration) inFlight.delete('active');
|
||||
}
|
||||
},
|
||||
|
||||
markAsSeen: async (matchId) => {
|
||||
const myGeneration = get().generation;
|
||||
await apiClient.patch("/v1/matches/seen", { ids: [matchId] });
|
||||
// A clear while this was in flight leaves an empty list behind, and the
|
||||
// recount below would write that emptiness — and a zero badge — over
|
||||
// whatever the next account has already loaded.
|
||||
if (get().generation !== myGeneration) return;
|
||||
|
||||
const matches = get().matches.map((m) =>
|
||||
m.id === matchId ? { ...m, is_new: false } : m
|
||||
@@ -179,11 +234,13 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
},
|
||||
|
||||
markAllAsSeen: async () => {
|
||||
const myGeneration = get().generation;
|
||||
const newMatches = get().matches.filter((m) => m.is_new);
|
||||
if (newMatches.length === 0) return;
|
||||
|
||||
const newMatchIds = newMatches.map((m) => m.id);
|
||||
await apiClient.patch("/v1/matches/seen", { ids: newMatchIds });
|
||||
if (get().generation !== myGeneration) return;
|
||||
|
||||
const matches = get().matches.map((m) =>
|
||||
m.is_new ? { ...m, is_new: false } : m
|
||||
@@ -210,11 +267,12 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
},
|
||||
|
||||
clearMatches: () => {
|
||||
set({
|
||||
set((state) => ({
|
||||
matches: [],
|
||||
newMatchesCount: 0,
|
||||
totalCount: null,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: null,
|
||||
page: 0,
|
||||
hasMore: true,
|
||||
@@ -228,9 +286,16 @@ clearMatches: () => {
|
||||
theirTurnQuestions: [],
|
||||
isLoadingTheirTurn: false,
|
||||
currentView: 'pending',
|
||||
loadedAt: {},
|
||||
nudgeCooldownUntil: null,
|
||||
isNudging: false,
|
||||
});
|
||||
// Invalidates any request already in flight: its generation check will
|
||||
// fail and it will not be able to write into the next account's store.
|
||||
generation: state.generation + 1,
|
||||
}));
|
||||
// A request belonging to the signed-out user must not suppress the next
|
||||
// one: the guard is a duplicate-request check, not a lock on the data.
|
||||
inFlight.clear();
|
||||
},
|
||||
|
||||
// Archive methods
|
||||
@@ -240,6 +305,7 @@ clearMatches: () => {
|
||||
|
||||
// Optimistically update UI
|
||||
const state = get();
|
||||
const myGeneration = state.generation;
|
||||
const matchToArchive = state.matches.find(m => m.id === matchId);
|
||||
|
||||
if (matchToArchive) {
|
||||
@@ -258,7 +324,11 @@ clearMatches: () => {
|
||||
await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: true });
|
||||
} catch (error) {
|
||||
console.error("Error archiving match:", error);
|
||||
// Revert on error
|
||||
// Revert on error — but only into the store the optimistic update was
|
||||
// applied to. After an account switch the row it would restore belongs
|
||||
// to the previous couple, and putting it back would show one couple's
|
||||
// match inside another's list.
|
||||
if (get().generation !== myGeneration) return;
|
||||
if (matchToArchive) {
|
||||
const revertArchivedIds = new Set(get().archivedMatchIds);
|
||||
revertArchivedIds.delete(matchId);
|
||||
@@ -279,6 +349,7 @@ clearMatches: () => {
|
||||
|
||||
// Optimistically update UI
|
||||
const state = get();
|
||||
const myGeneration = state.generation;
|
||||
const matchToUnarchive = state.archivedMatches.find(m => m.id === matchId);
|
||||
|
||||
if (matchToUnarchive) {
|
||||
@@ -297,7 +368,8 @@ clearMatches: () => {
|
||||
await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: false });
|
||||
} catch (error) {
|
||||
console.error("Error unarchiving match:", error);
|
||||
// Revert on error
|
||||
// Revert on error, and only for the account that started it.
|
||||
if (get().generation !== myGeneration) return;
|
||||
if (matchToUnarchive) {
|
||||
const revertArchivedIds = new Set(get().archivedMatchIds);
|
||||
revertArchivedIds.add(matchId);
|
||||
@@ -312,34 +384,35 @@ clearMatches: () => {
|
||||
}
|
||||
},
|
||||
|
||||
fetchArchivedMatches: async () => {
|
||||
fetchArchivedMatches: async (options = {}) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
|
||||
if (!coupleId || !userId) return;
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const state = get();
|
||||
if (state.isLoadingArchived) return;
|
||||
|
||||
set({ isLoadingArchived: true });
|
||||
if (inFlight.get('archived') === myGeneration) return;
|
||||
if (!options.silent || state.loadedAt.archived === undefined) set({ isLoadingArchived: true });
|
||||
|
||||
inFlight.set('archived', myGeneration);
|
||||
try {
|
||||
const result = await apiClient.get<{matches:Match[];totalCount:number}>(`/v1/matches?archived=true&limit=100`);
|
||||
const result = await apiClient.get<{matches:Match[];totalCount:number | null}>(`/v1/matches?archived=true&limit=100`);
|
||||
if (get().generation !== myGeneration) return;
|
||||
const archivedMatches = result.matches;
|
||||
const archivedIds = archivedMatches.map(match => match.id);
|
||||
if (archivedMatches.length === 0) {
|
||||
set({ archivedMatches: [], isLoadingArchived: false });
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
set((current) => ({
|
||||
archivedMatches,
|
||||
isLoadingArchived: false,
|
||||
archivedMatchIds: new Set(archivedIds),
|
||||
});
|
||||
loadedAt: { ...current.loadedAt, archived: Date.now() },
|
||||
}));
|
||||
} catch (err) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
console.error("Error fetching archived matches:", err);
|
||||
set({ isLoadingArchived: false });
|
||||
} finally {
|
||||
if (inFlight.get('archived') === myGeneration) inFlight.delete('archived');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -350,8 +423,8 @@ clearMatches: () => {
|
||||
set({ showArchived: newShowArchived });
|
||||
|
||||
// Fetch archived matches if switching to archived view and not yet loaded
|
||||
if (newShowArchived && state.archivedMatches.length === 0) {
|
||||
get().fetchArchivedMatches();
|
||||
if (newShowArchived && state.loadedAt.archived === undefined) {
|
||||
void get().fetchArchivedMatches();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -360,7 +433,7 @@ clearMatches: () => {
|
||||
},
|
||||
|
||||
// Pending (Your Turn) methods
|
||||
fetchPendingQuestions: async () => {
|
||||
fetchPendingQuestions: async (options = {}) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
|
||||
@@ -369,22 +442,27 @@ clearMatches: () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const state = get();
|
||||
if (state.isLoadingPending) return;
|
||||
|
||||
set({ isLoadingPending: true });
|
||||
if (inFlight.get('pending') === myGeneration) return;
|
||||
if (!options.silent || state.loadedAt.pending === undefined) set({ isLoadingPending: true });
|
||||
|
||||
inFlight.set('pending', myGeneration);
|
||||
try {
|
||||
const { questions: pendingQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=partner");
|
||||
set({ pendingQuestions, isLoadingPending: false });
|
||||
if (get().generation !== myGeneration) return;
|
||||
set((current) => ({ pendingQuestions, isLoadingPending: false, loadedAt: { ...current.loadedAt, pending: Date.now() } }));
|
||||
} catch (err) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
console.error("Error fetching pending questions:", err);
|
||||
set({ isLoadingPending: false });
|
||||
} finally {
|
||||
if (inFlight.get('pending') === myGeneration) inFlight.delete('pending');
|
||||
}
|
||||
},
|
||||
|
||||
// Their Turn methods (user answered, partner hasn't yet)
|
||||
fetchTheirTurnQuestions: async () => {
|
||||
fetchTheirTurnQuestions: async (options = {}) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
|
||||
@@ -393,35 +471,43 @@ clearMatches: () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const state = get();
|
||||
if (state.isLoadingTheirTurn) return;
|
||||
|
||||
set({ isLoadingTheirTurn: true });
|
||||
if (inFlight.get('their_turn') === myGeneration) return;
|
||||
if (!options.silent || state.loadedAt.their_turn === undefined) set({ isLoadingTheirTurn: true });
|
||||
|
||||
inFlight.set('their_turn', myGeneration);
|
||||
try {
|
||||
const { questions: theirTurnQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=mine");
|
||||
set({ theirTurnQuestions, isLoadingTheirTurn: false });
|
||||
if (get().generation !== myGeneration) return;
|
||||
set((current) => ({ theirTurnQuestions, isLoadingTheirTurn: false, loadedAt: { ...current.loadedAt, their_turn: Date.now() } }));
|
||||
} catch (err) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
console.error("Error fetching their turn questions:", err);
|
||||
set({ isLoadingTheirTurn: false });
|
||||
} finally {
|
||||
if (inFlight.get('their_turn') === myGeneration) inFlight.delete('their_turn');
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentView: (view: MatchViewType) => {
|
||||
const state = get();
|
||||
|
||||
// Update showArchived for backwards compatibility
|
||||
const showArchived = view === 'archived';
|
||||
set({ currentView: view, showArchived });
|
||||
|
||||
// Fetch data for the selected view if not already loaded
|
||||
if (view === 'archived' && state.archivedMatches.length === 0) {
|
||||
get().fetchArchivedMatches();
|
||||
} else if (view === 'pending' && state.pendingQuestions.length === 0) {
|
||||
get().fetchPendingQuestions();
|
||||
} else if (view === 'their_turn' && state.theirTurnQuestions.length === 0) {
|
||||
get().fetchTheirTurnQuestions();
|
||||
}
|
||||
get().ensureViewLoaded(view);
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads a view once. Emptiness is not evidence of never having loaded, so
|
||||
* returning to a legitimately empty view costs no request at all.
|
||||
*/
|
||||
ensureViewLoaded: (view = get().currentView) => {
|
||||
if (get().loadedAt[view] !== undefined) return;
|
||||
if (view === 'archived') void get().fetchArchivedMatches();
|
||||
else if (view === 'pending') void get().fetchPendingQuestions();
|
||||
else if (view === 'their_turn') void get().fetchTheirTurnQuestions();
|
||||
else void get().fetchMatches(true);
|
||||
},
|
||||
|
||||
// Nudge methods
|
||||
@@ -436,10 +522,14 @@ clearMatches: () => {
|
||||
return { success: false, notificationSent: false };
|
||||
}
|
||||
|
||||
const myGeneration = state.generation;
|
||||
set({ isNudging: true });
|
||||
|
||||
try {
|
||||
const data = await appDataApi.sendNudge();
|
||||
// The cooldown is per-account. Recording the previous account's window
|
||||
// would either silence the new account's nudge or clear a real cooldown.
|
||||
if (get().generation !== myGeneration) return { success: false, notificationSent: false };
|
||||
|
||||
// Success - update cooldown
|
||||
const cooldownUntil = data.next_nudge_available_at
|
||||
@@ -453,6 +543,7 @@ clearMatches: () => {
|
||||
notificationSent: data.notification_sent ?? false,
|
||||
};
|
||||
} catch (err) {
|
||||
if (get().generation !== myGeneration) return { success: false, notificationSent: false };
|
||||
if (err instanceof ApiError && err.status === 429) {
|
||||
const details = err.details as NudgeResponse | undefined;
|
||||
const cooldownUntil = details?.next_nudge_available_at
|
||||
@@ -471,8 +562,12 @@ clearMatches: () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const myGeneration = get().generation;
|
||||
try {
|
||||
const profile = await appDataApi.nudgeStatus();
|
||||
// Read for the previous account: neither its cooldown nor its absence
|
||||
// says anything about whether this one may nudge.
|
||||
if (get().generation !== myGeneration) return;
|
||||
|
||||
if (!profile.last_nudge_sent_at) {
|
||||
set({ nudgeCooldownUntil: null });
|
||||
|
||||
@@ -1,85 +1,110 @@
|
||||
import { create } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { chatApi } from "../lib/chatApi";
|
||||
import type { Database } from "@/types/supabase";
|
||||
import { useAuthStore } from "./authStore";
|
||||
import { useMatchStore } from "./matchStore";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
interface MessageWithMatch extends Message {
|
||||
match?: {
|
||||
id: string;
|
||||
question: {
|
||||
text: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface MessageState {
|
||||
unreadCount: number;
|
||||
lastMessage: MessageWithMatch | null;
|
||||
activeMatchId: string | null; // Track which chat is currently open
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
addMessage: (message: MessageWithMatch) => void;
|
||||
clearLastMessage: () => void;
|
||||
setActiveMatchId: (matchId: string | null) => void;
|
||||
markMatchMessagesAsRead: (matchId: string) => Promise<void>;
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
unreadCount: 0,
|
||||
lastMessage: null,
|
||||
activeMatchId: null,
|
||||
|
||||
fetchUnreadCount: async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) {
|
||||
set({ unreadCount: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
import type { Database } from "@/types/supabase";
|
||||
import { useAuthStore } from "./authStore";
|
||||
import { useMatchStore } from "./matchStore";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
interface MessageWithMatch extends Message {
|
||||
match?: {
|
||||
id: string;
|
||||
question: {
|
||||
text: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface MessageState {
|
||||
unreadCount: number;
|
||||
lastMessage: MessageWithMatch | null;
|
||||
activeMatchId: string | null; // Track which chat is currently open
|
||||
/**
|
||||
* Bumped by `clearMessages` on sign-out or a couple change, so a reply that
|
||||
* outlives the account that asked for it cannot write into the next one's badge.
|
||||
*/
|
||||
generation: number;
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
/** Applies a count the sync summary already reported, without a second request. */
|
||||
setUnreadCount: (total: number) => void;
|
||||
addMessage: (message: MessageWithMatch) => void;
|
||||
clearLastMessage: () => void;
|
||||
setActiveMatchId: (matchId: string | null) => void;
|
||||
markMatchMessagesAsRead: (matchId: string) => Promise<void>;
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
unreadCount: 0,
|
||||
lastMessage: null,
|
||||
activeMatchId: null,
|
||||
generation: 0,
|
||||
|
||||
fetchUnreadCount: async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) {
|
||||
set({ unreadCount: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const { total } = await chatApi.unread();
|
||||
// This total counts one account's messages; after a clear it would show
|
||||
// up as the next account's badge.
|
||||
if (get().generation !== myGeneration) return;
|
||||
set({ unreadCount: total });
|
||||
},
|
||||
|
||||
addMessage: (message) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const activeMatchId = get().activeMatchId;
|
||||
|
||||
// Only show notification if message is from partner and not in the active chat
|
||||
if (message.user_id !== userId && message.match_id !== activeMatchId) {
|
||||
set((state) => ({
|
||||
unreadCount: state.unreadCount + 1,
|
||||
lastMessage: message,
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
clearLastMessage: () => {
|
||||
set({ lastMessage: null });
|
||||
},
|
||||
|
||||
setActiveMatchId: (matchId) => {
|
||||
set({ activeMatchId: matchId });
|
||||
},
|
||||
|
||||
markMatchMessagesAsRead: async (matchId) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
},
|
||||
|
||||
setUnreadCount: (total) => {
|
||||
set({ unreadCount: Math.max(0, total) });
|
||||
},
|
||||
|
||||
addMessage: (message) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const activeMatchId = get().activeMatchId;
|
||||
|
||||
// Only show notification if message is from partner and not in the active chat
|
||||
if (message.user_id !== userId && message.match_id !== activeMatchId) {
|
||||
set((state) => ({
|
||||
unreadCount: state.unreadCount + 1,
|
||||
lastMessage: message,
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
clearLastMessage: () => {
|
||||
set({ lastMessage: null });
|
||||
},
|
||||
|
||||
setActiveMatchId: (matchId) => {
|
||||
set({ activeMatchId: matchId });
|
||||
},
|
||||
|
||||
markMatchMessagesAsRead: async (matchId) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const { updated: unreadInMatch } = await chatApi.markRead(matchId);
|
||||
|
||||
// Refetch global unread count
|
||||
await get().fetchUnreadCount();
|
||||
|
||||
// Sync per-match unread count in matchStore
|
||||
if (unreadInMatch > 0) {
|
||||
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
|
||||
}
|
||||
},
|
||||
|
||||
clearMessages: () => {
|
||||
set({ unreadCount: 0, lastMessage: null, activeMatchId: null });
|
||||
},
|
||||
}));
|
||||
if (unreadInMatch <= 0) return;
|
||||
|
||||
// Those messages belonged to a couple that is no longer loaded, and the
|
||||
// match they were in is not in the current list, so subtracting them now
|
||||
// would take the old badge off the new account's.
|
||||
if (get().generation !== myGeneration) return;
|
||||
|
||||
// The server just told us exactly how many it cleared, so subtract instead
|
||||
// of spending a request to re-read the total; the sync summary corrects
|
||||
// any drift on its next pass.
|
||||
set((state) => ({ unreadCount: Math.max(0, state.unreadCount - unreadInMatch) }));
|
||||
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
|
||||
},
|
||||
|
||||
clearMessages: () => {
|
||||
set((state) => ({
|
||||
unreadCount: 0, lastMessage: null, activeMatchId: null,
|
||||
// Invalidates any request already in flight for the account being left.
|
||||
generation: state.generation + 1,
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -4,6 +4,12 @@ import { Events } from "../lib/analytics";
|
||||
import type { QuestionPack, Category } from "@/types";
|
||||
import { useAuthStore } from "./authStore";
|
||||
|
||||
// The catalog load fans out into three requests, so a second caller arriving mid-flight
|
||||
// must wait for the first rather than triple the cost. Keyed to the generation that
|
||||
// started the request: a stale generation's entry must not block the next account's
|
||||
// request for the same key, and its `finally` must not clear a newer generation's guard.
|
||||
const inFlight = new Map<string, number>();
|
||||
|
||||
// Progress data for a pack
|
||||
export interface PackProgressData {
|
||||
totalQuestions: number;
|
||||
@@ -16,10 +22,23 @@ interface PacksState {
|
||||
enabledPackIds: string[];
|
||||
packProgress: Map<string, PackProgressData>;
|
||||
isLoading: boolean;
|
||||
/**
|
||||
* When the catalog, enabled set and progress were last read together. Null means
|
||||
* never — which is not the same as "empty", so a couple with no packs enabled
|
||||
* stops refetching after the first load.
|
||||
*/
|
||||
catalogLoadedAt: number | null;
|
||||
/** Tracked separately because the swipe screen loads the enabled set on its own. */
|
||||
enabledPacksLoadedAt: number | null;
|
||||
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
|
||||
generation: number;
|
||||
fetchPacks: () => Promise<void>;
|
||||
fetchEnabledPacks: () => Promise<void>;
|
||||
fetchPackProgress: () => Promise<void>;
|
||||
ensurePacksLoaded: () => Promise<void>;
|
||||
ensureEnabledPacksLoaded: () => Promise<void>;
|
||||
/** Marks the cached catalog stale without spending a request; the next ensure reloads it. */
|
||||
invalidatePacks: () => void;
|
||||
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
|
||||
clearPacks: () => void;
|
||||
getPackProgress: (packId: string) => PackProgressData | undefined;
|
||||
@@ -31,38 +50,61 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
enabledPackIds: [],
|
||||
packProgress: new Map(),
|
||||
isLoading: false,
|
||||
catalogLoadedAt: null,
|
||||
enabledPacksLoadedAt: null,
|
||||
generation: 0,
|
||||
|
||||
fetchPacks: async () => {
|
||||
const myGeneration = get().generation;
|
||||
if (inFlight.get('catalog') === myGeneration) return;
|
||||
inFlight.set('catalog', myGeneration);
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const catalog = await apiClient.get<{ categories: Category[]; packs: QuestionPack[] }>(
|
||||
'/v1/packs?showAllIntensities=false',
|
||||
);
|
||||
if (get().generation !== myGeneration) return;
|
||||
set({ packs: catalog.packs, categories: catalog.categories });
|
||||
await get().fetchEnabledPacks();
|
||||
await get().fetchPackProgress();
|
||||
if (get().generation !== myGeneration) return;
|
||||
set({ catalogLoadedAt: Date.now() });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
if (inFlight.get('catalog') === myGeneration) inFlight.delete('catalog');
|
||||
if (get().generation === myGeneration) set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
ensurePacksLoaded: async () => {
|
||||
if (get().catalogLoadedAt !== null) return;
|
||||
await get().fetchPacks();
|
||||
},
|
||||
|
||||
invalidatePacks: () => {
|
||||
set({ catalogLoadedAt: null });
|
||||
},
|
||||
|
||||
fetchEnabledPacks: async () => {
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
const myGeneration = get().generation;
|
||||
if (!coupleId) {
|
||||
set({ enabledPackIds: [] });
|
||||
set({ enabledPackIds: [], enabledPacksLoadedAt: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await apiClient.get<{ enabledPackIds: string[] }>("/v1/me/enabled-packs");
|
||||
set({ enabledPackIds: result.enabledPackIds });
|
||||
if (get().generation !== myGeneration) return;
|
||||
set({ enabledPackIds: result.enabledPackIds, enabledPacksLoadedAt: Date.now() });
|
||||
},
|
||||
|
||||
ensureEnabledPacksLoaded: async () => {
|
||||
// Only fetch if not already loaded - lightweight check for swipe screen
|
||||
// Only fetch if not already loaded - lightweight check for swipe screen.
|
||||
// Keyed off the load marker, not the array: "no packs enabled" is a real
|
||||
// answer and must not be retried on every visit.
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
if (!coupleId) return;
|
||||
|
||||
if (get().enabledPackIds.length === 0) {
|
||||
if (get().enabledPacksLoadedAt === null) {
|
||||
await get().fetchEnabledPacks();
|
||||
}
|
||||
},
|
||||
@@ -73,6 +115,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
return { success: false, reason: "no_couple" };
|
||||
}
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const isEnabled = get().enabledPackIds.includes(packId);
|
||||
const newValue = !isEnabled;
|
||||
|
||||
@@ -88,9 +131,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
`/v1/me/enabled-packs/${packId}`,
|
||||
{ enabled: newValue },
|
||||
);
|
||||
// These ids belong to the couple that asked; a clear while the request
|
||||
// was open means they would land in a different couple's selection.
|
||||
if (get().generation !== myGeneration) return { success: false, reason: "stale" };
|
||||
set({ enabledPackIds: result.enabledPackIds });
|
||||
} catch (error) {
|
||||
console.error("Error toggling pack:", error);
|
||||
// The optimistic update this reverts was cleared along with everything
|
||||
// else, so reverting now would only push a stale pack id into the next
|
||||
// couple's list.
|
||||
if (get().generation !== myGeneration) return { success: false, reason: "stale" };
|
||||
set({ enabledPackIds: isEnabled
|
||||
? [...new Set([...get().enabledPackIds, packId])]
|
||||
: get().enabledPackIds.filter(id => id !== packId) });
|
||||
@@ -108,6 +158,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
},
|
||||
|
||||
fetchPackProgress: async () => {
|
||||
const myGeneration = get().generation;
|
||||
if (!useAuthStore.getState().user?.id) {
|
||||
set({ packProgress: new Map() });
|
||||
return;
|
||||
@@ -115,6 +166,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
const result = await apiClient.get<{ progress: Array<PackProgressData & { packId: string }> }>(
|
||||
"/v1/me/pack-progress",
|
||||
);
|
||||
if (get().generation !== myGeneration) return;
|
||||
const progressMap = new Map<string, PackProgressData>();
|
||||
result.progress.forEach(({ packId, totalQuestions, answeredQuestions }) => {
|
||||
progressMap.set(packId, { totalQuestions, answeredQuestions });
|
||||
@@ -128,6 +180,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
},
|
||||
|
||||
clearPacks: () => {
|
||||
set({ enabledPackIds: [], packProgress: new Map() });
|
||||
set((state) => ({
|
||||
enabledPackIds: [],
|
||||
packProgress: new Map(),
|
||||
catalogLoadedAt: null,
|
||||
enabledPacksLoadedAt: null,
|
||||
// Invalidates any request already in flight for the signed-out user.
|
||||
generation: state.generation + 1,
|
||||
}));
|
||||
// See matchStore.clearMatches: the previous user's in-flight catalog read
|
||||
// must not block the next user's.
|
||||
inFlight.clear();
|
||||
},
|
||||
}));
|
||||
@@ -36,16 +36,33 @@ export type DateSortOrder = "newest" | "oldest";
|
||||
|
||||
const BATCH_SIZE = 20;
|
||||
|
||||
// Overlap protection independent of the presentational flags: a refresh over cached
|
||||
// rows raises `isRefreshing`, not `isLoading`, and still must not run twice. Keyed to
|
||||
// the generation that started the request: a stale generation's entry must not block
|
||||
// the next account's request, and its `finally` must not clear a newer generation's guard.
|
||||
const inFlight = new Map<string, number>();
|
||||
|
||||
interface ResponsesState {
|
||||
responses: ResponseWithQuestion[];
|
||||
/** First load only. A refresh over rows already on screen must not blank the list. */
|
||||
isLoading: boolean;
|
||||
/** Refresh over cached rows; drives pull-to-refresh only. */
|
||||
isRefreshing: boolean;
|
||||
isLoadingMore: boolean;
|
||||
groupBy: GroupByOption;
|
||||
dateSortOrder: DateSortOrder;
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
totalCount: number | null;
|
||||
/** Null until the first successful read; an empty history is a loaded answer. */
|
||||
loadedAt: number | null;
|
||||
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
|
||||
generation: number;
|
||||
fetchResponses: (refresh?: boolean) => Promise<void>;
|
||||
/** Loads the first page once; revisiting the screen reuses what is cached. */
|
||||
ensureResponsesLoaded: () => Promise<void>;
|
||||
/** Marks the cache stale without spending a request; the next ensure reloads it. */
|
||||
invalidateResponses: () => void;
|
||||
updateResponse: (
|
||||
questionId: string,
|
||||
newAnswer: AnswerType,
|
||||
@@ -61,12 +78,15 @@ interface ResponsesState {
|
||||
export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
responses: [],
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
isLoadingMore: false,
|
||||
groupBy: "date",
|
||||
dateSortOrder: "newest",
|
||||
hasMore: true,
|
||||
page: 0,
|
||||
totalCount: null,
|
||||
loadedAt: null,
|
||||
generation: 0,
|
||||
|
||||
fetchResponses: async (refresh = false) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
@@ -74,25 +94,31 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
|
||||
if (!userId || !coupleId) return;
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const state = get();
|
||||
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
|
||||
if (inFlight.get('responses') === myGeneration || (state.isLoadingMore && !refresh)) return;
|
||||
|
||||
// Nothing on screen yet is the only case that warrants a full-screen spinner;
|
||||
// every other refresh leaves the cached rows in place until replacements land.
|
||||
const initial = state.loadedAt === null;
|
||||
if (refresh) {
|
||||
set({ isLoading: true, page: 0, hasMore: true });
|
||||
set({ isLoading: initial, isRefreshing: !initial, page: 0, hasMore: true });
|
||||
} else {
|
||||
if (!state.hasMore) return;
|
||||
set({ isLoadingMore: true });
|
||||
}
|
||||
|
||||
inFlight.set('responses', myGeneration);
|
||||
try {
|
||||
const currentPage = refresh ? 0 : state.page;
|
||||
const result = await apiClient.get<{responses: ResponseWithQuestion[];totalCount:number}>(`/v1/me/responses?page=${currentPage}&limit=${BATCH_SIZE}`);
|
||||
if (get().generation !== myGeneration) return;
|
||||
const responses = result.responses;
|
||||
const totalCount = result.totalCount;
|
||||
|
||||
if (!responses || responses.length === 0) {
|
||||
if (refresh) {
|
||||
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
|
||||
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, loadedAt: Date.now() });
|
||||
} else {
|
||||
set({ isLoadingMore: false, hasMore: false });
|
||||
}
|
||||
@@ -100,29 +126,44 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
}
|
||||
|
||||
const transformedResponses = responses;
|
||||
|
||||
|
||||
set((state) => ({
|
||||
responses: refresh
|
||||
? transformedResponses
|
||||
: [...state.responses, ...transformedResponses],
|
||||
totalCount: refresh ? totalCount : state.totalCount,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
isLoadingMore: false,
|
||||
page: currentPage + 1,
|
||||
hasMore: responses.length === BATCH_SIZE
|
||||
hasMore: responses.length === BATCH_SIZE,
|
||||
loadedAt: Date.now(),
|
||||
}));
|
||||
} catch (error) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
console.error("Error in fetchResponses:", error);
|
||||
set({ isLoading: false, isLoadingMore: false });
|
||||
set({ isLoading: false, isRefreshing: false, isLoadingMore: false });
|
||||
} finally {
|
||||
if (inFlight.get('responses') === myGeneration) inFlight.delete('responses');
|
||||
}
|
||||
},
|
||||
|
||||
ensureResponsesLoaded: async () => {
|
||||
if (get().loadedAt !== null) return;
|
||||
await get().fetchResponses(true);
|
||||
},
|
||||
|
||||
invalidateResponses: () => {
|
||||
set({ loadedAt: null });
|
||||
},
|
||||
|
||||
updateResponse: async (
|
||||
questionId: string,
|
||||
newAnswer: AnswerType,
|
||||
confirmDelete = false,
|
||||
responseData?: ResponseData | null
|
||||
): Promise<UpdateResponseResult> => {
|
||||
const myGeneration = get().generation;
|
||||
try {
|
||||
const result = await apiClient.patch<UpdateResponseResult>(`/v1/responses/${questionId}`, {
|
||||
new_answer: newAnswer,
|
||||
@@ -130,6 +171,13 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
response_data: responseData,
|
||||
});
|
||||
|
||||
// Rows are keyed by a question id from the shared catalogue, so once the
|
||||
// store has been cleared that same id can name a different account's
|
||||
// answer. Hand the caller the server's result — it is true of whoever
|
||||
// asked — but write nothing, and refresh no matches, for the account
|
||||
// that is loaded now.
|
||||
if (get().generation !== myGeneration) return result;
|
||||
|
||||
// If update was successful (not just requiring confirmation), update local state
|
||||
if (result.success && !result.requires_confirmation) {
|
||||
// Update local response state
|
||||
@@ -191,7 +239,15 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
},
|
||||
|
||||
clearResponses: () => {
|
||||
set({ responses: [], isLoading: false, groupBy: "date", dateSortOrder: "newest", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
|
||||
set((state) => ({
|
||||
responses: [], isLoading: false, isRefreshing: false, groupBy: "date", dateSortOrder: "newest",
|
||||
page: 0, hasMore: true, isLoadingMore: false, totalCount: null, loadedAt: null,
|
||||
// Invalidates any request already in flight for the signed-out user.
|
||||
generation: state.generation + 1,
|
||||
}));
|
||||
// See matchStore.clearMatches: the previous user's in-flight page must not
|
||||
// block the next user's.
|
||||
inFlight.clear();
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,62 +1,110 @@
|
||||
import { create } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { apiClient } from "../lib/apiClient";
|
||||
import { useAuthStore } from "./authStore";
|
||||
|
||||
/**
|
||||
* Resolved from the signed-in partner's point of view by the API. The stored row is
|
||||
* positional (user1/user2 by member id), which a client cannot act on without
|
||||
* re-deriving that ordering, so the endpoint answers in you/partner terms instead.
|
||||
*/
|
||||
export interface CoupleStreak {
|
||||
couple_id: string;
|
||||
current_streak: number;
|
||||
longest_streak: number;
|
||||
last_active_date: string | null;
|
||||
last_completed_date: string | null;
|
||||
you_answered_today: boolean;
|
||||
partner_answered_today: boolean;
|
||||
partner_name: string | null;
|
||||
/** The couple's shared IANA zone, which is the day boundary the streak is counted in. */
|
||||
timezone: string;
|
||||
streak_celebrated_at: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface StreakState {
|
||||
streak: CoupleStreak | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
fetchStreak: () => Promise<void>;
|
||||
clearStreak: () => void;
|
||||
}
|
||||
|
||||
export const useStreakStore = create<StreakState>((set) => ({
|
||||
streak: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchStreak: async () => {
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
if (!coupleId) {
|
||||
set({ streak: null, isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak");
|
||||
set({ streak, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error("Error fetching streak:", error);
|
||||
set({ error: "Failed to load streak", isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearStreak: () => {
|
||||
set({ streak: null, isLoading: false, error: null });
|
||||
},
|
||||
}));
|
||||
import { useAuthStore } from "./authStore";
|
||||
|
||||
/**
|
||||
* Resolved from the signed-in partner's point of view by the API. The stored row is
|
||||
* positional (user1/user2 by member id), which a client cannot act on without
|
||||
* re-deriving that ordering, so the endpoint answers in you/partner terms instead.
|
||||
*/
|
||||
export interface CoupleStreak {
|
||||
couple_id: string;
|
||||
current_streak: number;
|
||||
longest_streak: number;
|
||||
last_active_date: string | null;
|
||||
last_completed_date: string | null;
|
||||
you_answered_today: boolean;
|
||||
partner_answered_today: boolean;
|
||||
partner_name: string | null;
|
||||
/** The couple's shared IANA zone, which is the day boundary the streak is counted in. */
|
||||
timezone: string;
|
||||
streak_celebrated_at: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface StreakState {
|
||||
streak: CoupleStreak | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
/**
|
||||
* Null until the first successful read. A couple with no streak yet resolves to a
|
||||
* null `streak`, which is a loaded answer rather than a reason to ask again.
|
||||
*/
|
||||
loadedAt: number | null;
|
||||
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
|
||||
generation: number;
|
||||
|
||||
// Actions
|
||||
fetchStreak: () => Promise<void>;
|
||||
/** Loads the streak once; later callers reuse the cached value. */
|
||||
ensureStreakLoaded: () => Promise<void>;
|
||||
/** Silent re-read of an already-loaded streak; leaves the cached value on screen if it fails. */
|
||||
refreshStreak: () => Promise<void>;
|
||||
clearStreak: () => void;
|
||||
}
|
||||
|
||||
// The streak is rendered by a badge, the home screen and the swipe screen, so
|
||||
// concurrent mounts must share one request rather than each issuing their own.
|
||||
// Tagged with the generation that started it: clearing/signing out bumps the
|
||||
// generation, so a stale response can no longer write into the next account's
|
||||
// store, and a stale `finally` cannot clear a newer generation's guard.
|
||||
let inFlight: { generation: number; promise: Promise<void> } | null = null;
|
||||
|
||||
export const useStreakStore = create<StreakState>((set, get) => ({
|
||||
streak: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
loadedAt: null,
|
||||
generation: 0,
|
||||
|
||||
fetchStreak: async () => {
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
const myGeneration = get().generation;
|
||||
if (!coupleId) {
|
||||
set({ streak: null, isLoading: false, loadedAt: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (inFlight && inFlight.generation === myGeneration) return inFlight.promise;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak");
|
||||
if (get().generation !== myGeneration) return;
|
||||
set({ streak, isLoading: false, loadedAt: Date.now() });
|
||||
} catch (error) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
console.error("Error fetching streak:", error);
|
||||
set({ error: "Failed to load streak", isLoading: false });
|
||||
} finally {
|
||||
if (inFlight?.generation === myGeneration) inFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
inFlight = { generation: myGeneration, promise };
|
||||
return promise;
|
||||
},
|
||||
|
||||
ensureStreakLoaded: async () => {
|
||||
if (get().loadedAt !== null) return;
|
||||
await get().fetchStreak();
|
||||
},
|
||||
|
||||
refreshStreak: async () => {
|
||||
// Only silently refresh a streak the UI has actually loaded; an unopened
|
||||
// screen has nothing cached to keep honest.
|
||||
if (get().loadedAt === null) return;
|
||||
await get().fetchStreak();
|
||||
},
|
||||
|
||||
clearStreak: () => {
|
||||
set((state) => ({
|
||||
streak: null, isLoading: false, error: null, loadedAt: null,
|
||||
// Invalidates any request already in flight for the signed-out user.
|
||||
generation: state.generation + 1,
|
||||
}));
|
||||
},
|
||||
}));
|
||||
Reference in new issue
Block a user