diff --git a/apps/web/app/entry.tsx b/apps/web/app/entry.tsx index a29ee38a20..0d452d11c6 100644 --- a/apps/web/app/entry.tsx +++ b/apps/web/app/entry.tsx @@ -8,6 +8,7 @@ import { useAtomValue } from 'jotai'; import dynamic from 'next/dynamic'; import { DebateCoordinator } from '~/core/debates/debate-coordinator'; +import { DebateChallengeStateProvider } from '~/core/debates/matchmaking/debate-challenge-state-provider'; import { DebateMediaSessionProvider } from '~/core/debates/media-session'; import { PlaybackDiagnostics } from '~/core/debates/playback-diagnostics'; import { DebateRecordingUploadCoordinator } from '~/core/debates/recording-upload-coordinator'; @@ -132,72 +133,74 @@ export function App({ children }: { children: React.ReactNode }) { }, [fullscreenActive]); return ( - -
- - - -
{!fullscreenActive && }
-
- setMobileBrowseOpen(true)} - onSearchClick={() => setOpen(true)} - hideLogo={sidebarOpen && !fullscreenActive} - showBrowseButton={!fullscreenActive} - /> - - setOpen(false)} /> -
-
{children}
-
-
- - - - - {/* Client-side rendered due to `window.localStorage` usage */} - - - - - - - - - + + +
- + - - - - - - - - - {/* Suspense: the panel reads `useSearchParams` to tell a debates deep link apart from +
{!fullscreenActive && }
+
+ setMobileBrowseOpen(true)} + onSearchClick={() => setOpen(true)} + hideLogo={sidebarOpen && !fullscreenActive} + showBrowseButton={!fullscreenActive} + /> + + setOpen(false)} /> +
+
{children}
+
+
+ + + + + {/* Client-side rendered due to `window.localStorage` usage */} + + + + + + + + + + + + + + + + + + + + + {/* Suspense: the panel reads `useSearchParams` to tell a debates deep link apart from an ordinary navigation. */} - - - - - - - {process.env.NODE_ENV === 'production' && } -
- - - -
+ + + + + +
+ {process.env.NODE_ENV === 'production' && } +
+ + + +
+ ); } diff --git a/apps/web/core/claims/browse/use-claim-matchup.test.tsx b/apps/web/core/claims/browse/use-claim-matchup.test.tsx new file mode 100644 index 0000000000..bf9f388485 --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-matchup.test.tsx @@ -0,0 +1,73 @@ +import { renderHook } from '@testing-library/react'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PENDING_OUTBOUND_REQUEST_REASON } from '~/core/debates/request-gate'; + +const mocks = vi.hoisted(() => ({ + activityOutboundRequest: null as { id: string } | null, + outboundRequestCreationPending: false, + resolveOutboundChallenge: vi.fn(() => ({ + outboundChallenge: null, + outboundChallengeDirectionUnknown: false, + })), +})); + +vi.mock('~/core/debates/hooks', () => ({ + useDebateActivity: () => ({ data: { available_to_debate: true, outbound_request: mocks.activityOutboundRequest } }), +})); + +vi.mock('~/core/debates/matchmaking/hooks', () => ({ + useMatchmakingMatches: () => ({ data: { matches: [] } }), + useDebateRequests: () => ({ data: { outbound: null } }), + useCreateDebateRequest: () => ({ mutate: vi.fn(), isPending: false, error: null }), +})); + +vi.mock('~/core/debates/matchmaking/use-outbound-debate-challenge', () => ({ + useOutboundDebateChallenge: () => mocks.resolveOutboundChallenge(), +})); + +vi.mock('~/core/debates/matchmaking/debate-challenge-state-provider', () => ({ + useSharedOutboundRequestState: () => ({ + outboundChallenge: null, + outboundChallengeDirectionUnknown: false, + outboundRequestCreationPending: mocks.outboundRequestCreationPending, + }), +})); + +vi.mock('~/core/debates/use-current-geo-chat-user-id', () => ({ + useCurrentGeoChatUserId: () => 'viewer-user', +})); + +const { useClaimMatchup } = await import('./use-claim-matchup'); + +beforeEach(() => { + mocks.activityOutboundRequest = null; + mocks.outboundRequestCreationPending = false; + mocks.resolveOutboundChallenge.mockClear(); +}); + +describe('useClaimMatchup', () => { + it('trusts an authoritative empty request list over stale activity', () => { + mocks.activityOutboundRequest = { id: 'stale-activity-request' }; + + const { result } = renderHook(() => useClaimMatchup({ claimId: 'claim-1', spaceId: 'space-1' })); + + expect(result.current.blockedReason).toBeUndefined(); + }); + + it('blocks while another control is creating an outbound request', () => { + mocks.outboundRequestCreationPending = true; + + const { result } = renderHook(() => useClaimMatchup({ claimId: 'claim-1', spaceId: 'space-1' })); + + expect(result.current.blockedReason).toBe(PENDING_OUTBOUND_REQUEST_REASON); + }); + + it('does not resolve account-level challenge identity and expiry once per claim card', () => { + renderHook(() => useClaimMatchup({ claimId: 'claim-1', spaceId: 'space-1' })); + renderHook(() => useClaimMatchup({ claimId: 'claim-2', spaceId: 'space-1' })); + + expect(mocks.resolveOutboundChallenge).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/core/claims/browse/use-claim-matchup.ts b/apps/web/core/claims/browse/use-claim-matchup.ts index a8925d4d49..96fd2a1121 100644 --- a/apps/web/core/claims/browse/use-claim-matchup.ts +++ b/apps/web/core/claims/browse/use-claim-matchup.ts @@ -2,7 +2,9 @@ import type { DebateClaimPositionSummary } from '~/core/debates/api'; import { useDebateActivity } from '~/core/debates/hooks'; +import { useSharedOutboundRequestState } from '~/core/debates/matchmaking/debate-challenge-state-provider'; import { useCreateDebateRequest, useDebateRequests, useMatchmakingMatches } from '~/core/debates/matchmaking/hooks'; +import { PENDING_OUTBOUND_REQUEST_REASON, resolveOutboundRequest } from '~/core/debates/request-gate'; import { ID } from '~/core/id'; /** @@ -29,6 +31,8 @@ export function useClaimMatchup({ const matchesQuery = useMatchmakingMatches(enabled); const requestsQuery = useDebateRequests(enabled); const { data: activity } = useDebateActivity(enabled); + const { outboundChallenge, outboundChallengeDirectionUnknown, outboundRequestCreationPending } = + useSharedOutboundRequestState(); const createRequest = useCreateDebateRequest(); // `enabled: false` only stops this query from *fetching*. React Query still hands back whatever @@ -41,13 +45,13 @@ export function useClaimMatchup({ candidate => ID.equals(candidate.claim.claim_entity_id, claimId) && ID.equals(candidate.claim.space_id, spaceId) ) ?? null); - const outbound = requestsQuery.data?.outbound ?? activity?.outbound_request ?? null; + const outbound = resolveOutboundRequest(requestsQuery.data, activity); // Only when the server actually says so — a missing field must not block requesting. const unavailable = activity?.available_to_debate === false; const blockedReason = unavailable ? 'Switch yourself to available to send a request.' - : outbound - ? 'Withdraw your open request to send another.' + : outbound || outboundChallenge || outboundChallengeDirectionUnknown || outboundRequestCreationPending + ? PENDING_OUTBOUND_REQUEST_REASON : undefined; return { diff --git a/apps/web/core/debates/api.ts b/apps/web/core/debates/api.ts index 685efe5102..49b66af981 100644 --- a/apps/web/core/debates/api.ts +++ b/apps/web/core/debates/api.ts @@ -235,6 +235,13 @@ export type DebateActivity = { debate: Debate | null; rematch: DebateRematchSession | null; challenge: DebateChallenge | null; + /** + * Client-retained copy of a challenge the viewer sent while `challenge` still reports an inbound + * one. The activity endpoint currently exposes only one challenge even though both can coexist. + */ + outbound_challenge?: DebateChallenge | null; + /** Local monotonic instant when `outbound_challenge` was cached; never supplied by geo-chat. */ + outbound_challenge_cached_at_monotonic_ms?: number | null; /** * The single debate request the viewer currently has awaiting a response. Optional until * geo-chat ships `debate_matchmaking_v1`. diff --git a/apps/web/core/debates/debate-coordinator.test.tsx b/apps/web/core/debates/debate-coordinator.test.tsx index 71bee1bedb..7934d7b7c9 100644 --- a/apps/web/core/debates/debate-coordinator.test.tsx +++ b/apps/web/core/debates/debate-coordinator.test.tsx @@ -462,6 +462,26 @@ describe('DebateCoordinator', () => { expect(mocks.acceptRequestMutate).not.toHaveBeenCalled(); }); + it('does not forget "Not now" when the request list disappears during an outbound transition', async () => { + mocks.activity = { ...idleActivity(), incoming_request_count: 1 }; + const request = incomingRequest(); + mocks.requests = { outbound: null, incoming: [request] }; + + const view = render(); + expect(await screen.findByText('Debate request')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Not now' })); + await waitFor(() => expect(screen.queryByText('Debate request')).not.toBeInTheDocument()); + + // Creating an outbound challenge can disable/refetch this list for a render. The original + // inbound request is still pending when the list comes back and must remain snoozed. + mocks.requests = { outbound: null, incoming: [] }; + view.rerender(); + mocks.requests = { outbound: null, incoming: [request] }; + view.rerender(); + + await waitFor(() => expect(screen.queryByText('Debate request')).not.toBeInTheDocument()); + }); + // A claimless challenge interrupts the person who has to answer it, and nobody else. The sender // has no decision to make, so their copy lives under Sent in the hub's Requests tab. it('prompts the recipient of a claimless challenge', async () => { @@ -474,6 +494,54 @@ describe('DebateCoordinator', () => { expect(screen.getByRole('button', { name: 'Explore claims' })).toBeInTheDocument(); }); + it('prompts an inbound person request while retaining a separate outbound person request', async () => { + mocks.currentUserId = 'user-recipient'; + const inbound = pendingChallenge(); + mocks.activity = { + ...idleActivity(), + challenge: inbound, + outbound_challenge: { + ...inbound, + id: 'challenge-outbound', + requester: inbound.recipient, + recipient: inbound.requester, + }, + }; + + render(); + + expect(await screen.findByText('Debate request')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Explore claims' })).toBeInTheDocument(); + }); + + it('does not reopen a snoozed challenge when activity switches between inbound and outbound', async () => { + mocks.currentUserId = 'user-recipient'; + const inbound = pendingChallenge(); + mocks.activity = { ...idleActivity(), challenge: inbound }; + + const view = render(); + expect(await screen.findByText('Debate request')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Not now' })); + await waitFor(() => expect(screen.queryByText('Debate request')).not.toBeInTheDocument()); + + // The activity endpoint can expose only one of two simultaneous challenges. Cycling through + // the outbound one must not forget that this inbound id was already snoozed. + mocks.activity = { + ...idleActivity(), + challenge: { + ...inbound, + id: 'challenge-outbound', + requester: inbound.recipient, + recipient: inbound.requester, + }, + }; + view.rerender(); + mocks.activity = { ...idleActivity(), challenge: inbound }; + view.rerender(); + + await waitFor(() => expect(screen.queryByText('Debate request')).not.toBeInTheDocument()); + }); + it('does not interrupt the sender of a challenge while it waits to be answered', async () => { mocks.currentUserId = 'user-requester'; mocks.activity = { ...idleActivity(), challenge: pendingChallenge() }; @@ -484,6 +552,24 @@ describe('DebateCoordinator', () => { expect(screen.queryByText(/Waiting for .* to accept/)).not.toBeInTheDocument(); }); + it('still prompts for an incoming claim request while an outbound person request is pending', async () => { + mocks.currentUserId = 'user-requester'; + const outboundChallenge = pendingChallenge(); + mocks.activity = { + ...idleActivity(), + challenge: outboundChallenge, + outbound_challenge: outboundChallenge, + incoming_request_count: 1, + }; + mocks.requests = { outbound: null, incoming: [incomingRequest()] }; + + render(); + + expect(await screen.findByText('Debate request')).toBeInTheDocument(); + expect(screen.getByText('Debates should hand off without flashing the page')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Not now' })).toBeInTheDocument(); + }); + // The stored geo-chat session is what names the viewer, and it isn't always written yet. An // absent id used to read as "not the recipient", which left the person the challenge was *for* // with no popup at all — the bug this hook exists to close. diff --git a/apps/web/core/debates/debate-coordinator.tsx b/apps/web/core/debates/debate-coordinator.tsx index 23c2c8b371..f2d979cd8e 100644 --- a/apps/web/core/debates/debate-coordinator.tsx +++ b/apps/web/core/debates/debate-coordinator.tsx @@ -179,39 +179,34 @@ export function DebateCoordinator() { const [retainedSharePrompt, setRetainedSharePrompt] = React.useState(null); const [closedSharePromptId, setClosedSharePromptId] = React.useState(null); - // Only fetch the request list once activity says one exists, so idle sessions stay quiet. "Not - // now" snoozes a request for this session; it stays in the hub's Requests tab either way. + // The claimless challenge gets the same treatment: "Not now" only closes the popup, and the + // challenge keeps its place in the hub's Requests tab until it is answered or expires. + const [snoozedChallengeIds, setSnoozedChallengeIds] = React.useState([]); + const promptedChallenge = challenge && !snoozedChallengeIds.includes(challenge.id) ? challenge : null; + // Only the recipient is prompted. `challenge` itself stays live for everyone so active debate + // and share-prompt coordination still knows a person request is outstanding. + const isChallengeRecipient = promptedChallenge?.recipient.user_id === currentUserId; + + // Only fetch the request list once activity says one exists, so idle sessions stay quiet. An + // outbound person challenge is waiting state, not an active modal flow: incoming claim requests + // still need to interrupt the viewer while it is pending. A received person challenge gets first + // choice until it is answered or snoozed, which prevents two request dialogs stacking. const hasIncomingRequests = (activity?.incoming_request_count ?? 0) > 0; - const requestsQuery = useDebateRequests(hasIncomingRequests && !activeFlow); + const requestsQuery = useDebateRequests(hasIncomingRequests && !debate && !activity?.rematch); const [snoozedRequestIds, setSnoozedRequestIds] = React.useState([]); // Expired requests are dropped here too, so the popup can never prompt for a dead request while // waiting on the server's `debate.requests_changed` event. const incomingRequests = useUnexpiredRequests(requestsQuery.data?.incoming ?? []); const promptedRequest = - activeFlow || !currentUserId + debate || activity?.rematch || isChallengeRecipient || !currentUserId ? null : (incomingRequests.find(request => request.status === 'pending' && !snoozedRequestIds.includes(request.id)) ?? null); - React.useEffect(() => { - const liveIds = new Set(incomingRequests.map(request => request.id)); - setSnoozedRequestIds(current => { - const next = current.filter(id => liveIds.has(id)); - return next.length === current.length ? current : next; - }); - }, [incomingRequests]); - - // The claimless challenge gets the same treatment: "Not now" only closes the popup, and the - // challenge keeps its place in the hub's Requests tab until it is answered or expires. - const [snoozedChallengeId, setSnoozedChallengeId] = React.useState(null); - const promptedChallenge = challenge && challenge.id !== snoozedChallengeId ? challenge : null; - // Only the recipient is prompted. `challenge` itself stays live for everyone, since `activeFlow` - // above reads it to keep other popups from stacking on top of an outstanding challenge. - const isChallengeRecipient = promptedChallenge?.recipient.user_id === currentUserId; - - React.useEffect(() => { - if (snoozedChallengeId && challenge?.id !== snoozedChallengeId) setSnoozedChallengeId(null); - }, [challenge, snoozedChallengeId]); + // Keep snoozed ids for this coordinator's lifetime. A request-list refresh can briefly have no + // data while an outbound challenge is being created; pruning against that empty transition made + // the same inbound popup reopen as soon as the viewer requested somebody else. Request ids are + // unique, short-lived values, so retaining answered/expired ids for one browser session is safe. // Everything waiting on this viewer's answer, for the tone and the tab title (GEO-3026). Snoozes // are ignored on purpose: a snoozed request was already seen, and each id alerts only once. @@ -442,7 +437,11 @@ export function DebateCoordinator() { error={challengeError instanceof Error ? challengeError.message : null} onAccept={() => acceptChallenge.mutate(promptedChallenge.id)} onReject={() => rejectChallenge.mutate(promptedChallenge.id)} - onNotNow={() => setSnoozedChallengeId(promptedChallenge.id)} + onNotNow={() => + setSnoozedChallengeIds(current => + current.includes(promptedChallenge.id) ? current : [...current, promptedChallenge.id] + ) + } /> )} {promptedRequest && currentUserId && ( diff --git a/apps/web/core/debates/hooks-query-network.test.tsx b/apps/web/core/debates/hooks-query-network.test.tsx index c552a183aa..6ab14483b6 100644 --- a/apps/web/core/debates/hooks-query-network.test.tsx +++ b/apps/web/core/debates/hooks-query-network.test.tsx @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GeoChatRequestError } from './api'; import { @@ -16,6 +16,7 @@ import { useDebateTranscript, useRematchLiveKitJoin, useSpaceDebates, + useUpdateDebateAvailability, } from './hooks'; type QueryOptions = { queryKey: readonly unknown[] }; @@ -29,15 +30,22 @@ const mocks = vi.hoisted(() => ({ queryCache: { subscribe: vi.fn(() => vi.fn()) }, queryClient: { getQueryCache: vi.fn(() => mocks.queryCache), + getQueryData: vi.fn(), invalidateQueries: vi.fn(), setQueryData: vi.fn(), }, + getDebateActivity: vi.fn(), queryRefetch: vi.fn(), useMutation: vi.fn((options: unknown) => options), useQuery: vi.fn((options: unknown) => ({ options, refetch: mocks.queryRefetch })), useScope: vi.fn(), })); +vi.mock('./api', async importOriginal => ({ + ...(await importOriginal()), + getDebateActivity: mocks.getDebateActivity, +})); + vi.mock('@geogenesis/auth', () => ({ usePrivy: () => ({ ready: true, authenticated: mocks.authenticated, user: { id: 'user-a' } }), })); @@ -91,6 +99,7 @@ beforeEach(() => { mocks.present = true; mocks.gatewayPaused = false; mocks.queryClient.invalidateQueries.mockClear(); + mocks.queryClient.getQueryData.mockReset(); mocks.queryClient.getQueryCache.mockClear(); mocks.queryCache.subscribe.mockClear(); mocks.queryClient.setQueryData.mockClear(); @@ -98,6 +107,12 @@ beforeEach(() => { mocks.useMutation.mockClear(); mocks.useQuery.mockClear(); mocks.useScope.mockClear(); + mocks.getDebateActivity.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); }); describe('debate query network ownership', () => { @@ -207,6 +222,52 @@ describe('debate query network ownership', () => { expect(mocks.queryRefetch).toHaveBeenCalledTimes(1); }); + it('preserves a retained outbound challenge when availability success replaces wire activity', () => { + const { result } = renderHook(() => useUpdateDebateAvailability()); + const mutation = result.current as unknown as { + onSuccess(activity: Record): void; + }; + const outbound = { id: 'challenge-outbound' }; + const wireActivity = { online: true, available_to_debate: false, challenge: null }; + const current = { + ...wireActivity, + outbound_challenge: outbound, + outbound_challenge_cached_at_monotonic_ms: 12_345, + }; + + mutation.onSuccess(wireActivity); + + const write = mocks.queryClient.setQueryData.mock.calls.at(-1)?.[1] as + Record | ((cached: Record) => Record); + const next = typeof write === 'function' ? write(current) : write; + expect(next).toEqual(current); + }); + + it('preserves a challenge created while a failed availability update was in flight', () => { + const { result } = renderHook(() => useUpdateDebateAvailability()); + const mutation = result.current as unknown as { + onError(error: Error, availableToDebate: boolean, context: { previous: Record }): void; + }; + const previous = { online: true, available_to_debate: true, challenge: null }; + const current = { + ...previous, + available_to_debate: false, + outbound_challenge: { id: 'challenge-outbound' }, + outbound_challenge_cached_at_monotonic_ms: 12_345, + }; + + mutation.onError(new Error('nope'), false, { previous }); + + const write = mocks.queryClient.setQueryData.mock.calls.at(-1)?.[1] as + Record | ((cached: Record) => Record); + const next = typeof write === 'function' ? write(current) : write; + expect(next).toEqual({ + ...previous, + outbound_challenge: current.outbound_challenge, + outbound_challenge_cached_at_monotonic_ms: 12_345, + }); + }); + it('invalidates the challenged profile after an availability rejection', () => { const { result } = renderHook(() => useCreateDebateChallenge()); const mutation = result.current as unknown as { @@ -222,6 +283,212 @@ describe('debate query network ownership', () => { }); }); + it('registers person challenge creation in the account outbound-request gate', () => { + const { result } = renderHook(() => useCreateDebateChallenge()); + + expect(result.current).toMatchObject({ + mutationKey: ['debates', 'account', 'user-a', 'create-outbound-request'], + }); + }); + + it('keeps a newly created outbound challenge cached instead of refetching stale activity over it', () => { + vi.useFakeTimers(); + vi.advanceTimersByTime(12_345); + const { result } = renderHook(() => useCreateDebateChallenge()); + const mutation = result.current as unknown as { + onSuccess(challenge: { id: string }): void; + }; + const challenge = { id: 'challenge-1' }; + + mutation.onSuccess(challenge); + + expect(mocks.queryClient.setQueryData).toHaveBeenCalledWith( + ['debates', 'account', 'user-a', 'activity'], + expect.any(Function) + ); + const update = mocks.queryClient.setQueryData.mock.calls.at(-1)?.[1] as ( + current: Record | undefined + ) => unknown; + const warmActivity = { + online: true, + available_to_debate: true, + cooldown_until: null, + match: null, + debate: null, + rematch: null, + challenge: null, + incoming_request_count: 2, + }; + expect(update(warmActivity)).toEqual({ + ...warmActivity, + challenge, + outbound_challenge: challenge, + outbound_challenge_cached_at_monotonic_ms: 12_345, + }); + expect(update(undefined)).toEqual({ + online: true, + available_to_debate: true, + cooldown_until: null, + match: null, + debate: null, + rematch: null, + challenge, + outbound_challenge: challenge, + outbound_challenge_cached_at_monotonic_ms: 12_345, + }); + expect(mocks.queryClient.invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: ['debates', 'account', 'user-a', 'activity'], + }); + }); + + it('reconciles activity when the outbound propagation grace expires', async () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useCreateDebateChallenge()); + const mutation = result.current as unknown as { + onSuccess(challenge: { id: string }): void; + }; + + mutation.onSuccess({ id: 'challenge-1' }); + + await vi.advanceTimersByTimeAsync(9_999); + expect(mocks.queryClient.invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: ['debates', 'account', 'user-a', 'activity'], + }); + + await vi.advanceTimersByTimeAsync(1); + expect(mocks.queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['debates', 'account', 'user-a', 'activity'], + }); + }); + + it('retains an outbound challenge while activity reports a simultaneous inbound challenge', async () => { + const outbound = { + id: 'challenge-outbound', + status: 'pending', + expires_at: '2099-01-01T00:00:00.000Z', + }; + const inbound = { id: 'challenge-inbound', status: 'pending', expires_at: '2099-01-01T00:00:00.000Z' }; + const activity = { challenge: inbound, debate: null, rematch: null }; + mocks.queryClient.getQueryData.mockReturnValue({ outbound_challenge: outbound }); + mocks.getDebateActivity.mockResolvedValue(activity); + renderHook(() => useDebateActivity()); + + const query = mocks.useQuery.mock.calls.at(-1)?.[0] as { + queryFn: (context: { signal: AbortSignal }) => Promise>; + }; + const result = await query.queryFn({ signal: new AbortController().signal }); + + expect(result).toEqual({ ...activity, outbound_challenge: outbound }); + }); + + it('drops a retained outbound challenge once activity reports no live challenge', async () => { + const outbound = { + id: 'challenge-outbound', + status: 'pending', + expires_at: '2099-01-01T00:00:00.000Z', + }; + const activity = { challenge: null, debate: null, rematch: null }; + mocks.queryClient.getQueryData.mockReturnValue({ outbound_challenge: outbound }); + mocks.getDebateActivity.mockResolvedValue(activity); + renderHook(() => useDebateActivity()); + + const query = mocks.useQuery.mock.calls.at(-1)?.[0] as { + queryFn: (context: { signal: AbortSignal }) => Promise>; + }; + const result = await query.queryFn({ signal: new AbortController().signal }); + + expect(result).toEqual(activity); + }); + + it('briefly retains a newly created outbound challenge while activity propagation catches up', async () => { + vi.spyOn(performance, 'now').mockReturnValue(9_999); + const outbound = { + id: 'challenge-outbound', + status: 'pending', + created_at: new Date().toISOString(), + expires_at: '2099-01-01T00:00:00.000Z', + }; + const activity = { challenge: null, debate: null, rematch: null }; + mocks.queryClient.getQueryData.mockReturnValue({ + outbound_challenge: outbound, + outbound_challenge_cached_at_monotonic_ms: 0, + }); + mocks.getDebateActivity.mockResolvedValue(activity); + renderHook(() => useDebateActivity()); + + const query = mocks.useQuery.mock.calls.at(-1)?.[0] as { + queryFn: (context: { signal: AbortSignal }) => Promise>; + }; + const result = await query.queryFn({ signal: new AbortController().signal }); + + expect(result).toEqual({ + ...activity, + outbound_challenge: outbound, + outbound_challenge_cached_at_monotonic_ms: 0, + }); + }); + + it('ends the propagation grace on monotonic time when the device clock trails the server', async () => { + vi.spyOn(performance, 'now').mockReturnValue(20_001); + const outbound = { + id: 'challenge-outbound', + status: 'pending', + // Five minutes ahead of this device. Wall-clock subtraction would keep the ten-second grace + // open for more than five minutes even though eleven monotonic seconds have elapsed. + created_at: new Date(Date.now() + 5 * 60_000).toISOString(), + expires_at: '2099-01-01T00:00:00.000Z', + }; + const activity = { challenge: null, debate: null, rematch: null }; + mocks.queryClient.getQueryData.mockReturnValue({ + outbound_challenge: outbound, + outbound_challenge_cached_at_monotonic_ms: 10_000, + }); + mocks.getDebateActivity.mockResolvedValue(activity); + renderHook(() => useDebateActivity()); + + const query = mocks.useQuery.mock.calls.at(-1)?.[0] as { + queryFn: (context: { signal: AbortSignal }) => Promise>; + }; + const result = await query.queryFn({ signal: new AbortController().signal }); + + expect(result).toEqual(activity); + }); + + it('leaves server-timestamp expiry to the synchronized request filter', async () => { + const serverNow = Date.now(); + vi.spyOn(Date, 'now').mockReturnValue(serverNow + 60 * 60_000); + vi.spyOn(performance, 'now').mockReturnValue(1_000); + const outbound = { + id: 'challenge-outbound', + status: 'pending', + created_at: new Date(serverNow).toISOString(), + expires_at: new Date(serverNow + 60_000).toISOString(), + }; + const inbound = { + id: 'challenge-inbound', + status: 'pending', + expires_at: new Date(serverNow + 60_000).toISOString(), + }; + const activity = { challenge: inbound, debate: null, rematch: null }; + mocks.queryClient.getQueryData.mockReturnValue({ + outbound_challenge: outbound, + outbound_challenge_cached_at_monotonic_ms: 0, + }); + mocks.getDebateActivity.mockResolvedValue(activity); + renderHook(() => useDebateActivity()); + + const query = mocks.useQuery.mock.calls.at(-1)?.[0] as { + queryFn: (context: { signal: AbortSignal }) => Promise>; + }; + const result = await query.queryFn({ signal: new AbortController().signal }); + + expect(result).toEqual({ + ...activity, + outbound_challenge: outbound, + outbound_challenge_cached_at_monotonic_ms: 0, + }); + }); + // The rematch voice token is the one query whose cache policy is load-bearing rather than a // freshness preference: `` cannot be handed a new token while it is mounted, so the // token has to be stable for as long as the room lives — and gone the moment the dock stops using diff --git a/apps/web/core/debates/hooks.ts b/apps/web/core/debates/hooks.ts index d10c694fa0..47d8968748 100644 --- a/apps/web/core/debates/hooks.ts +++ b/apps/web/core/debates/hooks.ts @@ -90,6 +90,7 @@ import { refreshRematchClaimBatches, rematchClaimBatchesWithClaim, } from './rematch-claims-query-key'; +import { outboundRequestCreationMutationKey } from './request-gate'; import { type SpaceDebateSupport, useSpaceDebateSupport } from './space-debate-support'; import { withQueryData } from './with-query-data'; @@ -469,6 +470,31 @@ const REMATCH_POLL_MS = 5_000; const ACTIVITY_POLL_MS = 30_000; /** And while the gateway is paused, when this is the only thing still asking. */ const ACTIVITY_DEGRADED_POLL_MS = 10_000; +/** Let an immediate push-triggered refetch observe the create response before treating null as final. */ +const OUTBOUND_CHALLENGE_PROPAGATION_GRACE_MS = 10_000; +/** Complete, conservative shape for successful mutations that land before the initial activity read. */ +const ACTIVITY_CACHE_FALLBACK = { + online: true, + available_to_debate: true, + cooldown_until: null, + match: null, + debate: null, + rematch: null, + challenge: null, +} satisfies DebateActivity; + +/** Overlay client-only activity state onto a wire result or mutation rollback. */ +function preserveClientActivityState( + activity: DebateActivity | undefined, + current: DebateActivity | undefined +): DebateActivity | undefined { + if (!activity || !current) return activity; + return { + ...activity, + outbound_challenge: current.outbound_challenge, + outbound_challenge_cached_at_monotonic_ms: current.outbound_challenge_cached_at_monotonic_ms, + }; +} /** * The viewer's own debate state: the debate or rematch they are in, and the counts that gate the @@ -502,6 +528,7 @@ const ACTIVITY_DEGRADED_POLL_MS = 10_000; export function useDebateActivity(enabled = true) { const queryClient = useQueryClient(); const { accountKey, authenticated, getPrivyIdentityToken } = useGeoChatAuth(); + const activityKey = debateQueryKeys.activity(accountKey); const attentive = useDebateAttention(); const present = useDebateVisibility(); const { paused } = useDebateGatewaySnapshot(); @@ -511,7 +538,7 @@ export function useDebateActivity(enabled = true) { const query = useQuery({ ...debateQueryNetworkOptions, - queryKey: debateQueryKeys.activity(accountKey), + queryKey: activityKey, queryFn: async ({ signal }) => { const activity = await getDebateActivity(getPrivyIdentityToken, accountKey, signal); if (activity.debate) { @@ -520,7 +547,42 @@ export function useDebateActivity(enabled = true) { if (activity.rematch) { queryClient.setQueryData(debateQueryKeys.rematch(accountKey, activity.rematch.id), activity.rematch); } - return activity; + // The wire shape can name only one challenge. After an inbound challenge and an outbound one + // coexist, it keeps returning the inbound row until that row expires; without this overlay a + // refetch hides the outbound card and makes the People tab offer another request. Retain the + // create response until it expires or activity advances into a debate/rematch. + const cachedActivity = queryClient.getQueryData(activityKey); + const retainedOutbound = cachedActivity?.outbound_challenge; + const retainedOutboundCachedAt = cachedActivity?.outbound_challenge_cached_at_monotonic_ms; + const outboundIsPending = retainedOutbound?.status === 'pending'; + // The create response can beat the activity read triggered by the gateway event. Keep its + // outbound row through that brief local propagation window, then treat a null/non-pending + // server challenge as authoritative. Request expiry stays in `useUnexpiredRequests`, whose + // synchronized server clock avoids comparing server timestamps against a skewed device. + // A different pending inbound challenge must keep the overlay: the wire shape has no outbound + // id/status with which to distinguish a still-pending request from one rejected remotely, and + // dropping it would erase valid simultaneous outbound requests. + const serverChallengeIsPending = activity.challenge?.status === 'pending'; + const hasRetainedOutboundCachedAt = + typeof retainedOutboundCachedAt === 'number' && Number.isFinite(retainedOutboundCachedAt); + const outboundPropagationElapsed = hasRetainedOutboundCachedAt + ? performance.now() - retainedOutboundCachedAt + : Number.POSITIVE_INFINITY; + const outboundIsPropagating = + outboundPropagationElapsed >= 0 && outboundPropagationElapsed < OUTBOUND_CHALLENGE_PROPAGATION_GRACE_MS; + if ( + !outboundIsPending || + (!serverChallengeIsPending && !outboundIsPropagating) || + activity.debate || + activity.rematch + ) { + return activity; + } + return { + ...activity, + outbound_challenge: retainedOutbound, + ...(hasRetainedOutboundCachedAt ? { outbound_challenge_cached_at_monotonic_ms: retainedOutboundCachedAt } : {}), + }; }, enabled: queryEnabled, // Hidden tabs still don't poll: they have no popup to draw, and browsers throttle their timers @@ -548,10 +610,19 @@ export function useDebateActivity(enabled = true) { // the usual case, so this costs nothing until there is something to draw. See // `participant-avatars`. const activityPeople = React.useMemo(() => { - const { challenge, outbound_request: outbound, debate, rematch } = query.data ?? {}; + const { + challenge, + outbound_challenge: outboundChallenge, + outbound_request: outbound, + debate, + rematch, + } = query.data ?? {}; return [ ...(challenge ? [challenge.requester, challenge.recipient] : []), + ...(outboundChallenge && outboundChallenge.id !== challenge?.id + ? [outboundChallenge.requester, outboundChallenge.recipient] + : []), // The tabs fall back to `outbound_request` while `useDebateRequests` is still loading, and // draw both parties from it in `OutboundRequestCard`. ...(outbound ? [outbound.requester, outbound.recipient] : []), @@ -568,14 +639,21 @@ export function useDebateActivity(enabled = true) { const data = React.useMemo(() => { if (!query.data) return query.data; - const { challenge, outbound_request: outbound, debate, rematch } = query.data; - if (!challenge && !outbound && !debate && !rematch) return query.data; + const { + challenge, + outbound_challenge: outboundChallenge, + outbound_request: outbound, + debate, + rematch, + } = query.data; + if (!challenge && !outboundChallenge && !outbound && !debate && !rematch) return query.data; // `match` is deliberately left alone: nothing has populated it since GEO-2514 and nothing here // reads it, so resolving faces for it would be work for a field that is always null. return { ...query.data, challenge: challenge ? withParties(challenge, withAvatar) : challenge, + outbound_challenge: outboundChallenge ? withParties(outboundChallenge, withAvatar) : outboundChallenge, outbound_request: outbound ? withParties(outbound, withAvatar) : outbound, debate: debate ? withRowParticipantAvatars(debate, withAvatar) : debate, rematch: rematch ? withRowParticipantAvatars(rematch, withAvatar) : rematch, @@ -684,10 +762,12 @@ export function useUpdateDebateAvailability() { return { previous }; }, onError: (_error, _availableToDebate, context) => { - queryClient.setQueryData(activityKey, context?.previous); + queryClient.setQueryData(activityKey, current => + preserveClientActivityState(context?.previous, current) + ); }, onSuccess: activity => { - queryClient.setQueryData(activityKey, activity); + queryClient.setQueryData(activityKey, current => preserveClientActivityState(activity, current)); }, onSettled: () => { void invalidateDebatesOutsideRematchClaims(queryClient); @@ -931,9 +1011,8 @@ export function useConsentToDebateRematch(debateId: string) { // `incoming_request_count` and `outbound_request`, which zeroed the navbar badge and stopped // the coordinator fetching requests until the invalidation below landed. queryClient.setQueryData(debateQueryKeys.activity(accountKey), current => ({ + ...ACTIVITY_CACHE_FALLBACK, ...current, - online: current?.online ?? true, - available_to_debate: current?.available_to_debate ?? true, cooldown_until: null, match: null, debate: null, @@ -1214,13 +1293,30 @@ export function useCreateDebateChallenge() { const { accountKey, getPrivyIdentityToken } = useGeoChatAuth(); return useMutation({ + mutationKey: outboundRequestCreationMutationKey(accountKey), mutationFn: (request: { recipient_profile_space_id: string }) => createDebateChallenge(request, getPrivyIdentityToken, accountKey), onSuccess: challenge => { - queryClient.setQueryData(debateQueryKeys.activity(accountKey), current => - current ? { ...current, challenge } : current - ); - void queryClient.invalidateQueries({ queryKey: debateQueryKeys.activity(accountKey) }); + const cachedAtMonotonicMs = performance.now(); + queryClient.setQueryData(debateQueryKeys.activity(accountKey), current => ({ + // A successful create is enough to seed the activity cache when its initial read has not + // landed yet. These are the same safe idle defaults used by the rematch transition above; + // spreading a warm cache keeps every viewer-relative field the endpoint already supplied. + ...ACTIVITY_CACHE_FALLBACK, + ...current, + // Keep an inbound challenge in the wire field so its popup and Received card survive. + challenge: current?.challenge ?? challenge, + outbound_challenge: challenge, + outbound_challenge_cached_at_monotonic_ms: cachedAtMonotonicMs, + })); + // The create response is the newest authoritative copy of this challenge. Refetching activity + // immediately can still return the pre-create row and erase it from the cache, which removes + // the outbound card and re-enables every request button until geo-chat catches up. Keep this + // response through that window, then force one reconciliation at the boundary; the gateway + // and normal activity poll own subsequent changes. + setTimeout(() => { + void queryClient.invalidateQueries({ queryKey: debateQueryKeys.activity(accountKey) }); + }, OUTBOUND_CHALLENGE_PROPAGATION_GRACE_MS); }, onError: (error, request) => { if (!(error instanceof GeoChatRequestError) || error.code !== 'challenge_unavailable') return; @@ -1241,6 +1337,16 @@ export function useAcceptDebateChallenge() { if (result.session) { queryClient.setQueryData(debateQueryKeys.rematch(accountKey, result.session.id), result.session); } + // Accepting an inbound challenge cancels the viewer's outbound one on the server. + queryClient.setQueryData(debateQueryKeys.activity(accountKey), current => + current + ? { + ...current, + outbound_challenge: null, + outbound_challenge_cached_at_monotonic_ms: null, + } + : current + ); void queryClient.invalidateQueries({ queryKey: debateQueryKeys.activity(accountKey) }); }, }); @@ -1252,9 +1358,19 @@ export function useRejectDebateChallenge() { return useMutation({ mutationFn: (challengeId: string) => rejectDebateChallenge(challengeId, getPrivyIdentityToken, accountKey), - onSuccess: () => { + onSuccess: (_result, challengeId) => { queryClient.setQueryData(debateQueryKeys.activity(accountKey), current => - current ? { ...current, challenge: null } : current + current + ? { + ...current, + challenge: current.challenge?.id === challengeId ? null : current.challenge, + outbound_challenge: current.outbound_challenge?.id === challengeId ? null : current.outbound_challenge, + outbound_challenge_cached_at_monotonic_ms: + current.outbound_challenge?.id === challengeId + ? null + : current.outbound_challenge_cached_at_monotonic_ms, + } + : current ); void queryClient.invalidateQueries({ queryKey: debateQueryKeys.activity(accountKey) }); }, diff --git a/apps/web/core/debates/matchmaking/claims-tab.test.tsx b/apps/web/core/debates/matchmaking/claims-tab.test.tsx index a51bb56b85..a5edbc1fd0 100644 --- a/apps/web/core/debates/matchmaking/claims-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/claims-tab.test.tsx @@ -7,7 +7,7 @@ import type { ReactElement } from 'react'; import { Provider, createStore } from 'jotai'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { MatchmakingClaim } from '../api'; +import type { DebateChallenge, MatchmakingClaim } from '../api'; import { ClaimsTab } from './claims-tab'; import { AUTO_PAGES_WITHOUT_ROWS } from './use-bounded-paging'; import { debatesHubExploreSpaceIdsAtom } from '~/atoms'; @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ promptSignIn: vi.fn(), /** Privy's answer; the tab's signed-out paths hang off it. */ authenticated: true, + outboundChallenge: null as DebateChallenge | null, claims: [] as MatchmakingClaim[], /** Which tag each enabled render of the graph hook asked for, in order. */ tagsAskedFor: [] as string[], @@ -273,7 +274,18 @@ vi.mock('../hooks', () => ({ }, useGeoChatAuth: () => ({ ready: true, authenticated: mocks.authenticated, accountKey: mocks.accountKey }), // Read by the end slot's match lookup; the tab's tests do not exercise availability. - useDebateActivity: () => ({ data: null, isLoading: false, error: null }), + useDebateActivity: () => ({ + data: { + challenge: null, + outbound_challenge: mocks.outboundChallenge, + outbound_request: null, + available_to_debate: true, + }, + isLoading: false, + error: null, + }), + useAcceptDebateChallenge: () => ({ mutate: vi.fn(), isPending: false, error: null }), + useRejectDebateChallenge: () => ({ mutate: vi.fn(), isPending: false, error: null }), // Featured rows are hydrated by the per-space debate-claims lookup. Records what it was asked // for so the suites can assert the tab only asks about spaces it may show. useDebateClaimsBySpaces: (groups: Array<{ spaceId: string; claimIds: string[] }>) => { @@ -310,6 +322,10 @@ vi.mock('../hooks', () => ({ }, })); +vi.mock('../use-current-geo-chat-user-id', () => ({ + useCurrentGeoChatUserId: () => 'user-me', +})); + /** * The tag query, which now pages and filters on the server (GEO-2798). * @@ -513,6 +529,24 @@ function render(ui: ReactElement, sharedStore?: ReturnType) const SPACE_ID = '019fedae-72b6-7ab2-927a-df044d57c566'; const OTHER_SPACE_ID = '019fedae-72b6-7ab2-927a-df044d57c599'; +function challenge(): DebateChallenge { + return { + id: 'challenge-1', + status: 'pending', + source_space_id: SPACE_ID, + requester: { user_id: 'user-me', profile_space_id: 'profile-user-me', display_name: 'You', avatar_cid: null }, + recipient: { + user_id: 'user-them', + profile_space_id: 'profile-user-them', + display_name: 'Arturas', + avatar_cid: null, + }, + rematch_session_id: null, + created_at: '2026-08-05T11:00:00.000Z', + expires_at: '2099-01-01T00:00:00.000Z', + }; +} + /** What `BrowseSidebar` has already loaded by the time the debates panel opens. */ function sidebarData() { return { @@ -586,6 +620,7 @@ const THEIRS = '019fedb2-1d52-7a4f-8b22-3d8e6f9c5520'; beforeEach(() => { // Not a mock fn, so `resetAllMocks` does not restore it. mocks.authenticated = true; + mocks.outboundChallenge = null; mocks.accountKey = 'account-1' as string | null; mocks.taggedRowsError = false; mocks.unreadableSpaceIds = []; @@ -666,6 +701,17 @@ beforeEach(() => { afterEach(cleanup); describe('ClaimsTab', () => { + it('shows a sent person request above Explore claims', () => { + mocks.outboundChallenge = challenge(); + render(); + + expect(screen.getByText('Awaiting response')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel request' })).toBeInTheDocument(); + expect(screen.getByLabelText('Search claims').closest('.sticky')).toContainElement( + screen.getByText('Awaiting response') + ); + }); + // GEO-2684. The list pages forever, so controls left in the scrolling body meant scrolling back // to the start to change a filter. jsdom has no layout, so what's assertable is that they sit in // a pinned container rather than in the body that scrolls. diff --git a/apps/web/core/debates/matchmaking/claims-tab.tsx b/apps/web/core/debates/matchmaking/claims-tab.tsx index 3940f0630e..69cf4cbcf2 100644 --- a/apps/web/core/debates/matchmaking/claims-tab.tsx +++ b/apps/web/core/debates/matchmaking/claims-tab.tsx @@ -26,6 +26,7 @@ import type { } from '../api'; import { eligibleClaimSpaceIds, isClaimSpaceAllowed } from '../claim-space-allowlist'; import { useDebateActivity, useDebateClaimsBySpaces, useGeoChatAuth } from '../hooks'; +import { resolveOutboundRequest } from '../request-gate'; import { type TaggedClaim, type TaggedClaimFilters, @@ -35,7 +36,9 @@ import { useTaggedTopicFacet, } from '../tagged-claims'; import { useClaimSpaceAllowlist } from '../use-claim-space-allowlist'; +import { useCurrentGeoChatUserId } from '../use-current-geo-chat-user-id'; import { isSpaceDebatePublishable, useDebatePublishableSpaces } from '../use-debate-publishable-spaces'; +import { DebateChallengeCard } from './challenge-card'; import { claimRowKey } from './claim-row-key'; import { type AnsweredState, useCollapseAnswered } from './collapse-answered'; import { DebateHoursNote } from './debate-hours-note'; @@ -52,6 +55,7 @@ import { keepSelectableTopics, orderFacetOptions, toggleId } from './topic-facet import { useBoundedPaging } from './use-bounded-paging'; import { useDebouncedSearch } from './use-debounced-search'; import { useDebouncedSelection } from './use-debounced-selection'; +import { useOutboundDebateChallenge } from './use-outbound-debate-challenge'; import { useScopedMatchmakingClaims } from './use-scoped-claims'; import { useSpaceFilterMenu } from './use-space-filter-selection'; import { useStableListOrder } from './use-stable-list-order'; @@ -216,7 +220,9 @@ export function ClaimsTab({ // a signed-out visitor has no request to have sent. const requestsQuery = useDebateRequests(authenticated); const { data: activity } = useDebateActivity(authenticated); - const outbound = requestsQuery.data?.outbound ?? activity?.outbound_request ?? null; + const outbound = resolveOutboundRequest(requestsQuery.data, activity); + const currentUserId = useCurrentGeoChatUserId(); + const { outboundChallenge } = useOutboundDebateChallenge(activity, currentUserId); const [search, setSearch] = useAtom(atoms.search); const { value: debouncedSearch, pending: searchSettling } = useDebouncedSearch(search); @@ -977,6 +983,7 @@ export function ClaimsTab({ and the only evidence was on another tab. It rides inside the sticky block rather than above it because two stickies would both claim `top-0` and overlap, and this one is conditional so the filters could not be offset by a known height. */} + {outboundChallenge ? : null} {outbound ? : null} (EMPTY_OUTBOUND_REQUEST_STATE); + +/** + * Resolves viewer identity and challenge expiry once for account-level gates rendered across lists. + * Individual challenge cards may still own their countdown labels; request buttons only need these + * two booleans and must not start an identity effect and expiry timer for every claim row. + */ +export function DebateChallengeStateProvider({ children }: { children: React.ReactNode }) { + const { data: activity } = useDebateActivity(); + const currentUserId = useCurrentGeoChatUserId(); + const { outboundChallenge, outboundChallengeDirectionUnknown } = useDebateChallengeState(activity, currentUserId); + const outboundRequestCreationPending = useOutboundRequestCreationPending(); + const value = React.useMemo( + () => ({ + outboundChallenge, + outboundChallengeDirectionUnknown, + outboundRequestCreationPending, + }), + [outboundChallenge, outboundChallengeDirectionUnknown, outboundRequestCreationPending] + ); + + return {children}; +} + +export function useSharedOutboundRequestState(): SharedOutboundRequestState { + return React.useContext(DebateChallengeStateContext); +} diff --git a/apps/web/core/debates/matchmaking/hooks.test.tsx b/apps/web/core/debates/matchmaking/hooks.test.tsx index 4a57aae776..ccb46e2cc7 100644 --- a/apps/web/core/debates/matchmaking/hooks.test.tsx +++ b/apps/web/core/debates/matchmaking/hooks.test.tsx @@ -7,11 +7,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type Debate, GeoChatRequestError, GeoChatSessionError } from '../api'; import { clearEnteringDebate, useEnteringDebateId } from '../debate-entry-intent'; -import { useAcceptDebateRequest, useDebatePeople, useMatchmakingMatches } from './hooks'; +import { useAcceptDebateRequest, useCreateDebateRequest, useDebatePeople, useMatchmakingMatches } from './hooks'; const mocks = vi.hoisted(() => ({ push: vi.fn(), acceptDebateRequest: vi.fn(), + createDebateRequest: vi.fn(), listMatchmakingMatches: vi.fn(), listDebatePeople: vi.fn(), accountKey: 'user-a' as string | null, @@ -26,6 +27,7 @@ vi.mock('../api', async importOriginal => { return { ...actual, acceptDebateRequest: mocks.acceptDebateRequest, + createDebateRequest: mocks.createDebateRequest, listMatchmakingMatches: mocks.listMatchmakingMatches, listDebatePeople: mocks.listDebatePeople, }; @@ -60,6 +62,7 @@ function wrapper({ children }: { children: ReactNode }) { beforeEach(() => { mocks.push.mockReset(); mocks.acceptDebateRequest.mockReset(); + mocks.createDebateRequest.mockReset(); mocks.listMatchmakingMatches.mockReset(); mocks.listDebatePeople.mockReset(); mocks.accountKey = 'user-a'; @@ -101,6 +104,51 @@ describe('useAcceptDebateRequest', () => { }); }); +describe('outbound request creation gate', () => { + it('registers claim request creation in the same account-scoped gate', async () => { + mocks.createDebateRequest.mockReturnValue(new Promise(() => undefined)); + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + const gateWrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useCreateDebateRequest(), { wrapper: gateWrapper }); + + result.current.mutate({ space_id: 'space-1', claim_entity_id: 'claim-1' }); + + await waitFor(() => + expect( + queryClient.isMutating({ + mutationKey: ['debates', 'account', 'user-a', 'create-outbound-request'], + exact: true, + }) + ).toBe(1) + ); + }); + + it('keeps the shared gate closed until a successful claim request has reconciled', async () => { + mocks.createDebateRequest.mockResolvedValue({ id: 'request-1' }); + let finishReconciliation!: () => void; + const reconciliation = new Promise(resolve => { + finishReconciliation = resolve; + }); + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockReturnValue(reconciliation); + const gateWrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useCreateDebateRequest(), { wrapper: gateWrapper }); + const mutationKey = ['debates', 'account', 'user-a', 'create-outbound-request'] as const; + + result.current.mutate({ space_id: 'space-1', claim_entity_id: 'claim-1' }); + + await waitFor(() => expect(invalidateQueries).toHaveBeenCalled()); + expect(queryClient.isMutating({ mutationKey, exact: true })).toBe(1); + + finishReconciliation(); + await waitFor(() => expect(queryClient.isMutating({ mutationKey, exact: true })).toBe(0)); + }); +}); + /** * A viewer's own reads all fail together for an account geo-chat has not finished registering, and * they all come good a moment later. Reported from a fresh sign-up: the hub sat in "Something went diff --git a/apps/web/core/debates/matchmaking/hooks.ts b/apps/web/core/debates/matchmaking/hooks.ts index 4228e36094..408de4df10 100644 --- a/apps/web/core/debates/matchmaking/hooks.ts +++ b/apps/web/core/debates/matchmaking/hooks.ts @@ -40,6 +40,7 @@ import { invalidateDebatesOutsideRematchClaims, useGeoChatAuth, } from '../hooks'; +import { outboundRequestCreationMutationKey } from '../request-gate'; const MATCHMAKING_CLAIMS_PAGE_SIZE = 20; @@ -331,8 +332,9 @@ export function useCreateDebateRequest() { const { accountKey, getPrivyIdentityToken } = useGeoChatAuth(); return useMutation({ + mutationKey: outboundRequestCreationMutationKey(accountKey), mutationFn: (request: CreateDebateRequestBody) => createDebateRequest(request, getPrivyIdentityToken, accountKey), - onSuccess: () => void invalidateDebatesOutsideRematchClaims(queryClient), + onSuccess: () => invalidateDebatesOutsideRematchClaims(queryClient), }); } diff --git a/apps/web/core/debates/matchmaking/hub-states.tsx b/apps/web/core/debates/matchmaking/hub-states.tsx index 1cd15880f3..dbf7b64656 100644 --- a/apps/web/core/debates/matchmaking/hub-states.tsx +++ b/apps/web/core/debates/matchmaking/hub-states.tsx @@ -99,6 +99,8 @@ type HubQueryStateProps = { * watches a skeleton for all of it. */ failureReason?: unknown; + /** Query-independent content to keep above loading/error states; normal content supplies its own copy. */ + fallbackContent?: React.ReactNode; children: React.ReactNode; }; @@ -114,6 +116,7 @@ export function HubQueryState({ onRetry, signInAction, failureReason, + fallbackContent, children, }: HubQueryStateProps) { const needsSignIn = Boolean(signInAction) && isSignInRequired(error); @@ -144,68 +147,78 @@ export function HubQueryState({ ? 'empty' : 'content'; + const stateContent = + state === 'sign-in' ? ( + + {signInAction!.label} + + } + > + {signInAction!.message} + + ) : state === 'warming-up' ? ( + // Deliberately not "Something went wrong", which is wrong about something going right, and + // not the sign-in prompt, which is wrong at somebody who just did. + // + // The button appears only once the retries are spent. While they are still running, + // `refetch()` joins the in-flight retry rather than starting a request, so the button would + // have been a control that visibly does nothing — worse than no control, because a reader + // who presses it and sees no change concludes the page is broken rather than busy. Until + // then the message is the whole state, and the reads are getting on with it. + + Try again + + ) : null + } + > + Setting up your account. Check back in a minute. + + ) : state === 'error' ? ( + + Try again + + ) + } + > + {isMatchmakingUnavailable(error) ? "Matchmaking isn't available yet." : 'Something went wrong.'} + + ) : state === 'loading' ? ( + + ) : state === 'empty' ? ( + + {emptyAction.label} + + ) : null + } + > + {emptyMessage} + + ) : ( + children + ); + return ( - {state === 'sign-in' ? ( - - {signInAction!.label} - - } - > - {signInAction!.message} - - ) : state === 'warming-up' ? ( - // Deliberately not "Something went wrong", which is wrong about something going right, and - // not the sign-in prompt, which is wrong at somebody who just did. - // - // The button appears only once the retries are spent. While they are still running, - // `refetch()` joins the in-flight retry rather than starting a request, so the button would - // have been a control that visibly does nothing — worse than no control, because a reader - // who presses it and sees no change concludes the page is broken rather than busy. Until - // then the message is the whole state, and the reads are getting on with it. - - Try again - - ) : null - } - > - Setting up your account. Check back in a minute. - - ) : state === 'error' ? ( - - Try again - - ) - } - > - {isMatchmakingUnavailable(error) ? "Matchmaking isn't available yet." : 'Something went wrong.'} - - ) : state === 'loading' ? ( - - ) : state === 'empty' ? ( - - {emptyAction.label} - - ) : null - } - > - {emptyMessage} - + {state !== 'content' && fallbackContent ? ( +
+ {fallbackContent} + {stateContent} +
) : ( - children + stateContent )}
); diff --git a/apps/web/core/debates/matchmaking/matches-list.test.tsx b/apps/web/core/debates/matchmaking/matches-list.test.tsx index b89009c2e7..1efad43878 100644 --- a/apps/web/core/debates/matchmaking/matches-list.test.tsx +++ b/apps/web/core/debates/matchmaking/matches-list.test.tsx @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TOPICS_PROPERTY_ID } from '~/core/claims/ontology'; -import type { MatchmakingMatch } from '../api'; +import type { DebateChallenge, MatchmakingMatch } from '../api'; import { MatchesList } from './matches-list'; import { debatesHubLobbySearchAtom, @@ -22,6 +22,7 @@ import { const mocks = vi.hoisted(() => ({ matches: [] as MatchmakingMatch[], outbound: null as unknown, + outboundChallenge: null as DebateChallenge | null, createRequestMutate: vi.fn(), submitResponse: vi.fn(), indexing: { status: 'idle', pending: null, runId: null } as { @@ -69,7 +70,12 @@ vi.mock('../hooks', () => ({ data: mocks.activityLoading || mocks.activityErrored ? undefined - : { outbound_request: null, available_to_debate: mocks.availableToDebate }, + : { + challenge: null, + outbound_challenge: mocks.outboundChallenge, + outbound_request: null, + available_to_debate: mocks.availableToDebate, + }, isLoading: mocks.activityLoading, }), // Mirrors the real key factory: `vi.mock` replaces the whole module, so every query key read @@ -81,6 +87,12 @@ vi.mock('../hooks', () => ({ rematchRoot: (accountKey: string | null) => ['debates', 'account', accountKey, 'rematch'] as const, }, useGeoChatAuth: () => ({ ready: true, authenticated: true, accountKey: 'account-1' }), + useAcceptDebateChallenge: () => ({ mutate: vi.fn(), isPending: false, error: null }), + useRejectDebateChallenge: () => ({ mutate: vi.fn(), isPending: false, error: null }), +})); + +vi.mock('../use-current-geo-chat-user-id', () => ({ + useCurrentGeoChatUserId: () => 'user-me', })); vi.mock('./hooks', () => ({ @@ -90,6 +102,14 @@ vi.mock('./hooks', () => ({ useWithdrawDebateRequest: () => ({ mutate: vi.fn(), isPending: false, error: null }), })); +vi.mock('./debate-challenge-state-provider', () => ({ + useSharedOutboundRequestState: () => ({ + outboundChallenge: mocks.outboundChallenge, + outboundChallengeDirectionUnknown: false, + outboundRequestCreationPending: false, + }), +})); + // The publish path itself is covered by the entity-response tests; here it only needs to record // what the card asked for. vi.mock('~/core/hooks/use-entity-vote', () => ({ @@ -178,6 +198,24 @@ function party(userId: string, displayName: string, position: boolean, positionL }; } +function challenge(): DebateChallenge { + return { + id: 'challenge-1', + status: 'pending', + source_space_id: SPACE_ID, + requester: { user_id: 'user-me', profile_space_id: 'profile-user-me', display_name: 'You', avatar_cid: null }, + recipient: { + user_id: 'user-them', + profile_space_id: 'profile-user-them', + display_name: 'Arturas', + avatar_cid: null, + }, + rematch_session_id: null, + created_at: '2026-08-05T11:00:00.000Z', + expires_at: '2099-01-01T00:00:00.000Z', + }; +} + function topicRelation(id: string, name: string) { return { type: { id: TOPICS_PROPERTY_ID }, toEntity: { id, name } }; } @@ -208,6 +246,7 @@ function match(overrides: Partial = {}): MatchmakingMatch { beforeEach(() => { mocks.matches = [match()]; mocks.outbound = null; + mocks.outboundChallenge = null; mocks.createRequestMutate.mockReset(); mocks.submitResponse.mockReset(); mocks.indexing = { status: 'idle', pending: null, runId: null }; @@ -372,6 +411,23 @@ describe('MatchesList', () => { expect(screen.getByRole('button', { name: 'Request debate' })).toBeDisabled(); }); + it('shows a sent person request in Lobby and explains why claim requests are blocked', async () => { + mocks.outboundChallenge = challenge(); + render(); + + expect(screen.getByText('Awaiting response')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel request' })).toBeInTheDocument(); + const request = screen.getByRole('button', { name: 'Request debate' }); + expect(request).toBeDisabled(); + expect(request.parentElement).toHaveAttribute('title', 'You can only have one pending outbound request at a time.'); + + await userEvent.hover(request.parentElement!); + + expect(await screen.findByRole('tooltip')).toHaveTextContent( + 'You can only have one pending outbound request at a time.' + ); + }); + // GEO-2684. The outbound card was already pinned; the filters joined it rather than becoming a // second sticky, because two would both claim `top-0` and overlap — and the card is conditional, // so the filters could not be offset by a known height either. The shared helper's own test only diff --git a/apps/web/core/debates/matchmaking/matches-list.tsx b/apps/web/core/debates/matchmaking/matches-list.tsx index 2e427077f3..ff5e95c303 100644 --- a/apps/web/core/debates/matchmaking/matches-list.tsx +++ b/apps/web/core/debates/matchmaking/matches-list.tsx @@ -9,6 +9,9 @@ import { Input } from '~/design-system/input'; import type { MatchmakingMatch } from '../api'; import { useClaimEntitiesByIds } from '../claim-picker-page'; import { useDebateActivity } from '../hooks'; +import { resolveOutboundRequest } from '../request-gate'; +import { useCurrentGeoChatUserId } from '../use-current-geo-chat-user-id'; +import { DebateChallengeCard } from './challenge-card'; import { claimRowKey } from './claim-row-key'; import { HubStickyControls, SpaceTopicFilters } from './claims-tab'; import { DebateHoursNote } from './debate-hours-note'; @@ -27,6 +30,7 @@ import { topicsFor, } from './topic-facets'; import { useDebouncedSearch } from './use-debounced-search'; +import { useOutboundDebateChallenge } from './use-outbound-debate-challenge'; import { useSpaceFilterMenu } from './use-space-filter-selection'; import { useStableListOrder } from './use-stable-list-order'; import { @@ -85,7 +89,9 @@ export function MatchesList({ const activity = activityQuery.data; const serverMatches = React.useMemo(() => matchesQuery.data?.matches ?? [], [matchesQuery.data]); - const outbound = requestsQuery.data?.outbound ?? activity?.outbound_request ?? null; + const outbound = resolveOutboundRequest(requestsQuery.data, activity); + const currentUserId = useCurrentGeoChatUserId(); + const { outboundChallenge } = useOutboundDebateChallenge(activity, currentUserId); // Same hold as Explore's list: standing down from one claim shouldn't reshuffle the rest. const matches = useStableListOrder(serverMatches, claimRowKey, spaceIds.join(',')); @@ -231,6 +237,7 @@ export function MatchesList({ both claim `top-0` and overlap, and the outbound card is conditional so the filters couldn't be offset by a known height. */} + {outboundChallenge ? : null} {outbound ? : null} ({ peopleError: null as Error | null, peopleRefetch: vi.fn(), challenge: null as DebateChallenge | null, + outboundChallenge: null as DebateChallenge | null, outboundRequest: null as unknown, + requestsDataAvailable: true, activeDebate: null as unknown, currentUserId: 'user-me' as string | null, personalSpaceId: '019fedae-72b6-7ab2-927a-df044d57c500' as string | null, @@ -37,6 +39,9 @@ const mocks = vi.hoisted(() => ({ claimEntitiesLoading: false, claimEntitiesError: null as Error | null, createChallenge: vi.fn(), + createPending: false, + createRecipientProfileSpaceId: null as string | null, + outboundRequestCreationPending: false, onTabChange: vi.fn(), cancelChallenge: vi.fn(), cancelPending: false, @@ -94,9 +99,21 @@ vi.mock('../hooks', () => ({ usePeerSchedule: (peerUserId: string | null) => mocks.usePeerSchedule(peerUserId), useGeoChatAuth: () => ({ authenticated: mocks.authenticated, ready: true, accountKey: 'user-a' }), useDebateActivity: () => ({ - data: { challenge: mocks.challenge, outbound_request: mocks.outboundRequest, debate: mocks.activeDebate }, + data: { + challenge: mocks.challenge, + outbound_challenge: mocks.outboundChallenge, + outbound_request: mocks.outboundRequest, + debate: mocks.activeDebate, + }, + }), + useCreateDebateChallenge: () => ({ + mutate: mocks.createChallenge, + isPending: mocks.createPending, + variables: mocks.createRecipientProfileSpaceId + ? { recipient_profile_space_id: mocks.createRecipientProfileSpaceId } + : undefined, + error: null, }), - useCreateDebateChallenge: () => ({ mutate: mocks.createChallenge, isPending: false, error: null }), useAcceptDebateChallenge: () => ({ mutate: vi.fn(), isPending: false, error: null }), useRejectDebateChallenge: () => ({ mutate: mocks.cancelChallenge, @@ -113,7 +130,11 @@ vi.mock('./hooks', () => ({ failureReason: mocks.peopleError, refetch: mocks.peopleRefetch, }), - useDebateRequests: () => ({ data: { incoming: [], outbound: null }, isLoading: false, error: null }), + useDebateRequests: () => ({ + data: mocks.requestsDataAvailable ? { incoming: [], outbound: null } : undefined, + isLoading: false, + error: null, + }), })); // The record is fetched once for the whole list through react-query; these tests render the tab @@ -156,6 +177,23 @@ vi.mock('../use-current-geo-chat-user-id', () => ({ useCurrentGeoChatUserId: () => mocks.currentUserId, })); +vi.mock('./debate-challenge-state-provider', () => ({ + useSharedOutboundRequestState: () => { + const challenge = + mocks.outboundChallenge ?? (mocks.challenge?.requester.user_id === mocks.currentUserId ? mocks.challenge : null); + const expiresAt = challenge ? new Date(challenge.expires_at).getTime() : Number.NaN; + const isUnexpired = !Number.isFinite(expiresAt) || expiresAt > Date.now(); + + return { + outboundChallenge: challenge && isUnexpired ? challenge : null, + outboundChallengeDirectionUnknown: Boolean( + mocks.challenge && isUnexpired && !mocks.outboundChallenge && !mocks.currentUserId + ), + outboundRequestCreationPending: mocks.outboundRequestCreationPending, + }; + }, +})); + vi.mock('~/core/hooks/use-personal-space-id', () => ({ usePersonalSpaceId: () => ({ personalSpaceId: mocks.personalSpaceId, isLoading: false }), })); @@ -284,7 +322,9 @@ beforeEach(() => { mocks.peopleError = null; mocks.peopleRefetch.mockReset(); mocks.challenge = null; + mocks.outboundChallenge = null; mocks.outboundRequest = null; + mocks.requestsDataAvailable = true; mocks.activeDebate = null; mocks.currentUserId = 'user-me'; mocks.personalSpaceId = '019fedae-72b6-7ab2-927a-df044d57c500'; @@ -296,6 +336,9 @@ beforeEach(() => { mocks.claimEntitiesLoading = false; mocks.claimEntitiesError = null; mocks.createChallenge.mockReset(); + mocks.createPending = false; + mocks.createRecipientProfileSpaceId = null; + mocks.outboundRequestCreationPending = false; mocks.onTabChange.mockReset(); mocks.cancelChallenge.mockReset(); mocks.cancelPending = false; @@ -704,17 +747,58 @@ describe('PeopleTab', () => { for (const button of screen.getAllByRole('button', { name: 'Request debate' })) { expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', 'You can only have one pending outbound request at a time.'); } }); - // `activity.challenge` is whichever challenge involves the viewer. Being challenged is not a - // request you sent, so it keeps the sentence rather than claiming you're waiting on a reply. - it('keeps the sentence when the challenge is one you received', () => { + // GEO-3027. An inbound request remains available in Requests, but it is outside the viewer's + // control and must not take away their ability to send one of their own. + it('allows a new request while an inbound challenge is pending', () => { mocks.challenge = challenge('recipient'); render(); expect(card()).not.toBeInTheDocument(); - expect(screen.getByText(awaitingText)).toBeInTheDocument(); + expect(screen.queryByText(awaitingText)).not.toBeInTheDocument(); + + const button = screen.getAllByRole('button', { name: 'Request debate' })[0]; + expect(button).toBeEnabled(); + fireEvent.click(button); + + expect(mocks.createChallenge).toHaveBeenCalledWith({ + recipient_profile_space_id: PROFILE_SPACE_IDS['user-them'], + }); + }); + + it('disables every request while the shared outbound mutation is in flight', () => { + mocks.createPending = true; + mocks.createRecipientProfileSpaceId = PROFILE_SPACE_IDS['user-them']; + render(); + + expect(screen.getByRole('button', { name: 'Requesting…' })).toBeDisabled(); + for (const button of screen.getAllByRole('button', { name: 'Request debate' })) { + expect(button).toBeDisabled(); + } + }); + + it('disables every person request while another control creates an outbound request', () => { + mocks.outboundRequestCreationPending = true; + render(); + + for (const button of screen.getAllByRole('button', { name: 'Request debate' })) { + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', 'You can only have one pending outbound request at a time.'); + } + }); + + it('shows and gates on the retained outbound challenge while an inbound one remains pending', () => { + mocks.challenge = challenge('recipient'); + mocks.outboundChallenge = { ...challenge('requester'), id: 'challenge-outbound' }; + render(); + + expect(within(card()!).getByText('Awaiting response')).toBeInTheDocument(); + for (const button of screen.getAllByRole('button', { name: 'Request debate' })) { + expect(button).toBeDisabled(); + } }); // Without an id there is no way to tell the two directions apart, and showing a "you sent this" @@ -796,12 +880,11 @@ describe('PeopleTab', () => { it('leaves the other blocked reasons alone', () => { mocks.outboundRequest = { id: 'request-1' }; + mocks.requestsDataAvailable = false; render(); expect(card()).not.toBeInTheDocument(); - expect( - screen.getByText('You already have an open request — withdraw it to challenge someone else.') - ).toBeInTheDocument(); + expect(screen.getByText('You can only have one pending outbound request at a time.')).toBeInTheDocument(); }); // GEO-2725. Signed out the button is the entry to signing in, so it stays live and opens Privy diff --git a/apps/web/core/debates/matchmaking/people-tab.tsx b/apps/web/core/debates/matchmaking/people-tab.tsx index 66288da95e..d13e57fe05 100644 --- a/apps/web/core/debates/matchmaking/people-tab.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.tsx @@ -28,10 +28,12 @@ import { useClaimEntitiesByIds } from '../claim-picker-page'; import { useCreateDebateChallenge, useDebateActivity, useGeoChatAuth } from '../hooks'; import { useParticipantPositions } from '../participant-positions'; import { speakerLabel } from '../playback-utils'; +import { PENDING_OUTBOUND_REQUEST_REASON, resolveOutboundRequest } from '../request-gate'; import { useCurrentGeoChatUserId } from '../use-current-geo-chat-user-id'; import { isSpaceDebatePublishable, useDebatePublishableSpaces } from '../use-debate-publishable-spaces'; import { DebateChallengeCard } from './challenge-card'; import { HubStickyControls, SpaceTopicFilters } from './claims-tab'; +import { useSharedOutboundRequestState } from './debate-challenge-state-provider'; import { DebateHoursNote } from './debate-hours-note'; import { type ClaimMatch, analyzeMatchingClaims } from './disagreement-counts'; import { useDebatePeople, useDebateRequests } from './hooks'; @@ -43,7 +45,6 @@ import { PersonRecordLine } from './person-record-line'; import { isPersonId } from './person-records-document'; import { PersonSpaceIcons } from './person-space-icons'; import { usePersonRecords } from './use-person-records'; -import { useUnexpiredRequests } from './use-request-countdown'; import { useSpaceFilterMenu } from './use-space-filter-selection'; import { type DebatesHubTab, debatesHubPeopleSpaceIdsAtom } from '~/atoms'; @@ -77,8 +78,17 @@ export function PeopleTab({ onTabChange }: { onTabChange: (tab: DebatesHubTab) = // `authenticated` rather than `true` keeps them from firing a request that can only 401. const { data: activity } = useDebateActivity(authenticated); const { data: requests } = useDebateRequests(authenticated); + const outboundRequest = resolveOutboundRequest(requests, activity); const currentUserId = useCurrentGeoChatUserId(); const { personalSpaceId } = usePersonalSpaceId(); + // One mutation for the whole list. A mutation per row only disables the row that was clicked, + // leaving every other person requestable while the same outbound request is still in flight. + const createChallenge = useCreateDebateChallenge(); + const { + outboundChallenge, + outboundChallengeDirectionUnknown: challengeDirectionUnknown, + outboundRequestCreationPending, + } = useSharedOutboundRequestState(); // One elevated portal for every row's menu. A portal per person would append a matching number // of containers to the body, while a plain Radix portal sits behind this z-200 panel. const popoverPortal = useElevatedPopoverPortal(); @@ -281,38 +291,25 @@ export function PeopleTab({ onTabChange }: { onTabChange: (tab: DebatesHubTab) = // one of the two things holding the list down. const searchIsTheOnlyFilter = Boolean(search.trim()) && effectiveSpaceIds.length === 0; - const reportedChallenge = activity?.challenge?.status === 'pending' ? activity.challenge : null; - // A challenge stays `pending` in the activity payload until the server says otherwise, so its own - // expiry has to be applied here — the same filter every other request surface derives from, so - // none of them disagree about a dead request while waiting for `debate.requests_changed`. Without - // it this tab would sit on an "Expired" card with every Debate button still dead underneath it. - const liveChallenges = useUnexpiredRequests( - React.useMemo(() => (reportedChallenge ? [reportedChallenge] : []), [reportedChallenge]) - ); - const pendingChallenge = liveChallenges[0] ?? null; - // `activity.challenge` is whichever challenge involves the viewer, in either direction. The card - // is about a request you sent, so it only stands in for the message when you are the one waiting - // on a reply — being challenged blocks the buttons just the same, but the sentence is what - // explains that. - const outboundChallenge = - pendingChallenge && currentUserId && pendingChallenge.requester.user_id === currentUserId ? pendingChallenge : null; - + // `activity.challenge` is whichever challenge involves the viewer, in either direction. The + // shared resolver keeps every matchmaking surface on the same outbound gate and applies expiry + // even while the activity payload still reports the challenge as pending. // Every Debate button greys out at once when the viewer already has something open, so say why // rather than leaving a list of dead buttons. The card says it for an outbound challenge, so the // sentence would only repeat it. - const blockedReason = pendingChallenge - ? outboundChallenge - ? null - : 'You have a debate request awaiting a reply.' + const blockedReason = challengeDirectionUnknown + ? 'You have a debate request awaiting a reply.' : activeDebate(activity) ? "You're already in a debate." - : activity?.outbound_request || requests?.outbound - ? 'You already have an open request — withdraw it to challenge someone else.' + : outboundRequest || outboundRequestCreationPending + ? PENDING_OUTBOUND_REQUEST_REASON : null; // Kept separate from `blockedReason`: the card replaces the sentence but not the reason every // button below is disabled. - const buttonsDisabled = Boolean(blockedReason) || Boolean(outboundChallenge); + const buttonsDisabled = Boolean(blockedReason) || Boolean(outboundChallenge) || createChallenge.isPending; + const disabledReason = + blockedReason ?? (createChallenge.isPending ? 'Sending your debate request…' : PENDING_OUTBOUND_REQUEST_REASON); return (
@@ -428,7 +425,12 @@ export function PeopleTab({ onTabChange }: { onTabChange: (tab: DebatesHubTab) = labelsById={labelsById} popoverPortal={popoverPortal} disabled={buttonsDisabled} - disabledReason={blockedReason ?? 'You have a debate request awaiting a reply.'} + disabledReason={disabledReason} + onRequest={() => createChallenge.mutate({ recipient_profile_space_id: person.profile_space_id })} + requestPending={ + createChallenge.isPending && + createChallenge.variables?.recipient_profile_space_id === person.profile_space_id + } onRequireSignIn={onRequireSignIn} onSeeTimes={ bookingEnabled @@ -480,6 +482,8 @@ function PersonRow({ popoverPortal, disabled, disabledReason, + onRequest, + requestPending, onRequireSignIn, onSeeTimes, }: { @@ -501,6 +505,9 @@ function PersonRow({ disabled: boolean; /** Only surfaced on hover, so it explains the greyed-out button without repeating the card. */ disabledReason: string; + onRequest: () => void; + /** Only the row whose shared mutation is running carries the progress label. */ + requestPending: boolean; /** * Set only when signed out. Pressing Debate then opens Privy instead of sending a request, which * would fail at the token exchange with an error the viewer can do nothing about. @@ -509,7 +516,6 @@ function PersonRow({ /** Absent while the feature flag is off, which is what hides "See times". */ onSeeTimes?: (peer: { userId: string; name: string }, opener: HTMLElement | null) => void; }) { - const createChallenge = useCreateDebateChallenge(); const profileHref = validateSpaceId(person.profile_space_id) ? NavUtils.toSpace(person.profile_space_id) : null; const activeSpaces = spaceIds.length > 0 ? ( @@ -609,17 +615,13 @@ function PersonRow({ )} - onRequireSignIn - ? onRequireSignIn() - : createChallenge.mutate({ recipient_profile_space_id: person.profile_space_id }) - } + onClick={() => (onRequireSignIn ? onRequireSignIn() : onRequest())} // `in_debate` holds signed out too: it means this person is in an active debate right now, // which is true of them rather than of any viewer, so signing in would not make them // available. `can_challenge` and the viewer's own pending request are the viewer-relative // ones, and those are what the press bypasses on its way to the sign-in. disabled={person.in_debate || (!onRequireSignIn && (!person.can_challenge || disabled))} - pending={createChallenge.isPending} + pending={requestPending} pendingLabel="Requesting…" title={disabled ? disabledReason : undefined} > diff --git a/apps/web/core/debates/matchmaking/requests-tab.test.tsx b/apps/web/core/debates/matchmaking/requests-tab.test.tsx index c0a443d864..d89cc7fb42 100644 --- a/apps/web/core/debates/matchmaking/requests-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/requests-tab.test.tsx @@ -9,7 +9,12 @@ import { RequestsTab } from './requests-tab'; const mocks = vi.hoisted(() => ({ incoming: [] as DebateRequest[], outbound: null as DebateRequest | null, + activityOutbound: null as DebateRequest | null, challenge: null as DebateChallenge | null, + outboundChallenge: null as DebateChallenge | null, + requestsLoading: false, + requestsError: null as Error | null, + requestsFailureReason: null as Error | null, accept: vi.fn(), dismiss: vi.fn(), withdraw: vi.fn(), @@ -27,7 +32,13 @@ vi.mock('../hooks', async importOriginal => ({ // exercising it — the schedule itself is covered in core/availability. useDebateSchedule: () => ({ blocks: [], isSet: false }), useSaveDebateSchedule: () => ({ mutate: vi.fn(), isPending: false }), - useDebateActivity: () => ({ data: { challenge: mocks.challenge, outbound_request: null } }), + useDebateActivity: () => ({ + data: { + challenge: mocks.challenge, + outbound_challenge: mocks.outboundChallenge, + outbound_request: mocks.activityOutbound, + }, + }), useAcceptDebateChallenge: () => ({ mutate: mocks.acceptChallenge, isPending: false, error: null }), useRejectDebateChallenge: () => ({ mutate: mocks.rejectChallenge, isPending: false, error: null }), useGeoChatAuth: () => ({ ready: true, authenticated: true, accountKey: 'account-a', getPrivyIdentityToken: vi.fn() }), @@ -36,8 +47,9 @@ vi.mock('../hooks', async importOriginal => ({ vi.mock('./hooks', () => ({ useDebateRequests: () => ({ data: { incoming: mocks.incoming, outbound: mocks.outbound }, - isLoading: false, - error: null, + isLoading: mocks.requestsLoading, + error: mocks.requestsError, + failureReason: mocks.requestsFailureReason, refetch: vi.fn(), }), useAcceptDebateRequest: () => ({ mutate: mocks.accept, isPending: false, error: null }), @@ -55,7 +67,14 @@ vi.mock('~/core/browse/use-browse-sidebar-cache', () => ({ keyInput: null, isLoading: false, }), - useCachedBrowseSidebarData: () => null, + useCachedBrowseSidebarData: () => ({ + featured: [ + { id: '019fedae-72b6-7ab2-927a-df044d57c566', name: 'Crypto', image: null }, + { id: '019fedb1-0c41-7f3e-9a11-2c7d5e8b4419', name: 'Science', image: null }, + ], + editorOf: [], + memberOf: [], + }), })); vi.mock('~/core/hooks/use-spaces-by-ids', () => ({ @@ -119,7 +138,12 @@ function challenge(role: 'recipient' | 'requester'): DebateChallenge { beforeEach(() => { mocks.incoming = [request('request-1', SPACE_A, 'Bitcoin will never go above $250K')]; mocks.outbound = null; + mocks.activityOutbound = null; mocks.challenge = null; + mocks.outboundChallenge = null; + mocks.requestsLoading = false; + mocks.requestsError = null; + mocks.requestsFailureReason = null; mocks.currentUserId = 'user-me'; mocks.accept.mockReset(); mocks.dismiss.mockReset(); @@ -141,6 +165,16 @@ afterEach(cleanup); const openFilter = (label: string) => fireEvent.click(screen.getByRole('button', { name: new RegExp(label) })); describe('RequestsTab', () => { + it('does not show stale activity after the authoritative request list clears', () => { + mocks.incoming = []; + mocks.activityOutbound = request('stale-outbound', SPACE_A, 'A stale claim request'); + + render(); + + expect(screen.queryByRole('button', { name: 'Withdraw' })).not.toBeInTheDocument(); + expect(screen.getByText('Any debate requests you’ll receive will appear here.')).toBeInTheDocument(); + }); + it('gives plain request and filter controls stable analytics metadata', () => { mocks.outbound = request('request-outbound', SPACE_B, 'A second claim'); mocks.challenge = challenge('requester'); @@ -292,6 +326,57 @@ describe('RequestsTab', () => { expect(screen.getAllByText(/^Expires in/)).toHaveLength(2); }); + it('keeps simultaneous inbound and outbound person requests in their respective sections', () => { + mocks.incoming = []; + mocks.challenge = challenge('recipient'); + mocks.outboundChallenge = { ...challenge('requester'), id: 'challenge-outbound' }; + + render(); + + const sent = screen.getByRole('heading', { name: 'Sent' }); + const received = screen.getByRole('heading', { name: 'Received' }); + expect(sent.compareDocumentPosition(received) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Cancel request' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Explore claims' })).toBeInTheDocument(); + }); + + it('keeps an outbound person request cancellable while claim requests load', () => { + mocks.incoming = []; + mocks.outboundChallenge = challenge('requester'); + mocks.requestsLoading = true; + + render(); + + expect(screen.getByRole('heading', { name: 'Sent' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel request' })).toBeInTheDocument(); + }); + + it('keeps an inbound person request actionable when claim requests fail', () => { + mocks.incoming = []; + mocks.challenge = challenge('recipient'); + mocks.requestsError = new Error('nope'); + + render(); + + expect(screen.getByRole('heading', { name: 'Received' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Explore claims' })).toBeInTheDocument(); + expect(screen.getByText('Something went wrong.')).toBeInTheDocument(); + }); + + it('hides claimless inbound and retained outbound requests behind a space filter', () => { + mocks.challenge = challenge('recipient'); + mocks.outboundChallenge = { ...challenge('requester'), id: 'challenge-outbound' }; + render(); + + openFilter('Any space'); + fireEvent.click(screen.getByRole('button', { name: /Crypto/ })); + + expect(screen.queryByRole('heading', { name: 'Sent' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cancel request' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Explore claims' })).not.toBeInTheDocument(); + expect(screen.getByText('Bitcoin will never go above $250K')).toBeInTheDocument(); + }); + it('narrows to one side with the status filter', () => { mocks.outbound = request('request-2', SPACE_A, 'Chips are better than fries'); render(); diff --git a/apps/web/core/debates/matchmaking/requests-tab.tsx b/apps/web/core/debates/matchmaking/requests-tab.tsx index 7bae6d96fa..bc996ac269 100644 --- a/apps/web/core/debates/matchmaking/requests-tab.tsx +++ b/apps/web/core/debates/matchmaking/requests-tab.tsx @@ -6,7 +6,9 @@ import { usePeerAvailabilityEnabled } from '~/core/state/feature-flags'; import { Text } from '~/design-system/text'; +import type { DebateChallenge, DebateRequest } from '../api'; import { useDebateActivity } from '../hooks'; +import { resolveOutboundRequest } from '../request-gate'; import { useCurrentGeoChatUserId } from '../use-current-geo-chat-user-id'; import { DebateChallengeCard } from './challenge-card'; import { HubStickyControls, SpaceTopicFilters } from './claims-tab'; @@ -18,6 +20,7 @@ import { IncomingRequestCard } from './incoming-request-card'; import { OutboundRequestCard } from './outbound-request-card'; import { type ScheduledContent, ScheduledDebatesSection, useScheduledContent } from './scheduled-debates-section'; import { countBy, orderFacetOptions, toggleId } from './topic-facets'; +import { useDebateChallengeState } from './use-outbound-debate-challenge'; import { useUnexpiredRequests } from './use-request-countdown'; type RequestStatusFilter = 'all' | 'sent' | 'received'; @@ -68,7 +71,7 @@ function RequestsTabBody({ const { data: activity } = useDebateActivity(true); const incoming = useUnexpiredRequests(requestsQuery.data?.incoming ?? []); - const outbound = requestsQuery.data?.outbound ?? activity?.outbound_request ?? null; + const outbound = resolveOutboundRequest(requestsQuery.data, activity); const inSpace = React.useCallback( (requestSpaceId: string) => spaceIds.length === 0 || spaceIds.includes(requestSpaceId), @@ -95,25 +98,15 @@ function RequestsTabBody({ return orderFacetOptions(countBy(spaces.map(id => ({ id, name: null }))), spaceIds); }, [incoming, outbound, spaceIds, status]); - // The claimless challenge sits alongside claim requests: it expires the same way, and "Not now" - // in its popup leaves it here rather than answering it. - const reportedChallenge = activity?.challenge?.status === 'pending' ? activity.challenge : null; - const liveChallenges = useUnexpiredRequests( - React.useMemo(() => (reportedChallenge ? [reportedChallenge] : []), [reportedChallenge]) - ); - const challenge = liveChallenges[0] ?? null; const currentUserId = useCurrentGeoChatUserId(); + const { challenge, challengeRole, outboundChallenge } = useDebateChallengeState(activity, currentUserId); // A claimless challenge belongs to no space, so a space filter can only hide it. Role is left // undecided until the viewer's id is known — guessing files an incoming challenge under Sent, // where it reads as something the viewer sent and offers them "Cancel request" for it. - const challengeRole = - !challenge || spaceIds.length > 0 || !currentUserId - ? null - : challenge.recipient.user_id === currentUserId - ? 'recipient' - : 'requester'; - const incomingChallenge = challengeRole === 'recipient' && status !== 'sent' ? challenge : null; - const outgoingChallenge = challengeRole === 'requester' && status !== 'received' ? challenge : null; + const challengeHiddenBySpace = spaceIds.length > 0; + const incomingChallenge = + !challengeHiddenBySpace && challengeRole === 'recipient' && status !== 'sent' ? challenge : null; + const outgoingChallenge = challengeHiddenBySpace || status === 'received' ? null : outboundChallenge; const hasFilters = spaceIds.length > 0 || status !== 'all'; const hasScheduled = @@ -122,6 +115,15 @@ function RequestsTabBody({ scheduled.requestsError !== null || scheduled.roomsError !== null; const isEmpty = !sent && !outgoingChallenge && received.length === 0 && !incomingChallenge && !hasScheduled; + const challengeSections = + outgoingChallenge || incomingChallenge ? ( + + ) : null; return (
@@ -170,36 +172,58 @@ function RequestsTabBody({ } : undefined } + fallbackContent={challengeSections} > -
- {sent || outgoingChallenge ? ( - -
- {outgoingChallenge ? : null} - {sent ? : null} -
-
- ) : null} - - {incomingChallenge || received.length > 0 ? ( - -
- {incomingChallenge ? : null} - - {received.map(request => ( - - ))} - -
-
- ) : null} -
+
); } +function RequestSections({ + sent, + outgoingChallenge, + received, + incomingChallenge, +}: { + sent: DebateRequest | null; + outgoingChallenge: DebateChallenge | null; + received: DebateRequest[]; + incomingChallenge: DebateChallenge | null; +}) { + return ( +
+ {sent || outgoingChallenge ? ( + +
+ {outgoingChallenge ? : null} + {sent ? : null} +
+
+ ) : null} + + {incomingChallenge || received.length > 0 ? ( + +
+ {incomingChallenge ? : null} + + {received.map(request => ( + + ))} + +
+
+ ) : null} +
+ ); +} + function RequestSection({ label, children }: { label: string; children: React.ReactNode }) { return (
diff --git a/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts b/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts new file mode 100644 index 0000000000..cf0afa8b36 --- /dev/null +++ b/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts @@ -0,0 +1,73 @@ +'use client'; + +import * as React from 'react'; + +import type { DebateActivity, DebateChallenge } from '../api'; +import { useUnexpiredRequests } from './use-request-countdown'; + +export type DebateChallengeRole = 'requester' | 'recipient'; + +/** + * Every live person-request state the matchmaking surfaces need, resolved once. + * + * Keeping the server-reported challenge and the client-retained outbound challenge separate is + * load-bearing: both can exist at once, and the former may be inbound while the latter is outbound. + */ +export function useDebateChallengeState( + activity: DebateActivity | null | undefined, + currentUserId: string | null +): { + challenge: DebateChallenge | null; + challengeRole: DebateChallengeRole | null; + outboundChallenge: DebateChallenge | null; + outboundChallengeDirectionUnknown: boolean; +} { + const reported = activity?.challenge?.status === 'pending' ? activity.challenge : null; + const retained = activity?.outbound_challenge?.status === 'pending' ? activity.outbound_challenge : null; + const pendingChallenges = React.useMemo(() => { + if (!reported) return retained ? [retained] : []; + return retained && retained.id !== reported.id ? [reported, retained] : [reported]; + }, [reported, retained]); + const liveChallenges = useUnexpiredRequests(pendingChallenges); + const challenge = reported ? (liveChallenges.find(candidate => candidate.id === reported.id) ?? null) : null; + const retainedOutbound = retained ? (liveChallenges.find(candidate => candidate.id === retained.id) ?? null) : null; + const challengeRole = + !challenge || !currentUserId + ? null + : challenge.requester.user_id === currentUserId + ? 'requester' + : challenge.recipient.user_id === currentUserId + ? 'recipient' + : null; + const outboundChallenge = retainedOutbound ?? (challengeRole === 'requester' ? challenge : null); + + return { + challenge, + challengeRole, + outboundChallenge, + outboundChallengeDirectionUnknown: Boolean(challenge && !retainedOutbound && !currentUserId), + }; +} + +/** + * The live person-to-person request sent by the viewer. + * + * geo-chat's activity payload currently exposes whichever challenge involves the viewer in + * `challenge`, regardless of direction. The client-retained `outbound_challenge` removes that + * ambiguity when an incoming challenge and a newly sent challenge coexist; after a reload, the + * current user id identifies whether the server's single challenge is outbound. + */ +export function useOutboundDebateChallenge( + activity: DebateActivity | null | undefined, + currentUserId: string | null +): { + outboundChallenge: DebateChallenge | null; + outboundChallengeDirectionUnknown: boolean; +} { + const { outboundChallenge, outboundChallengeDirectionUnknown } = useDebateChallengeState(activity, currentUserId); + + return { + outboundChallenge, + outboundChallengeDirectionUnknown, + }; +} diff --git a/apps/web/core/debates/matchmaking/use-request-countdown.test.ts b/apps/web/core/debates/matchmaking/use-request-countdown.test.ts index 89ef513550..6be85e9543 100644 --- a/apps/web/core/debates/matchmaking/use-request-countdown.test.ts +++ b/apps/web/core/debates/matchmaking/use-request-countdown.test.ts @@ -6,6 +6,7 @@ import { formatCountdown } from './use-request-countdown'; const mocks = vi.hoisted(() => ({ serverTimeMs: 0, + clockBarrier: null as Promise | null, })); // The clock is synchronized against the server so a skewed client still counts down correctly. @@ -17,6 +18,7 @@ vi.mock('../server-clock', () => ({ createLocalServerClock: () => ({ now: () => Date.now(), roundTripMs: null }), // The synchronized clock keeps advancing with (fake) time; only its offset comes from the server. synchronizeServerClock: vi.fn(async () => { + await mocks.clockBarrier; const offsetMs = mocks.serverTimeMs - Date.now(); return { now: () => Date.now() + offsetMs, roundTripMs: 0 }; }), @@ -42,6 +44,7 @@ describe('useRequestCountdown', () => { beforeEach(() => { vi.useFakeTimers(); mocks.serverTimeMs = new Date('2026-08-05T12:00:00.000Z').getTime(); + mocks.clockBarrier = null; }); afterEach(() => { @@ -64,6 +67,27 @@ describe('useRequestCountdown', () => { expect(result.current.label).toBe('Expires in 25m'); }); + it('does not report expiry from a skewed device clock while server time is resolving', async () => { + vi.setSystemTime(new Date('2026-08-05T13:00:00.000Z')); + let releaseClock!: () => void; + mocks.clockBarrier = new Promise(resolve => { + releaseClock = resolve; + }); + const { useRequestCountdown } = await import('./use-request-countdown'); + + const { result } = renderHook(() => useRequestCountdown('2026-08-05T12:25:00.000Z')); + + expect(result.current.expired).toBe(false); + expect(result.current.label).toBe('Expires later'); + + releaseClock(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.expired).toBe(false); + expect(result.current.label).toBe('Expires in 25m'); + }); + it('marks a request expired once the deadline passes', async () => { vi.setSystemTime(new Date('2026-08-05T12:00:00.000Z')); const { useRequestCountdown } = await import('./use-request-countdown'); @@ -111,6 +135,7 @@ describe('useUnexpiredRequests', () => { beforeEach(() => { vi.useFakeTimers(); mocks.serverTimeMs = new Date('2026-08-05T12:00:00.000Z').getTime(); + mocks.clockBarrier = null; }); afterEach(() => { @@ -142,6 +167,26 @@ describe('useUnexpiredRequests', () => { expect(vi.getTimerCount()).toBe(1); }); + it('keeps a server-live request while the skewed device clock is being corrected', async () => { + vi.setSystemTime(new Date('2026-08-05T13:00:00.000Z')); + let releaseClock!: () => void; + mocks.clockBarrier = new Promise(resolve => { + releaseClock = resolve; + }); + const { useUnexpiredRequests } = await import('./use-request-countdown'); + const requests = [{ id: 'live', expires_at: '2026-08-05T12:25:00.000Z' }]; + + const { result } = renderHook(() => useUnexpiredRequests(requests)); + + expect(result.current).toEqual(requests); + + releaseClock(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current).toEqual(requests); + }); + it('filters requests that are already expired on mount and keeps unparseable ones', async () => { vi.setSystemTime(new Date('2026-08-05T12:00:00.000Z')); const { useUnexpiredRequests } = await import('./use-request-countdown'); diff --git a/apps/web/core/debates/matchmaking/use-request-countdown.ts b/apps/web/core/debates/matchmaking/use-request-countdown.ts index 92cbfe675a..2b1b2a1eb3 100644 --- a/apps/web/core/debates/matchmaking/use-request-countdown.ts +++ b/apps/web/core/debates/matchmaking/use-request-countdown.ts @@ -63,14 +63,23 @@ export function useRequestCountdown(expiresAt: string): RequestCountdown { const expiresAtMs = React.useMemo(() => new Date(expiresAt).getTime(), [expiresAt]); const [now, setNow] = React.useState(() => Date.now()); - const remainingMs = Math.max(0, expiresAtMs - now); + // A server expiry cannot be judged against the device clock while synchronization is pending. + // Keep the useful local countdown when it is plausible, but never call a server-live request + // expired solely because a fast device clock says its timestamp has passed. + const localRemainingMs = Math.max(0, expiresAtMs - now); + const remainingMs = clock + ? Math.max(0, expiresAtMs - clock.now()) + : localRemainingMs > 0 + ? localRemainingMs + : Number.NaN; // The tick rate only changes when we cross the final minute, so the effect re-arms then — and // once more at expiry, where it clears the interval for good. - const isFinalMinute = remainingMs <= MINUTE_MS; - const expired = Number.isFinite(expiresAtMs) && remainingMs <= 0; + const isFinalMinute = Number.isFinite(remainingMs) && remainingMs <= MINUTE_MS; + const expired = Boolean(clock) && Number.isFinite(expiresAtMs) && remainingMs <= 0; React.useEffect(() => { - const read = () => (clock ? clock.now() : Date.now()); + if (!clock) return; + const read = () => clock.now(); setNow(read()); if (expired || !Number.isFinite(expiresAtMs) || expiresAtMs - read() <= 0) return; @@ -96,19 +105,22 @@ export function useUnexpiredRequests(requests: // An empty list is the common case on the surfaces that mount globally, and it has no expiry to // get wrong — so it doesn't pay for the clock. The sync starts when the first request lands. const clock = useServerClock(requests.length > 0); - const [now, setNow] = React.useState(() => Date.now()); + const [, setNow] = React.useState(() => Date.now()); + const serverNow = clock?.now(); const nextExpiryMs = React.useMemo(() => { + if (serverNow === undefined) return Infinity; let next = Infinity; for (const request of requests) { const expires = new Date(request.expires_at).getTime(); - if (Number.isFinite(expires) && expires > now && expires < next) next = expires; + if (Number.isFinite(expires) && expires > serverNow && expires < next) next = expires; } return next; - }, [now, requests]); + }, [requests, serverNow]); React.useEffect(() => { - const read = () => (clock ? clock.now() : Date.now()); + if (!clock) return; + const read = () => clock.now(); setNow(read()); if (!Number.isFinite(nextExpiryMs)) return; @@ -116,15 +128,16 @@ export function useUnexpiredRequests(requests: return () => clearTimeout(timeout); }, [clock, nextExpiryMs]); - return React.useMemo( - () => - requests.filter(request => { - const expires = new Date(request.expires_at).getTime(); - // Unparseable expiries stay visible — matching the card, which only hides a finite past. - return !Number.isFinite(expires) || expires > now; - }), - [now, requests] - ); + return React.useMemo(() => { + // Until synchronization resolves there is no trustworthy basis for declaring a server + // timestamp expired. Keeping the raw pending rows is the safe side of the one-request gate. + if (serverNow === undefined) return requests; + return requests.filter(request => { + const expires = new Date(request.expires_at).getTime(); + // Unparseable expiries stay visible — matching the card, which only hides a finite past. + return !Number.isFinite(expires) || expires > serverNow; + }); + }, [requests, serverNow]); } export function formatCountdown(remainingMs: number) { diff --git a/apps/web/core/debates/profile-debate-button.test.tsx b/apps/web/core/debates/profile-debate-button.test.tsx index 1b94ba4090..869c928e05 100644 --- a/apps/web/core/debates/profile-debate-button.test.tsx +++ b/apps/web/core/debates/profile-debate-button.test.tsx @@ -7,10 +7,17 @@ const mocks = vi.hoisted(() => ({ canChallenge: true, createChallenge: vi.fn(), isPending: false, + activityOutboundRequest: null as { id: string } | null, + requestsOutboundRequest: null as { id: string } | null, + disabledRequestsOutboundRequest: null as { id: string } | null, + outboundChallenge: null as { id: string } | null, + outboundChallengeDirectionUnknown: false, + outboundRequestCreationPending: false, })); vi.mock('./hooks', () => ({ useDebateProfile: () => ({ data: { can_challenge: mocks.canChallenge } }), + useDebateActivity: () => ({ data: { outbound_request: mocks.activityOutboundRequest } }), useCreateDebateChallenge: () => ({ mutate: mocks.createChallenge, isPending: mocks.isPending, @@ -18,11 +25,33 @@ vi.mock('./hooks', () => ({ }), })); +vi.mock('./matchmaking/hooks', () => ({ + useDebateRequests: (enabled: boolean) => ({ + data: { + outbound: enabled ? mocks.requestsOutboundRequest : mocks.disabledRequestsOutboundRequest, + }, + }), +})); + +vi.mock('./matchmaking/debate-challenge-state-provider', () => ({ + useSharedOutboundRequestState: () => ({ + outboundChallenge: mocks.outboundChallenge, + outboundChallengeDirectionUnknown: mocks.outboundChallengeDirectionUnknown, + outboundRequestCreationPending: mocks.outboundRequestCreationPending, + }), +})); + const { ProfileDebateButton } = await import('./profile-debate-button'); beforeEach(() => { mocks.canChallenge = true; mocks.isPending = false; + mocks.activityOutboundRequest = null; + mocks.requestsOutboundRequest = null; + mocks.disabledRequestsOutboundRequest = null; + mocks.outboundChallenge = null; + mocks.outboundChallengeDirectionUnknown = false; + mocks.outboundRequestCreationPending = false; mocks.createChallenge.mockReset(); }); @@ -60,6 +89,51 @@ describe('ProfileDebateButton', () => { expect(screen.getByRole('button', { name: 'Requesting...' })).toBeDisabled(); }); + it('blocks while a request is being created by another control', () => { + mocks.outboundRequestCreationPending = true; + render(); + + const button = screen.getByRole('button', { name: 'Request debate' }); + expect(button).toBeDisabled(); + expect(screen.getByTitle('You can only have one pending outbound request at a time.')).toContainElement(button); + }); + + it('blocks another person request while an outbound person challenge is pending', () => { + mocks.outboundChallenge = { id: 'challenge-outbound' }; + render(); + + const button = screen.getByRole('button', { name: 'Request debate' }); + expect(button).toBeDisabled(); + expect(screen.getByTitle('You can only have one pending outbound request at a time.')).toContainElement(button); + + fireEvent.click(button); + expect(mocks.createChallenge).not.toHaveBeenCalled(); + }); + + it('blocks another person request while an outbound claim request is pending', () => { + mocks.requestsOutboundRequest = { id: 'claim-request-outbound' }; + render(); + + const button = screen.getByRole('button', { name: 'Request debate' }); + expect(button).toBeDisabled(); + expect(screen.getByTitle('You can only have one pending outbound request at a time.')).toContainElement(button); + + fireEvent.click(button); + expect(mocks.createChallenge).not.toHaveBeenCalled(); + }); + + it('stops blocking after the authoritative request list clears a stale cached outbound request', () => { + mocks.disabledRequestsOutboundRequest = { id: 'stale-claim-request' }; + mocks.activityOutboundRequest = { id: 'stale-activity-request' }; + render(); + + const button = screen.getByRole('button', { name: 'Request debate' }); + expect(button).toBeEnabled(); + + fireEvent.click(button); + expect(mocks.createChallenge).toHaveBeenCalledWith({ recipient_profile_space_id: 'profile-them' }); + }); + it('stays hidden when the server says this person cannot be challenged', () => { mocks.canChallenge = false; const { container } = render(); diff --git a/apps/web/core/debates/profile-debate-button.tsx b/apps/web/core/debates/profile-debate-button.tsx index 984bda6273..0c334f7843 100644 --- a/apps/web/core/debates/profile-debate-button.tsx +++ b/apps/web/core/debates/profile-debate-button.tsx @@ -4,7 +4,11 @@ import * as React from 'react'; import { Text } from '~/design-system/text'; -import { useCreateDebateChallenge, useDebateProfile } from './hooks'; +import { useCreateDebateChallenge, useDebateActivity, useDebateProfile } from './hooks'; +import { useSharedOutboundRequestState } from './matchmaking/debate-challenge-state-provider'; +import { useDebateRequests } from './matchmaking/hooks'; +import { RequestBlockedReasonTooltip } from './request-blocked-reason-tooltip'; +import { PENDING_OUTBOUND_REQUEST_REASON, resolveOutboundRequest } from './request-gate'; /** * Challenges the owner of a personal space to a debate with no claim attached. @@ -13,25 +17,41 @@ import { useCreateDebateChallenge, useDebateProfile } from './hooks'; */ export function ProfileDebateButton({ spaceId }: { spaceId: string }) { const profileQuery = useDebateProfile(spaceId); + const { data: activity } = useDebateActivity(); + // The request list is authoritative for claim requests while this control is visible. It must be + // enabled rather than cache-only: `debate.requests_changed` invalidates this key when a request + // ends, and a disabled observer would keep gating on its stale outbound row indefinitely. + const { data: requests } = useDebateRequests(profileQuery.data?.can_challenge === true); + const { outboundChallenge, outboundChallengeDirectionUnknown, outboundRequestCreationPending } = + useSharedOutboundRequestState(); const createChallenge = useCreateDebateChallenge(); if (!profileQuery.data?.can_challenge) return null; const error = createChallenge.error instanceof Error ? createChallenge.error.message : null; + const outboundRequest = resolveOutboundRequest(requests, activity); + const blockedReason = + outboundRequest || outboundChallenge || outboundChallengeDirectionUnknown || outboundRequestCreationPending + ? PENDING_OUTBOUND_REQUEST_REASON + : null; + + const button = ( + + ); return (
- + {blockedReason ? : button} {error && ( {error} diff --git a/apps/web/core/debates/request-blocked-reason-tooltip.tsx b/apps/web/core/debates/request-blocked-reason-tooltip.tsx new file mode 100644 index 0000000000..7c438a85fd --- /dev/null +++ b/apps/web/core/debates/request-blocked-reason-tooltip.tsx @@ -0,0 +1,39 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import cx from 'classnames'; + +import { Tooltip } from '~/design-system/tooltip'; + +/** + * Makes the reason for a disabled debate-request control reachable by pointer, touch, and keyboard. + * + * A disabled button does not reliably emit the pointer events Radix needs, so the wrapper owns the + * interactions while leaving the button's disabled semantics intact. + */ +export function RequestBlockedReasonTooltip({ + reason, + trigger, + align = 'end', + fullWidth = false, +}: { + reason: string; + trigger: ReactNode; + align?: 'start' | 'center' | 'end'; + fullWidth?: boolean; +}) { + return ( + + {trigger} + + } + /> + ); +} diff --git a/apps/web/core/debates/request-debate-control.tsx b/apps/web/core/debates/request-debate-control.tsx index ad79eae14d..747f18ca74 100644 --- a/apps/web/core/debates/request-debate-control.tsx +++ b/apps/web/core/debates/request-debate-control.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import cx from 'classnames'; -import { Tooltip } from '~/design-system/tooltip'; +import { RequestBlockedReasonTooltip } from './request-blocked-reason-tooltip'; /** * The offer to debate a claim, as one control (GEO-2825). @@ -115,19 +115,11 @@ export function RequestDebateControl({ return ( {blockedReason ? ( - - {button} - - } + fullWidth={variant === 'block'} + trigger={button} /> ) : ( button diff --git a/apps/web/core/debates/request-gate.ts b/apps/web/core/debates/request-gate.ts index d18a761bd0..ccda142356 100644 --- a/apps/web/core/debates/request-gate.ts +++ b/apps/web/core/debates/request-gate.ts @@ -1,3 +1,5 @@ +import type { DebateActivity, DebateRequest, DebateRequestsResponse } from './api'; + /** * Determines whether a debate request can be created for a claim. * @@ -63,6 +65,22 @@ export type DebateRequestGate = { export const REQUEST_PENDING_LABEL = 'Publishing your position…'; /** The publish landed and the confirmation is late; pointing at the transaction would mislead. */ export const REQUEST_PENDING_DELAYED_LABEL = 'Still confirming your position…'; +/** One account may have only one unanswered request, regardless of whether it targets a claim or person. */ +export const PENDING_OUTBOUND_REQUEST_REASON = 'You can only have one pending outbound request at a time.'; +/** Both create APIs share one account-scoped key so every request control sees either one in flight. */ +export const outboundRequestCreationMutationKey = (accountKey: string | null) => + ['debates', 'account', accountKey, 'create-outbound-request'] as const; + +/** + * The request list is authoritative once it has loaded, including when it explicitly says there + * is no outbound request. Activity is only the cold-load fallback while that list is unavailable. + */ +export function resolveOutboundRequest( + requests: DebateRequestsResponse | undefined, + activity: DebateActivity | undefined +): DebateRequest | null { + return requests === undefined ? (activity?.outbound_request ?? null) : requests.outbound; +} export function debateRequestGate({ chatPosition, diff --git a/apps/web/core/debates/use-outbound-request-creation.ts b/apps/web/core/debates/use-outbound-request-creation.ts new file mode 100644 index 0000000000..83c6120b93 --- /dev/null +++ b/apps/web/core/debates/use-outbound-request-creation.ts @@ -0,0 +1,12 @@ +'use client'; + +import { useIsMutating } from '@tanstack/react-query'; + +import { useGeoChatAuth } from './hooks'; +import { outboundRequestCreationMutationKey } from './request-gate'; + +/** Whether either outbound request API is currently submitting for this query-client/account scope. */ +export function useOutboundRequestCreationPending(): boolean { + const { accountKey } = useGeoChatAuth(); + return useIsMutating({ mutationKey: outboundRequestCreationMutationKey(accountKey), exact: true }) > 0; +}