From b66b981da4ba1977ebd919a6b4c6137b21d9e926 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 10:45:36 -0700 Subject: [PATCH 01/16] fix: allow outbound requests with pending inbound Only the viewer's own pending challenge should gate a new request. Keep received challenges actionable without disabling the People tab. --- .../core/debates/matchmaking/people-tab.test.tsx | 16 ++++++++++++---- apps/web/core/debates/matchmaking/people-tab.tsx | 16 ++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/web/core/debates/matchmaking/people-tab.test.tsx b/apps/web/core/debates/matchmaking/people-tab.test.tsx index e412691530..3577aed2ba 100644 --- a/apps/web/core/debates/matchmaking/people-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.test.tsx @@ -707,14 +707,22 @@ describe('PeopleTab', () => { } }); - // `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'], + }); }); // Without an id there is no way to tell the two directions apart, and showing a "you sent this" diff --git a/apps/web/core/debates/matchmaking/people-tab.tsx b/apps/web/core/debates/matchmaking/people-tab.tsx index 66288da95e..a2d2fed20f 100644 --- a/apps/web/core/debates/matchmaking/people-tab.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.tsx @@ -290,20 +290,20 @@ export function PeopleTab({ onTabChange }: { onTabChange: (tab: DebatesHubTab) = 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. + // `activity.challenge` is whichever challenge involves the viewer, in either direction. Only a + // request the viewer sent blocks another outbound request; an inbound request stays actionable in + // Requests without taking away the viewer's ability to ask somebody else (GEO-3027). const outboundChallenge = pendingChallenge && currentUserId && pendingChallenge.requester.user_id === currentUserId ? pendingChallenge : null; + // Until the viewer id resolves, the direction is genuinely unknown. Keep the conservative gate + // for that short window so an outbound request cannot be duplicated before it can be identified. + const challengeDirectionUnknown = Boolean(pendingChallenge && !currentUserId); // 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 From 8adb726dc7221cb6b1239053f0198b43ea5bf4ae Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 11:10:42 -0700 Subject: [PATCH 02/16] fix: retain simultaneous debate request state Keep snoozed inbound challenges and newly created outbound challenges independently so activity refreshes cannot reopen the popup, hide the sent card, or enable duplicate requests. --- apps/web/core/debates/api.ts | 5 ++ .../core/debates/debate-coordinator.test.tsx | 48 +++++++++++++++++ apps/web/core/debates/debate-coordinator.tsx | 25 ++++----- .../core/debates/hooks-query-network.test.tsx | 24 +++++++++ apps/web/core/debates/hooks.ts | 54 +++++++++++++++---- .../debates/matchmaking/people-tab.test.tsx | 44 ++++++++++++++- .../core/debates/matchmaking/people-tab.tsx | 32 ++++++++--- .../debates/matchmaking/requests-tab.test.tsx | 20 ++++++- .../core/debates/matchmaking/requests-tab.tsx | 17 +++++- 9 files changed, 236 insertions(+), 33 deletions(-) diff --git a/apps/web/core/debates/api.ts b/apps/web/core/debates/api.ts index 685efe5102..404010660f 100644 --- a/apps/web/core/debates/api.ts +++ b/apps/web/core/debates/api.ts @@ -235,6 +235,11 @@ 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; /** * 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..cdc9d45a2f 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,34 @@ describe('DebateCoordinator', () => { 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() }; diff --git a/apps/web/core/debates/debate-coordinator.tsx b/apps/web/core/debates/debate-coordinator.tsx index 23c2c8b371..4cd4256d81 100644 --- a/apps/web/core/debates/debate-coordinator.tsx +++ b/apps/web/core/debates/debate-coordinator.tsx @@ -193,26 +193,19 @@ export function DebateCoordinator() { : (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]); + // 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. // 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; + 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, 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]); - // 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. const challengeForViewerId = challenge && challenge.recipient.user_id === currentUserId ? challenge.id : null; @@ -442,7 +435,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..7edce0717d 100644 --- a/apps/web/core/debates/hooks-query-network.test.tsx +++ b/apps/web/core/debates/hooks-query-network.test.tsx @@ -222,6 +222,30 @@ describe('debate query network ownership', () => { }); }); + it('keeps a newly created outbound challenge cached instead of refetching stale activity over it', () => { + 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) => unknown; + expect(update({ online: true, challenge: null })).toEqual({ + online: true, + challenge, + outbound_challenge: challenge, + }); + expect(mocks.queryClient.invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: ['debates', 'account', 'user-a', 'activity'], + }); + }); + // 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..35dbaa0fe6 100644 --- a/apps/web/core/debates/hooks.ts +++ b/apps/web/core/debates/hooks.ts @@ -502,6 +502,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 +512,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 +521,16 @@ 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 retainedOutbound = queryClient.getQueryData(activityKey)?.outbound_challenge; + const outboundIsLive = + retainedOutbound?.status === 'pending' && Date.parse(retainedOutbound.expires_at) > Date.now(); + return outboundIsLive && !activity.debate && !activity.rematch + ? { ...activity, outbound_challenge: retainedOutbound } + : activity; }, enabled: queryEnabled, // Hidden tabs still don't poll: they have no popup to draw, and browsers throttle their timers @@ -548,10 +558,14 @@ 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 +582,15 @@ 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, @@ -1218,9 +1233,19 @@ export function useCreateDebateChallenge() { createDebateChallenge(request, getPrivyIdentityToken, accountKey), onSuccess: challenge => { queryClient.setQueryData(debateQueryKeys.activity(accountKey), current => - current ? { ...current, challenge } : current + current + ? { + ...current, + // Keep an inbound challenge in the wire field so its popup and Received card survive. + challenge: current.challenge ?? challenge, + outbound_challenge: challenge, + } + : current ); - void queryClient.invalidateQueries({ queryKey: debateQueryKeys.activity(accountKey) }); + // 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. The gateway + // and the activity poll still reconcile later changes; keep this response in place meanwhile. }, onError: (error, request) => { if (!(error instanceof GeoChatRequestError) || error.code !== 'challenge_unavailable') return; @@ -1241,6 +1266,10 @@ 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 } : current + ); void queryClient.invalidateQueries({ queryKey: debateQueryKeys.activity(accountKey) }); }, }); @@ -1252,9 +1281,16 @@ 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, + } + : current ); void queryClient.invalidateQueries({ queryKey: debateQueryKeys.activity(accountKey) }); }, diff --git a/apps/web/core/debates/matchmaking/people-tab.test.tsx b/apps/web/core/debates/matchmaking/people-tab.test.tsx index 3577aed2ba..87235ef83a 100644 --- a/apps/web/core/debates/matchmaking/people-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.test.tsx @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({ peopleError: null as Error | null, peopleRefetch: vi.fn(), challenge: null as DebateChallenge | null, + outboundChallenge: null as DebateChallenge | null, outboundRequest: null as unknown, activeDebate: null as unknown, currentUserId: 'user-me' as string | null, @@ -37,6 +38,8 @@ const mocks = vi.hoisted(() => ({ claimEntitiesLoading: false, claimEntitiesError: null as Error | null, createChallenge: vi.fn(), + createPending: false, + createRecipientProfileSpaceId: null as string | null, onTabChange: vi.fn(), cancelChallenge: vi.fn(), cancelPending: false, @@ -94,9 +97,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, @@ -284,6 +299,7 @@ beforeEach(() => { mocks.peopleError = null; mocks.peopleRefetch.mockReset(); mocks.challenge = null; + mocks.outboundChallenge = null; mocks.outboundRequest = null; mocks.activeDebate = null; mocks.currentUserId = 'user-me'; @@ -296,6 +312,8 @@ beforeEach(() => { mocks.claimEntitiesLoading = false; mocks.claimEntitiesError = null; mocks.createChallenge.mockReset(); + mocks.createPending = false; + mocks.createRecipientProfileSpaceId = null; mocks.onTabChange.mockReset(); mocks.cancelChallenge.mockReset(); mocks.cancelPending = false; @@ -725,6 +743,28 @@ describe('PeopleTab', () => { }); }); + 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('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" // card for a request you received would be worse than showing none. it('holds the card back until the viewer is identified', () => { diff --git a/apps/web/core/debates/matchmaking/people-tab.tsx b/apps/web/core/debates/matchmaking/people-tab.tsx index a2d2fed20f..93f71f167c 100644 --- a/apps/web/core/debates/matchmaking/people-tab.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.tsx @@ -79,6 +79,9 @@ export function PeopleTab({ onTabChange }: { onTabChange: (tab: DebatesHubTab) = const { data: requests } = useDebateRequests(authenticated); 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(); // 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,7 +284,12 @@ 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; + const reportedChallenge = + activity?.outbound_challenge?.status === 'pending' + ? activity.outbound_challenge + : 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 @@ -312,7 +320,10 @@ export function PeopleTab({ onTabChange }: { onTabChange: (tab: DebatesHubTab) = // 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…' : 'You have a debate request awaiting a reply.'); return (
@@ -428,7 +439,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 +496,8 @@ function PersonRow({ popoverPortal, disabled, disabledReason, + onRequest, + requestPending, onRequireSignIn, onSeeTimes, }: { @@ -501,6 +519,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 +530,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 ? ( @@ -612,14 +632,14 @@ function PersonRow({ onClick={() => onRequireSignIn ? onRequireSignIn() - : createChallenge.mutate({ recipient_profile_space_id: person.profile_space_id }) + : 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..ececdabcb1 100644 --- a/apps/web/core/debates/matchmaking/requests-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/requests-tab.test.tsx @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ incoming: [] as DebateRequest[], outbound: null as DebateRequest | null, challenge: null as DebateChallenge | null, + outboundChallenge: null as DebateChallenge | null, accept: vi.fn(), dismiss: vi.fn(), withdraw: vi.fn(), @@ -27,7 +28,9 @@ 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: null }, + }), 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() }), @@ -120,6 +123,7 @@ beforeEach(() => { mocks.incoming = [request('request-1', SPACE_A, 'Bitcoin will never go above $250K')]; mocks.outbound = null; mocks.challenge = null; + mocks.outboundChallenge = null; mocks.currentUserId = 'user-me'; mocks.accept.mockReset(); mocks.dismiss.mockReset(); @@ -292,6 +296,20 @@ 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('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..7f2e6367fa 100644 --- a/apps/web/core/debates/matchmaking/requests-tab.tsx +++ b/apps/web/core/debates/matchmaking/requests-tab.tsx @@ -102,6 +102,18 @@ function RequestsTabBody({ React.useMemo(() => (reportedChallenge ? [reportedChallenge] : []), [reportedChallenge]) ); const challenge = liveChallenges[0] ?? null; + const reportedOutboundChallenge = + activity?.outbound_challenge?.status === 'pending' ? activity.outbound_challenge : null; + const liveOutboundChallenges = useUnexpiredRequests( + React.useMemo( + () => + reportedOutboundChallenge && reportedOutboundChallenge.id !== reportedChallenge?.id + ? [reportedOutboundChallenge] + : [], + [reportedChallenge?.id, reportedOutboundChallenge] + ) + ); + const retainedOutboundChallenge = liveOutboundChallenges[0] ?? null; const currentUserId = useCurrentGeoChatUserId(); // 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, @@ -113,7 +125,10 @@ function RequestsTabBody({ ? 'recipient' : 'requester'; const incomingChallenge = challengeRole === 'recipient' && status !== 'sent' ? challenge : null; - const outgoingChallenge = challengeRole === 'requester' && status !== 'received' ? challenge : null; + const outgoingChallenge = + status === 'received' + ? null + : (retainedOutboundChallenge ?? (challengeRole === 'requester' ? challenge : null)); const hasFilters = spaceIds.length > 0 || status !== 'all'; const hasScheduled = From 55626c479c1f1822b819bc2656b19c57ecb334b3 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 11:46:46 -0700 Subject: [PATCH 03/16] fix: share outbound debate gate across tabs --- .../core/claims/browse/use-claim-matchup.ts | 6 ++- .../debates/matchmaking/claims-tab.test.tsx | 50 ++++++++++++++++++- .../core/debates/matchmaking/claims-tab.tsx | 6 +++ .../debates/matchmaking/matches-list.test.tsx | 44 +++++++++++++++- .../core/debates/matchmaking/matches-list.tsx | 6 +++ .../core/debates/matchmaking/people-tab.tsx | 35 +++---------- .../use-outbound-debate-challenge.ts | 37 ++++++++++++++ 7 files changed, 151 insertions(+), 33 deletions(-) create mode 100644 apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts diff --git a/apps/web/core/claims/browse/use-claim-matchup.ts b/apps/web/core/claims/browse/use-claim-matchup.ts index a8925d4d49..c2147be63c 100644 --- a/apps/web/core/claims/browse/use-claim-matchup.ts +++ b/apps/web/core/claims/browse/use-claim-matchup.ts @@ -3,6 +3,8 @@ import type { DebateClaimPositionSummary } from '~/core/debates/api'; import { useDebateActivity } from '~/core/debates/hooks'; import { useCreateDebateRequest, useDebateRequests, useMatchmakingMatches } from '~/core/debates/matchmaking/hooks'; +import { useOutboundDebateChallenge } from '~/core/debates/matchmaking/use-outbound-debate-challenge'; +import { useCurrentGeoChatUserId } from '~/core/debates/use-current-geo-chat-user-id'; 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 currentUserId = useCurrentGeoChatUserId(); + const { outboundChallenge, outboundChallengeDirectionUnknown } = useOutboundDebateChallenge(activity, currentUserId); const createRequest = useCreateDebateRequest(); // `enabled: false` only stops this query from *fetching*. React Query still hands back whatever @@ -46,7 +50,7 @@ export function useClaimMatchup({ const unavailable = activity?.available_to_debate === false; const blockedReason = unavailable ? 'Switch yourself to available to send a request.' - : outbound + : outbound || outboundChallenge || outboundChallengeDirectionUnknown ? 'Withdraw your open request to send another.' : undefined; 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..c6adfcc73c 100644 --- a/apps/web/core/debates/matchmaking/claims-tab.tsx +++ b/apps/web/core/debates/matchmaking/claims-tab.tsx @@ -35,7 +35,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 +54,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'; @@ -217,6 +220,8 @@ export function ClaimsTab({ const requestsQuery = useDebateRequests(authenticated); const { data: activity } = useDebateActivity(authenticated); const outbound = requestsQuery.data?.outbound ?? activity?.outbound_request ?? null; + const currentUserId = useCurrentGeoChatUserId(); + const { outboundChallenge } = useOutboundDebateChallenge(activity, currentUserId); const [search, setSearch] = useAtom(atoms.search); const { value: debouncedSearch, pending: searchSettling } = useDebouncedSearch(search); @@ -977,6 +982,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} ({ 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', () => ({ @@ -178,6 +190,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 +238,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 +403,15 @@ describe('MatchesList', () => { expect(screen.getByRole('button', { name: 'Request debate' })).toBeDisabled(); }); + it('shows a sent person request in Lobby and blocks claim requests', () => { + mocks.outboundChallenge = challenge(); + render(); + + expect(screen.getByText('Awaiting response')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel request' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Request debate' })).toBeDisabled(); + }); + // 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..2dc9499ae9 100644 --- a/apps/web/core/debates/matchmaking/matches-list.tsx +++ b/apps/web/core/debates/matchmaking/matches-list.tsx @@ -9,6 +9,8 @@ import { Input } from '~/design-system/input'; import type { MatchmakingMatch } from '../api'; import { useClaimEntitiesByIds } from '../claim-picker-page'; import { useDebateActivity } from '../hooks'; +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 +29,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 { @@ -86,6 +89,8 @@ export function MatchesList({ const serverMatches = React.useMemo(() => matchesQuery.data?.matches ?? [], [matchesQuery.data]); const outbound = requestsQuery.data?.outbound ?? activity?.outbound_request ?? null; + 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 +236,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} (reportedChallenge ? [reportedChallenge] : []), [reportedChallenge]) - ); - const pendingChallenge = liveChallenges[0] ?? null; - // `activity.challenge` is whichever challenge involves the viewer, in either direction. Only a - // request the viewer sent blocks another outbound request; an inbound request stays actionable in - // Requests without taking away the viewer's ability to ask somebody else (GEO-3027). - const outboundChallenge = - pendingChallenge && currentUserId && pendingChallenge.requester.user_id === currentUserId ? pendingChallenge : null; - // Until the viewer id resolves, the direction is genuinely unknown. Keep the conservative gate - // for that short window so an outbound request cannot be duplicated before it can be identified. - const challengeDirectionUnknown = Boolean(pendingChallenge && !currentUserId); + // `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. + const { outboundChallenge, outboundChallengeDirectionUnknown: challengeDirectionUnknown } = + useOutboundDebateChallenge(activity, currentUserId); // 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 @@ -629,11 +612,7 @@ function PersonRow({ )} - onRequireSignIn - ? onRequireSignIn() - : onRequest() - } + 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 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..1f1115a252 --- /dev/null +++ b/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts @@ -0,0 +1,37 @@ +'use client'; + +import * as React from 'react'; + +import type { DebateActivity, DebateChallenge } from '../api'; +import { useUnexpiredRequests } from './use-request-countdown'; + +/** + * 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 retained = activity?.outbound_challenge?.status === 'pending' ? activity.outbound_challenge : null; + const reported = activity?.challenge?.status === 'pending' ? activity.challenge : null; + const candidate = + retained ?? (reported && (!currentUserId || reported.requester.user_id === currentUserId) ? reported : null); + const liveCandidates = useUnexpiredRequests(React.useMemo(() => (candidate ? [candidate] : []), [candidate])); + const liveCandidate = liveCandidates[0] ?? null; + const retainedIsLive = Boolean(liveCandidate && retained?.id === liveCandidate.id); + const directionUnknown = Boolean(liveCandidate && !retainedIsLive && !currentUserId); + + return { + outboundChallenge: + liveCandidate && (retainedIsLive || liveCandidate.requester.user_id === currentUserId) ? liveCandidate : null, + outboundChallengeDirectionUnknown: directionUnknown, + }; +} From d68a7fc0fafb47bf88533bd5cafa1e86bc8ba438 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 12:03:42 -0700 Subject: [PATCH 04/16] fix: explain the outbound request limit --- apps/web/core/claims/browse/use-claim-matchup.ts | 7 +++++-- .../core/debates/matchmaking/matches-list.test.tsx | 11 +++++++++-- apps/web/core/debates/matchmaking/people-tab.test.tsx | 5 ++--- apps/web/core/debates/matchmaking/people-tab.tsx | 7 +++---- .../matchmaking/use-outbound-debate-challenge.ts | 2 ++ 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/web/core/claims/browse/use-claim-matchup.ts b/apps/web/core/claims/browse/use-claim-matchup.ts index c2147be63c..6f5ce8ca05 100644 --- a/apps/web/core/claims/browse/use-claim-matchup.ts +++ b/apps/web/core/claims/browse/use-claim-matchup.ts @@ -3,7 +3,10 @@ import type { DebateClaimPositionSummary } from '~/core/debates/api'; import { useDebateActivity } from '~/core/debates/hooks'; import { useCreateDebateRequest, useDebateRequests, useMatchmakingMatches } from '~/core/debates/matchmaking/hooks'; -import { useOutboundDebateChallenge } from '~/core/debates/matchmaking/use-outbound-debate-challenge'; +import { + PENDING_OUTBOUND_REQUEST_REASON, + useOutboundDebateChallenge, +} from '~/core/debates/matchmaking/use-outbound-debate-challenge'; import { useCurrentGeoChatUserId } from '~/core/debates/use-current-geo-chat-user-id'; import { ID } from '~/core/id'; @@ -51,7 +54,7 @@ export function useClaimMatchup({ const blockedReason = unavailable ? 'Switch yourself to available to send a request.' : outbound || outboundChallenge || outboundChallengeDirectionUnknown - ? 'Withdraw your open request to send another.' + ? PENDING_OUTBOUND_REQUEST_REASON : undefined; return { diff --git a/apps/web/core/debates/matchmaking/matches-list.test.tsx b/apps/web/core/debates/matchmaking/matches-list.test.tsx index 8ae4db7c61..05f840c032 100644 --- a/apps/web/core/debates/matchmaking/matches-list.test.tsx +++ b/apps/web/core/debates/matchmaking/matches-list.test.tsx @@ -403,13 +403,20 @@ describe('MatchesList', () => { expect(screen.getByRole('button', { name: 'Request debate' })).toBeDisabled(); }); - it('shows a sent person request in Lobby and blocks claim requests', () => { + 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(); - expect(screen.getByRole('button', { name: 'Request debate' })).toBeDisabled(); + const request = screen.getByRole('button', { name: 'Request debate' }); + expect(request).toBeDisabled(); + + 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 diff --git a/apps/web/core/debates/matchmaking/people-tab.test.tsx b/apps/web/core/debates/matchmaking/people-tab.test.tsx index 87235ef83a..a74a44fdea 100644 --- a/apps/web/core/debates/matchmaking/people-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.test.tsx @@ -722,6 +722,7 @@ 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.'); } }); @@ -847,9 +848,7 @@ describe('PeopleTab', () => { 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 d6febaab16..c17c6e9b41 100644 --- a/apps/web/core/debates/matchmaking/people-tab.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.tsx @@ -42,7 +42,7 @@ import type { PersonRecord } from './person-record'; import { PersonRecordLine } from './person-record-line'; import { isPersonId } from './person-records-document'; import { PersonSpaceIcons } from './person-space-icons'; -import { useOutboundDebateChallenge } from './use-outbound-debate-challenge'; +import { PENDING_OUTBOUND_REQUEST_REASON, useOutboundDebateChallenge } from './use-outbound-debate-challenge'; import { usePersonRecords } from './use-person-records'; import { useSpaceFilterMenu } from './use-space-filter-selection'; import { type DebatesHubTab, debatesHubPeopleSpaceIdsAtom } from '~/atoms'; @@ -298,15 +298,14 @@ export function PeopleTab({ onTabChange }: { onTabChange: (tab: DebatesHubTab) = : 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.' + ? 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) || createChallenge.isPending; const disabledReason = - blockedReason ?? - (createChallenge.isPending ? 'Sending your debate request…' : 'You have a debate request awaiting a reply.'); + blockedReason ?? (createChallenge.isPending ? 'Sending your debate request…' : PENDING_OUTBOUND_REQUEST_REASON); 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 index 1f1115a252..a621cab9d0 100644 --- a/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts +++ b/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts @@ -5,6 +5,8 @@ import * as React from 'react'; import type { DebateActivity, DebateChallenge } from '../api'; import { useUnexpiredRequests } from './use-request-countdown'; +export const PENDING_OUTBOUND_REQUEST_REASON = 'You can only have one pending outbound request at a time.'; + /** * The live person-to-person request sent by the viewer. * From d76b8b0121d70fb3636d5d70272eb053e77bfac2 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 12:16:57 -0700 Subject: [PATCH 05/16] fix: preserve inbound request prompts --- .../core/debates/debate-coordinator.test.tsx | 38 +++++++++++++++++++ apps/web/core/debates/debate-coordinator.tsx | 26 +++++++------ .../debates/matchmaking/matches-list.test.tsx | 1 + .../core/debates/request-debate-control.tsx | 6 ++- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/apps/web/core/debates/debate-coordinator.test.tsx b/apps/web/core/debates/debate-coordinator.test.tsx index cdc9d45a2f..7934d7b7c9 100644 --- a/apps/web/core/debates/debate-coordinator.test.tsx +++ b/apps/web/core/debates/debate-coordinator.test.tsx @@ -494,6 +494,26 @@ 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(); @@ -532,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 4cd4256d81..f2d979cd8e 100644 --- a/apps/web/core/debates/debate-coordinator.tsx +++ b/apps/web/core/debates/debate-coordinator.tsx @@ -179,16 +179,26 @@ 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); @@ -198,14 +208,6 @@ export function DebateCoordinator() { // 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. - // 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, since `activeFlow` - // above reads it to keep other popups from stacking on top of an outstanding challenge. - const isChallengeRecipient = promptedChallenge?.recipient.user_id === currentUserId; - // 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. const challengeForViewerId = challenge && challenge.recipient.user_id === currentUserId ? challenge.id : null; diff --git a/apps/web/core/debates/matchmaking/matches-list.test.tsx b/apps/web/core/debates/matchmaking/matches-list.test.tsx index 05f840c032..0e993ae02a 100644 --- a/apps/web/core/debates/matchmaking/matches-list.test.tsx +++ b/apps/web/core/debates/matchmaking/matches-list.test.tsx @@ -411,6 +411,7 @@ describe('MatchesList', () => { 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!); diff --git a/apps/web/core/debates/request-debate-control.tsx b/apps/web/core/debates/request-debate-control.tsx index ad79eae14d..2abc974086 100644 --- a/apps/web/core/debates/request-debate-control.tsx +++ b/apps/web/core/debates/request-debate-control.tsx @@ -124,7 +124,11 @@ export function RequestDebateControl({ // owns hover/touch and is focusable for keyboard readers, while leaving the button's // disabled semantics intact. trigger={ - + {button} } From d82391c392c3e722e3f6270a83baba22c2a58c81 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 13:09:40 -0700 Subject: [PATCH 06/16] refactor: consolidate debate challenge state --- .../core/claims/browse/use-claim-matchup.ts | 6 +- .../core/debates/hooks-query-network.test.tsx | 72 ++++++++++++++++++- apps/web/core/debates/hooks.ts | 35 +++++++-- .../core/debates/matchmaking/people-tab.tsx | 3 +- .../debates/matchmaking/requests-tab.test.tsx | 23 +++++- .../core/debates/matchmaking/requests-tab.tsx | 36 ++-------- .../use-outbound-debate-challenge.ts | 58 +++++++++++---- apps/web/core/debates/request-gate.ts | 2 + 8 files changed, 180 insertions(+), 55 deletions(-) diff --git a/apps/web/core/claims/browse/use-claim-matchup.ts b/apps/web/core/claims/browse/use-claim-matchup.ts index 6f5ce8ca05..f495bf588d 100644 --- a/apps/web/core/claims/browse/use-claim-matchup.ts +++ b/apps/web/core/claims/browse/use-claim-matchup.ts @@ -3,10 +3,8 @@ import type { DebateClaimPositionSummary } from '~/core/debates/api'; import { useDebateActivity } from '~/core/debates/hooks'; import { useCreateDebateRequest, useDebateRequests, useMatchmakingMatches } from '~/core/debates/matchmaking/hooks'; -import { - PENDING_OUTBOUND_REQUEST_REASON, - useOutboundDebateChallenge, -} from '~/core/debates/matchmaking/use-outbound-debate-challenge'; +import { useOutboundDebateChallenge } from '~/core/debates/matchmaking/use-outbound-debate-challenge'; +import { PENDING_OUTBOUND_REQUEST_REASON } from '~/core/debates/request-gate'; import { useCurrentGeoChatUserId } from '~/core/debates/use-current-geo-chat-user-id'; import { ID } from '~/core/id'; diff --git a/apps/web/core/debates/hooks-query-network.test.tsx b/apps/web/core/debates/hooks-query-network.test.tsx index 7edce0717d..7d3d8c4970 100644 --- a/apps/web/core/debates/hooks-query-network.test.tsx +++ b/apps/web/core/debates/hooks-query-network.test.tsx @@ -29,15 +29,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 +98,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 +106,7 @@ beforeEach(() => { mocks.useMutation.mockClear(); mocks.useQuery.mockClear(); mocks.useScope.mockClear(); + mocks.getDebateActivity.mockReset(); }); describe('debate query network ownership', () => { @@ -235,7 +244,9 @@ describe('debate query network ownership', () => { ['debates', 'account', 'user-a', 'activity'], expect.any(Function) ); - const update = mocks.queryClient.setQueryData.mock.calls.at(-1)?.[1] as (current: Record) => unknown; + const update = mocks.queryClient.setQueryData.mock.calls.at(-1)?.[1] as ( + current: Record + ) => unknown; expect(update({ online: true, challenge: null })).toEqual({ online: true, challenge, @@ -246,6 +257,65 @@ describe('debate query network ownership', () => { }); }); + 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 () => { + 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 }); + 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 }); + }); + // 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 35dbaa0fe6..de1a8ffd1a 100644 --- a/apps/web/core/debates/hooks.ts +++ b/apps/web/core/debates/hooks.ts @@ -469,6 +469,8 @@ 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; /** * The viewer's own debate state: the debate or rematch they are in, and the counts that gate the @@ -528,7 +530,18 @@ export function useDebateActivity(enabled = true) { const retainedOutbound = queryClient.getQueryData(activityKey)?.outbound_challenge; const outboundIsLive = retainedOutbound?.status === 'pending' && Date.parse(retainedOutbound.expires_at) > Date.now(); - return outboundIsLive && !activity.debate && !activity.rematch + // The create response can beat the activity read triggered by the gateway event. Keep its + // outbound row through that brief propagation window, then treat a null/non-pending server + // challenge as authoritative so a rejected request cannot block every button until expiry. + // A live server challenge also keeps the overlay: it may be an inbound row that displaced the + // simultaneous outbound row from the wire shape's single `challenge` field. + const serverChallengeIsLive = + activity.challenge?.status === 'pending' && Date.parse(activity.challenge.expires_at) > Date.now(); + const retainedOutboundCreatedAt = retainedOutbound ? Date.parse(retainedOutbound.created_at) : Number.NaN; + const outboundIsPropagating = + Number.isFinite(retainedOutboundCreatedAt) && + Date.now() - retainedOutboundCreatedAt < OUTBOUND_CHALLENGE_PROPAGATION_GRACE_MS; + return outboundIsLive && (serverChallengeIsLive || outboundIsPropagating) && !activity.debate && !activity.rematch ? { ...activity, outbound_challenge: retainedOutbound } : activity; }, @@ -558,8 +571,13 @@ 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_challenge: outboundChallenge, 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] : []), @@ -582,7 +600,13 @@ export function useDebateActivity(enabled = true) { const data = React.useMemo(() => { if (!query.data) return query.data; - const { challenge, outbound_challenge: outboundChallenge, outbound_request: outbound, debate, rematch } = 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 @@ -1287,8 +1311,7 @@ export function useRejectDebateChallenge() { ? { ...current, challenge: current.challenge?.id === challengeId ? null : current.challenge, - outbound_challenge: - current.outbound_challenge?.id === challengeId ? null : current.outbound_challenge, + outbound_challenge: current.outbound_challenge?.id === challengeId ? null : current.outbound_challenge, } : current ); diff --git a/apps/web/core/debates/matchmaking/people-tab.tsx b/apps/web/core/debates/matchmaking/people-tab.tsx index c17c6e9b41..021b7d55f8 100644 --- a/apps/web/core/debates/matchmaking/people-tab.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.tsx @@ -28,6 +28,7 @@ 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 } 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'; @@ -42,7 +43,7 @@ import type { PersonRecord } from './person-record'; import { PersonRecordLine } from './person-record-line'; import { isPersonId } from './person-records-document'; import { PersonSpaceIcons } from './person-space-icons'; -import { PENDING_OUTBOUND_REQUEST_REASON, useOutboundDebateChallenge } from './use-outbound-debate-challenge'; +import { useOutboundDebateChallenge } from './use-outbound-debate-challenge'; import { usePersonRecords } from './use-person-records'; import { useSpaceFilterMenu } from './use-space-filter-selection'; import { type DebatesHubTab, debatesHubPeopleSpaceIdsAtom } from '~/atoms'; diff --git a/apps/web/core/debates/matchmaking/requests-tab.test.tsx b/apps/web/core/debates/matchmaking/requests-tab.test.tsx index ececdabcb1..25cfd061ad 100644 --- a/apps/web/core/debates/matchmaking/requests-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/requests-tab.test.tsx @@ -58,7 +58,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', () => ({ @@ -310,6 +317,20 @@ describe('RequestsTab', () => { expect(screen.getByRole('button', { name: 'Explore claims' })).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 7f2e6367fa..7a88404d16 100644 --- a/apps/web/core/debates/matchmaking/requests-tab.tsx +++ b/apps/web/core/debates/matchmaking/requests-tab.tsx @@ -18,6 +18,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'; @@ -95,40 +96,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 reportedOutboundChallenge = - activity?.outbound_challenge?.status === 'pending' ? activity.outbound_challenge : null; - const liveOutboundChallenges = useUnexpiredRequests( - React.useMemo( - () => - reportedOutboundChallenge && reportedOutboundChallenge.id !== reportedChallenge?.id - ? [reportedOutboundChallenge] - : [], - [reportedChallenge?.id, reportedOutboundChallenge] - ) - ); - const retainedOutboundChallenge = liveOutboundChallenges[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 = - status === 'received' - ? null - : (retainedOutboundChallenge ?? (challengeRole === 'requester' ? 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 = diff --git a/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts b/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts index a621cab9d0..cf0afa8b36 100644 --- a/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts +++ b/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts @@ -5,7 +5,49 @@ import * as React from 'react'; import type { DebateActivity, DebateChallenge } from '../api'; import { useUnexpiredRequests } from './use-request-countdown'; -export const PENDING_OUTBOUND_REQUEST_REASON = 'You can only have one pending outbound request at a time.'; +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. @@ -22,18 +64,10 @@ export function useOutboundDebateChallenge( outboundChallenge: DebateChallenge | null; outboundChallengeDirectionUnknown: boolean; } { - const retained = activity?.outbound_challenge?.status === 'pending' ? activity.outbound_challenge : null; - const reported = activity?.challenge?.status === 'pending' ? activity.challenge : null; - const candidate = - retained ?? (reported && (!currentUserId || reported.requester.user_id === currentUserId) ? reported : null); - const liveCandidates = useUnexpiredRequests(React.useMemo(() => (candidate ? [candidate] : []), [candidate])); - const liveCandidate = liveCandidates[0] ?? null; - const retainedIsLive = Boolean(liveCandidate && retained?.id === liveCandidate.id); - const directionUnknown = Boolean(liveCandidate && !retainedIsLive && !currentUserId); + const { outboundChallenge, outboundChallengeDirectionUnknown } = useDebateChallengeState(activity, currentUserId); return { - outboundChallenge: - liveCandidate && (retainedIsLive || liveCandidate.requester.user_id === currentUserId) ? liveCandidate : null, - outboundChallengeDirectionUnknown: directionUnknown, + outboundChallenge, + outboundChallengeDirectionUnknown, }; } diff --git a/apps/web/core/debates/request-gate.ts b/apps/web/core/debates/request-gate.ts index d18a761bd0..eef5321a2f 100644 --- a/apps/web/core/debates/request-gate.ts +++ b/apps/web/core/debates/request-gate.ts @@ -63,6 +63,8 @@ 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.'; export function debateRequestGate({ chatPosition, From 36dfb068c277cbb0479ff083615ca3e0323146b6 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 13:39:49 -0700 Subject: [PATCH 07/16] fix: seed outbound challenge on cold cache --- .../core/debates/hooks-query-network.test.tsx | 24 +++++++++++- apps/web/core/debates/hooks.ts | 39 ++++++++++++------- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/apps/web/core/debates/hooks-query-network.test.tsx b/apps/web/core/debates/hooks-query-network.test.tsx index 7d3d8c4970..ae88637806 100644 --- a/apps/web/core/debates/hooks-query-network.test.tsx +++ b/apps/web/core/debates/hooks-query-network.test.tsx @@ -245,10 +245,30 @@ describe('debate query network ownership', () => { expect.any(Function) ); const update = mocks.queryClient.setQueryData.mock.calls.at(-1)?.[1] as ( - current: Record + current: Record | undefined ) => unknown; - expect(update({ online: true, challenge: null })).toEqual({ + 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, + }); + expect(update(undefined)).toEqual({ + online: true, + available_to_debate: true, + cooldown_until: null, + match: null, + debate: null, + rematch: null, challenge, outbound_challenge: challenge, }); diff --git a/apps/web/core/debates/hooks.ts b/apps/web/core/debates/hooks.ts index de1a8ffd1a..c9a4a089ba 100644 --- a/apps/web/core/debates/hooks.ts +++ b/apps/web/core/debates/hooks.ts @@ -471,6 +471,16 @@ const ACTIVITY_POLL_MS = 30_000; 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; /** * The viewer's own debate state: the debate or rematch they are in, and the counts that gate the @@ -532,9 +542,9 @@ export function useDebateActivity(enabled = true) { retainedOutbound?.status === 'pending' && Date.parse(retainedOutbound.expires_at) > Date.now(); // The create response can beat the activity read triggered by the gateway event. Keep its // outbound row through that brief propagation window, then treat a null/non-pending server - // challenge as authoritative so a rejected request cannot block every button until expiry. - // A live server challenge also keeps the overlay: it may be an inbound row that displaced the - // simultaneous outbound row from the wire shape's single `challenge` field. + // challenge as authoritative. A different live 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 serverChallengeIsLive = activity.challenge?.status === 'pending' && Date.parse(activity.challenge.expires_at) > Date.now(); const retainedOutboundCreatedAt = retainedOutbound ? Date.parse(retainedOutbound.created_at) : Number.NaN; @@ -970,9 +980,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, @@ -1256,16 +1265,16 @@ export function useCreateDebateChallenge() { mutationFn: (request: { recipient_profile_space_id: string }) => createDebateChallenge(request, getPrivyIdentityToken, accountKey), onSuccess: challenge => { - queryClient.setQueryData(debateQueryKeys.activity(accountKey), current => - current - ? { - ...current, - // Keep an inbound challenge in the wire field so its popup and Received card survive. - challenge: current.challenge ?? challenge, - outbound_challenge: challenge, - } - : current - ); + 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, + })); // 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. The gateway From 0cb64711243ff839cd6e74b7987d751bdc038ee6 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 14:09:59 -0700 Subject: [PATCH 08/16] fix: make challenge reconciliation clock-safe --- apps/web/core/debates/api.ts | 2 + .../core/debates/hooks-query-network.test.tsx | 82 ++++++++++++++++++- apps/web/core/debates/hooks.ts | 59 +++++++++---- 3 files changed, 124 insertions(+), 19 deletions(-) diff --git a/apps/web/core/debates/api.ts b/apps/web/core/debates/api.ts index 404010660f..49b66af981 100644 --- a/apps/web/core/debates/api.ts +++ b/apps/web/core/debates/api.ts @@ -240,6 +240,8 @@ export type DebateActivity = { * 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/hooks-query-network.test.tsx b/apps/web/core/debates/hooks-query-network.test.tsx index ae88637806..de16cdbb6d 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 { @@ -109,6 +109,10 @@ beforeEach(() => { mocks.getDebateActivity.mockReset(); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('debate query network ownership', () => { // The gateway owns freshness; a poll is an exception each query has to earn. Profile eligibility // and account activity are the two that have, and each is pinned by its own case below. @@ -232,6 +236,7 @@ describe('debate query network ownership', () => { }); it('keeps a newly created outbound challenge cached instead of refetching stale activity over it', () => { + vi.spyOn(performance, 'now').mockReturnValue(12_345); const { result } = renderHook(() => useCreateDebateChallenge()); const mutation = result.current as unknown as { onSuccess(challenge: { id: string }): void; @@ -261,6 +266,7 @@ describe('debate query network ownership', () => { ...warmActivity, challenge, outbound_challenge: challenge, + outbound_challenge_cached_at_monotonic_ms: 12_345, }); expect(update(undefined)).toEqual({ online: true, @@ -271,6 +277,7 @@ describe('debate query network ownership', () => { 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'], @@ -317,6 +324,7 @@ describe('debate query network ownership', () => { }); 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', @@ -324,7 +332,10 @@ describe('debate query network ownership', () => { expires_at: '2099-01-01T00:00:00.000Z', }; const activity = { challenge: null, debate: null, rematch: null }; - mocks.queryClient.getQueryData.mockReturnValue({ outbound_challenge: outbound }); + mocks.queryClient.getQueryData.mockReturnValue({ + outbound_challenge: outbound, + outbound_challenge_cached_at_monotonic_ms: 0, + }); mocks.getDebateActivity.mockResolvedValue(activity); renderHook(() => useDebateActivity()); @@ -333,7 +344,72 @@ describe('debate query network ownership', () => { }; const result = await query.queryFn({ signal: new AbortController().signal }); - expect(result).toEqual({ ...activity, outbound_challenge: outbound }); + 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 diff --git a/apps/web/core/debates/hooks.ts b/apps/web/core/debates/hooks.ts index c9a4a089ba..ec890c470b 100644 --- a/apps/web/core/debates/hooks.ts +++ b/apps/web/core/debates/hooks.ts @@ -537,23 +537,38 @@ export function useDebateActivity(enabled = true) { // 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 retainedOutbound = queryClient.getQueryData(activityKey)?.outbound_challenge; - const outboundIsLive = - retainedOutbound?.status === 'pending' && Date.parse(retainedOutbound.expires_at) > Date.now(); + 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 propagation window, then treat a null/non-pending server - // challenge as authoritative. A different live 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 serverChallengeIsLive = - activity.challenge?.status === 'pending' && Date.parse(activity.challenge.expires_at) > Date.now(); - const retainedOutboundCreatedAt = retainedOutbound ? Date.parse(retainedOutbound.created_at) : Number.NaN; + // 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 = - Number.isFinite(retainedOutboundCreatedAt) && - Date.now() - retainedOutboundCreatedAt < OUTBOUND_CHALLENGE_PROPAGATION_GRACE_MS; - return outboundIsLive && (serverChallengeIsLive || outboundIsPropagating) && !activity.debate && !activity.rematch - ? { ...activity, outbound_challenge: retainedOutbound } - : activity; + 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 @@ -1265,6 +1280,7 @@ export function useCreateDebateChallenge() { mutationFn: (request: { recipient_profile_space_id: string }) => createDebateChallenge(request, getPrivyIdentityToken, accountKey), onSuccess: challenge => { + 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; @@ -1274,6 +1290,7 @@ export function useCreateDebateChallenge() { // 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 @@ -1301,7 +1318,13 @@ export function useAcceptDebateChallenge() { } // Accepting an inbound challenge cancels the viewer's outbound one on the server. queryClient.setQueryData(debateQueryKeys.activity(accountKey), current => - current ? { ...current, outbound_challenge: null } : current + current + ? { + ...current, + outbound_challenge: null, + outbound_challenge_cached_at_monotonic_ms: null, + } + : current ); void queryClient.invalidateQueries({ queryKey: debateQueryKeys.activity(accountKey) }); }, @@ -1321,6 +1344,10 @@ export function useRejectDebateChallenge() { ...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 ); From 7834451c25c84c81f1d3545877d1b521ab245734 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 14:19:09 -0700 Subject: [PATCH 09/16] fix: reconcile outbound challenge after grace --- .../core/debates/hooks-query-network.test.tsx | 24 ++++++++++++++++++- apps/web/core/debates/hooks.ts | 8 +++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/web/core/debates/hooks-query-network.test.tsx b/apps/web/core/debates/hooks-query-network.test.tsx index de16cdbb6d..d2976418a6 100644 --- a/apps/web/core/debates/hooks-query-network.test.tsx +++ b/apps/web/core/debates/hooks-query-network.test.tsx @@ -110,6 +110,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -236,7 +237,8 @@ describe('debate query network ownership', () => { }); it('keeps a newly created outbound challenge cached instead of refetching stale activity over it', () => { - vi.spyOn(performance, 'now').mockReturnValue(12_345); + vi.useFakeTimers(); + vi.advanceTimersByTime(12_345); const { result } = renderHook(() => useCreateDebateChallenge()); const mutation = result.current as unknown as { onSuccess(challenge: { id: string }): void; @@ -284,6 +286,26 @@ describe('debate query network ownership', () => { }); }); + 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', diff --git a/apps/web/core/debates/hooks.ts b/apps/web/core/debates/hooks.ts index ec890c470b..914abd50f6 100644 --- a/apps/web/core/debates/hooks.ts +++ b/apps/web/core/debates/hooks.ts @@ -1294,8 +1294,12 @@ export function useCreateDebateChallenge() { })); // 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. The gateway - // and the activity poll still reconcile later changes; keep this response in place meanwhile. + // 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; From 6b95dd696567d208b11aca201821037619115072 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 14:33:16 -0700 Subject: [PATCH 10/16] fix: retain person requests through query transitions --- .../core/debates/hooks-query-network.test.tsx | 47 +++++++ apps/web/core/debates/hooks.ts | 19 ++- .../core/debates/matchmaking/hub-states.tsx | 131 ++++++++++-------- .../debates/matchmaking/requests-tab.test.tsx | 34 ++++- .../core/debates/matchmaking/requests-tab.tsx | 78 ++++++++--- 5 files changed, 223 insertions(+), 86 deletions(-) diff --git a/apps/web/core/debates/hooks-query-network.test.tsx b/apps/web/core/debates/hooks-query-network.test.tsx index d2976418a6..fdc0bf1908 100644 --- a/apps/web/core/debates/hooks-query-network.test.tsx +++ b/apps/web/core/debates/hooks-query-network.test.tsx @@ -16,6 +16,7 @@ import { useDebateTranscript, useRematchLiveKitJoin, useSpaceDebates, + useUpdateDebateAvailability, } from './hooks'; type QueryOptions = { queryKey: readonly unknown[] }; @@ -221,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 { diff --git a/apps/web/core/debates/hooks.ts b/apps/web/core/debates/hooks.ts index 914abd50f6..0f3c7af38b 100644 --- a/apps/web/core/debates/hooks.ts +++ b/apps/web/core/debates/hooks.ts @@ -482,6 +482,19 @@ const ACTIVITY_CACHE_FALLBACK = { 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 * incoming-request popup. @@ -748,10 +761,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); 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/requests-tab.test.tsx b/apps/web/core/debates/matchmaking/requests-tab.test.tsx index 25cfd061ad..915b60452a 100644 --- a/apps/web/core/debates/matchmaking/requests-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/requests-tab.test.tsx @@ -11,6 +11,9 @@ const mocks = vi.hoisted(() => ({ outbound: 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(), @@ -39,8 +42,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 }), @@ -131,6 +135,9 @@ beforeEach(() => { mocks.outbound = 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(); @@ -317,6 +324,29 @@ describe('RequestsTab', () => { 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' }; diff --git a/apps/web/core/debates/matchmaking/requests-tab.tsx b/apps/web/core/debates/matchmaking/requests-tab.tsx index 7a88404d16..bfad340da1 100644 --- a/apps/web/core/debates/matchmaking/requests-tab.tsx +++ b/apps/web/core/debates/matchmaking/requests-tab.tsx @@ -6,6 +6,7 @@ import { usePeerAvailabilityEnabled } from '~/core/state/feature-flags'; import { Text } from '~/design-system/text'; +import type { DebateChallenge, DebateRequest } from '../api'; import { useDebateActivity } from '../hooks'; import { useCurrentGeoChatUserId } from '../use-current-geo-chat-user-id'; import { DebateChallengeCard } from './challenge-card'; @@ -113,6 +114,15 @@ function RequestsTabBody({ scheduled.requestsError !== null || scheduled.roomsError !== null; const isEmpty = !sent && !outgoingChallenge && received.length === 0 && !incomingChallenge && !hasScheduled; + const challengeSections = + outgoingChallenge || incomingChallenge ? ( + + ) : null; return (
@@ -161,36 +171,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 (
From fbf662e2fe4e88824cb16a4b2c752000a9d503fb Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 14:58:06 -0700 Subject: [PATCH 11/16] fix: gate profile debate requests --- .../debates/profile-debate-button.test.tsx | 48 +++++++++++++++++++ .../core/debates/profile-debate-button.tsx | 43 ++++++++++++----- .../request-blocked-reason-tooltip.tsx | 39 +++++++++++++++ .../core/debates/request-debate-control.tsx | 22 ++------- 4 files changed, 123 insertions(+), 29 deletions(-) create mode 100644 apps/web/core/debates/request-blocked-reason-tooltip.tsx diff --git a/apps/web/core/debates/profile-debate-button.test.tsx b/apps/web/core/debates/profile-debate-button.test.tsx index 1b94ba4090..fe21abca12 100644 --- a/apps/web/core/debates/profile-debate-button.test.tsx +++ b/apps/web/core/debates/profile-debate-button.test.tsx @@ -7,10 +7,15 @@ const mocks = vi.hoisted(() => ({ canChallenge: true, createChallenge: vi.fn(), isPending: false, + activityOutboundRequest: null as { id: string } | null, + requestsOutboundRequest: null as { id: string } | null, + outboundChallenge: null as { id: string } | null, + outboundChallengeDirectionUnknown: 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 +23,30 @@ vi.mock('./hooks', () => ({ }), })); +vi.mock('./matchmaking/hooks', () => ({ + useDebateRequests: () => ({ data: { outbound: mocks.requestsOutboundRequest } }), +})); + +vi.mock('./matchmaking/use-outbound-debate-challenge', () => ({ + useOutboundDebateChallenge: () => ({ + outboundChallenge: mocks.outboundChallenge, + outboundChallengeDirectionUnknown: mocks.outboundChallengeDirectionUnknown, + }), +})); + +vi.mock('./use-current-geo-chat-user-id', () => ({ + useCurrentGeoChatUserId: () => 'viewer-user', +})); + const { ProfileDebateButton } = await import('./profile-debate-button'); beforeEach(() => { mocks.canChallenge = true; mocks.isPending = false; + mocks.activityOutboundRequest = null; + mocks.requestsOutboundRequest = null; + mocks.outboundChallenge = null; + mocks.outboundChallengeDirectionUnknown = false; mocks.createChallenge.mockReset(); }); @@ -60,6 +84,30 @@ describe('ProfileDebateButton', () => { expect(screen.getByRole('button', { name: 'Requesting...' })).toBeDisabled(); }); + 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('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..541b874648 100644 --- a/apps/web/core/debates/profile-debate-button.tsx +++ b/apps/web/core/debates/profile-debate-button.tsx @@ -4,7 +4,12 @@ import * as React from 'react'; import { Text } from '~/design-system/text'; -import { useCreateDebateChallenge, useDebateProfile } from './hooks'; +import { useCreateDebateChallenge, useDebateActivity, useDebateProfile } from './hooks'; +import { useDebateRequests } from './matchmaking/hooks'; +import { useOutboundDebateChallenge } from './matchmaking/use-outbound-debate-challenge'; +import { RequestBlockedReasonTooltip } from './request-blocked-reason-tooltip'; +import { PENDING_OUTBOUND_REQUEST_REASON } from './request-gate'; +import { useCurrentGeoChatUserId } from './use-current-geo-chat-user-id'; /** * Challenges the owner of a personal space to a debate with no claim attached. @@ -13,25 +18,39 @@ import { useCreateDebateChallenge, useDebateProfile } from './hooks'; */ export function ProfileDebateButton({ spaceId }: { spaceId: string }) { const profileQuery = useDebateProfile(spaceId); + const { data: activity } = useDebateActivity(); + // Read any request list already loaded by the hub or coordinator without making a profile page + // fetch it just to repeat the activity payload's gate. + const { data: requests } = useDebateRequests(false); + const currentUserId = useCurrentGeoChatUserId(); + const { outboundChallenge, outboundChallengeDirectionUnknown } = useOutboundDebateChallenge(activity, currentUserId); const createChallenge = useCreateDebateChallenge(); if (!profileQuery.data?.can_challenge) return null; const error = createChallenge.error instanceof Error ? createChallenge.error.message : null; + const blockedReason = + requests?.outbound || activity?.outbound_request || outboundChallenge || outboundChallengeDirectionUnknown + ? 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 2abc974086..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,23 +115,11 @@ export function RequestDebateControl({ return ( {blockedReason ? ( - - {button} - - } + fullWidth={variant === 'block'} + trigger={button} /> ) : ( button From 1e4c428514da3269bf60727c6944a0d64ea3b1cc Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 15:18:16 -0700 Subject: [PATCH 12/16] fix: refresh profile request gate --- .../debates/profile-debate-button.test.tsx | 19 ++++++++++++++++++- .../core/debates/profile-debate-button.tsx | 7 ++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/web/core/debates/profile-debate-button.test.tsx b/apps/web/core/debates/profile-debate-button.test.tsx index fe21abca12..1567952b13 100644 --- a/apps/web/core/debates/profile-debate-button.test.tsx +++ b/apps/web/core/debates/profile-debate-button.test.tsx @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ 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, })); @@ -24,7 +25,11 @@ vi.mock('./hooks', () => ({ })); vi.mock('./matchmaking/hooks', () => ({ - useDebateRequests: () => ({ data: { outbound: mocks.requestsOutboundRequest } }), + useDebateRequests: (enabled: boolean) => ({ + data: { + outbound: enabled ? mocks.requestsOutboundRequest : mocks.disabledRequestsOutboundRequest, + }, + }), })); vi.mock('./matchmaking/use-outbound-debate-challenge', () => ({ @@ -45,6 +50,7 @@ beforeEach(() => { mocks.isPending = false; mocks.activityOutboundRequest = null; mocks.requestsOutboundRequest = null; + mocks.disabledRequestsOutboundRequest = null; mocks.outboundChallenge = null; mocks.outboundChallengeDirectionUnknown = false; mocks.createChallenge.mockReset(); @@ -108,6 +114,17 @@ describe('ProfileDebateButton', () => { 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' }; + 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 541b874648..a16994fdce 100644 --- a/apps/web/core/debates/profile-debate-button.tsx +++ b/apps/web/core/debates/profile-debate-button.tsx @@ -19,9 +19,10 @@ import { useCurrentGeoChatUserId } from './use-current-geo-chat-user-id'; export function ProfileDebateButton({ spaceId }: { spaceId: string }) { const profileQuery = useDebateProfile(spaceId); const { data: activity } = useDebateActivity(); - // Read any request list already loaded by the hub or coordinator without making a profile page - // fetch it just to repeat the activity payload's gate. - const { data: requests } = useDebateRequests(false); + // 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 currentUserId = useCurrentGeoChatUserId(); const { outboundChallenge, outboundChallengeDirectionUnknown } = useOutboundDebateChallenge(activity, currentUserId); const createChallenge = useCreateDebateChallenge(); From 235b742bfdd052bcd730f1cfea167971783b8ba7 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 16:21:22 -0700 Subject: [PATCH 13/16] fix: close outbound request submission race --- apps/web/app/entry.tsx | 131 +++++++++--------- .../claims/browse/use-claim-matchup.test.tsx | 63 +++++++++ .../core/claims/browse/use-claim-matchup.ts | 9 +- .../core/debates/hooks-query-network.test.tsx | 8 ++ apps/web/core/debates/hooks.ts | 2 + .../debate-challenge-state-provider.tsx | 49 +++++++ .../core/debates/matchmaking/hooks.test.tsx | 27 +++- apps/web/core/debates/matchmaking/hooks.ts | 2 + .../debates/matchmaking/matches-list.test.tsx | 8 ++ .../debates/matchmaking/people-tab.test.tsx | 29 ++++ .../core/debates/matchmaking/people-tab.tsx | 12 +- .../debates/profile-debate-button.test.tsx | 20 ++- .../core/debates/profile-debate-button.tsx | 13 +- apps/web/core/debates/request-gate.ts | 3 + .../debates/use-outbound-request-creation.ts | 12 ++ 15 files changed, 302 insertions(+), 86 deletions(-) create mode 100644 apps/web/core/claims/browse/use-claim-matchup.test.tsx create mode 100644 apps/web/core/debates/matchmaking/debate-challenge-state-provider.tsx create mode 100644 apps/web/core/debates/use-outbound-request-creation.ts 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..146f779114 --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-matchup.test.tsx @@ -0,0 +1,63 @@ +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(() => ({ + outboundRequestCreationPending: false, + resolveOutboundChallenge: vi.fn(() => ({ + outboundChallenge: null, + outboundChallengeDirectionUnknown: false, + })), +})); + +vi.mock('~/core/debates/hooks', () => ({ + useDebateActivity: () => ({ data: { available_to_debate: true, outbound_request: null } }), +})); + +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.outboundRequestCreationPending = false; + mocks.resolveOutboundChallenge.mockClear(); +}); + +describe('useClaimMatchup', () => { + 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 f495bf588d..e5596d6fea 100644 --- a/apps/web/core/claims/browse/use-claim-matchup.ts +++ b/apps/web/core/claims/browse/use-claim-matchup.ts @@ -2,10 +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 { useOutboundDebateChallenge } from '~/core/debates/matchmaking/use-outbound-debate-challenge'; import { PENDING_OUTBOUND_REQUEST_REASON } from '~/core/debates/request-gate'; -import { useCurrentGeoChatUserId } from '~/core/debates/use-current-geo-chat-user-id'; import { ID } from '~/core/id'; /** @@ -32,8 +31,8 @@ export function useClaimMatchup({ const matchesQuery = useMatchmakingMatches(enabled); const requestsQuery = useDebateRequests(enabled); const { data: activity } = useDebateActivity(enabled); - const currentUserId = useCurrentGeoChatUserId(); - const { outboundChallenge, outboundChallengeDirectionUnknown } = useOutboundDebateChallenge(activity, currentUserId); + const { outboundChallenge, outboundChallengeDirectionUnknown, outboundRequestCreationPending } = + useSharedOutboundRequestState(); const createRequest = useCreateDebateRequest(); // `enabled: false` only stops this query from *fetching*. React Query still hands back whatever @@ -51,7 +50,7 @@ export function useClaimMatchup({ const unavailable = activity?.available_to_debate === false; const blockedReason = unavailable ? 'Switch yourself to available to send a request.' - : outbound || outboundChallenge || outboundChallengeDirectionUnknown + : outbound || outboundChallenge || outboundChallengeDirectionUnknown || outboundRequestCreationPending ? PENDING_OUTBOUND_REQUEST_REASON : undefined; diff --git a/apps/web/core/debates/hooks-query-network.test.tsx b/apps/web/core/debates/hooks-query-network.test.tsx index fdc0bf1908..6ab14483b6 100644 --- a/apps/web/core/debates/hooks-query-network.test.tsx +++ b/apps/web/core/debates/hooks-query-network.test.tsx @@ -283,6 +283,14 @@ 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); diff --git a/apps/web/core/debates/hooks.ts b/apps/web/core/debates/hooks.ts index 0f3c7af38b..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'; @@ -1292,6 +1293,7 @@ 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 => { diff --git a/apps/web/core/debates/matchmaking/debate-challenge-state-provider.tsx b/apps/web/core/debates/matchmaking/debate-challenge-state-provider.tsx new file mode 100644 index 0000000000..840dc5bae3 --- /dev/null +++ b/apps/web/core/debates/matchmaking/debate-challenge-state-provider.tsx @@ -0,0 +1,49 @@ +'use client'; + +import * as React from 'react'; + +import type { DebateChallenge } from '../api'; +import { useDebateActivity } from '../hooks'; +import { useCurrentGeoChatUserId } from '../use-current-geo-chat-user-id'; +import { useOutboundRequestCreationPending } from '../use-outbound-request-creation'; +import { useDebateChallengeState } from './use-outbound-debate-challenge'; + +type SharedOutboundRequestState = { + outboundChallenge: DebateChallenge | null; + outboundChallengeDirectionUnknown: boolean; + outboundRequestCreationPending: boolean; +}; + +const EMPTY_OUTBOUND_REQUEST_STATE: SharedOutboundRequestState = { + outboundChallenge: null, + outboundChallengeDirectionUnknown: false, + outboundRequestCreationPending: false, +}; + +const DebateChallengeStateContext = React.createContext(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..d4f42adc0c 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,28 @@ 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) + ); + }); +}); + /** * 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..9a67b96c51 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,6 +332,7 @@ export function useCreateDebateRequest() { const { accountKey, getPrivyIdentityToken } = useGeoChatAuth(); return useMutation({ + mutationKey: outboundRequestCreationMutationKey(accountKey), mutationFn: (request: CreateDebateRequestBody) => createDebateRequest(request, getPrivyIdentityToken, accountKey), onSuccess: () => void invalidateDebatesOutsideRematchClaims(queryClient), }); diff --git a/apps/web/core/debates/matchmaking/matches-list.test.tsx b/apps/web/core/debates/matchmaking/matches-list.test.tsx index 0e993ae02a..1efad43878 100644 --- a/apps/web/core/debates/matchmaking/matches-list.test.tsx +++ b/apps/web/core/debates/matchmaking/matches-list.test.tsx @@ -102,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', () => ({ diff --git a/apps/web/core/debates/matchmaking/people-tab.test.tsx b/apps/web/core/debates/matchmaking/people-tab.test.tsx index a74a44fdea..4fe6f5c43a 100644 --- a/apps/web/core/debates/matchmaking/people-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.test.tsx @@ -40,6 +40,7 @@ const mocks = vi.hoisted(() => ({ createChallenge: vi.fn(), createPending: false, createRecipientProfileSpaceId: null as string | null, + outboundRequestCreationPending: false, onTabChange: vi.fn(), cancelChallenge: vi.fn(), cancelPending: false, @@ -171,6 +172,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 }), })); @@ -314,6 +332,7 @@ beforeEach(() => { mocks.createChallenge.mockReset(); mocks.createPending = false; mocks.createRecipientProfileSpaceId = null; + mocks.outboundRequestCreationPending = false; mocks.onTabChange.mockReset(); mocks.cancelChallenge.mockReset(); mocks.cancelPending = false; @@ -755,6 +774,16 @@ describe('PeopleTab', () => { } }); + 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' }; diff --git a/apps/web/core/debates/matchmaking/people-tab.tsx b/apps/web/core/debates/matchmaking/people-tab.tsx index 021b7d55f8..d943b93b74 100644 --- a/apps/web/core/debates/matchmaking/people-tab.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.tsx @@ -33,6 +33,7 @@ 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 +44,6 @@ import type { PersonRecord } from './person-record'; import { PersonRecordLine } from './person-record-line'; import { isPersonId } from './person-records-document'; import { PersonSpaceIcons } from './person-space-icons'; -import { useOutboundDebateChallenge } from './use-outbound-debate-challenge'; import { usePersonRecords } from './use-person-records'; import { useSpaceFilterMenu } from './use-space-filter-selection'; import { type DebatesHubTab, debatesHubPeopleSpaceIdsAtom } from '~/atoms'; @@ -83,6 +83,11 @@ export function PeopleTab({ onTabChange }: { onTabChange: (tab: DebatesHubTab) = // 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(); @@ -288,9 +293,6 @@ export function PeopleTab({ onTabChange }: { onTabChange: (tab: DebatesHubTab) = // `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. - const { outboundChallenge, outboundChallengeDirectionUnknown: challengeDirectionUnknown } = - useOutboundDebateChallenge(activity, currentUserId); - // 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. @@ -298,7 +300,7 @@ export function PeopleTab({ onTabChange }: { onTabChange: (tab: DebatesHubTab) = ? 'You have a debate request awaiting a reply.' : activeDebate(activity) ? "You're already in a debate." - : activity?.outbound_request || requests?.outbound + : activity?.outbound_request || requests?.outbound || outboundRequestCreationPending ? PENDING_OUTBOUND_REQUEST_REASON : null; diff --git a/apps/web/core/debates/profile-debate-button.test.tsx b/apps/web/core/debates/profile-debate-button.test.tsx index 1567952b13..406541e3f0 100644 --- a/apps/web/core/debates/profile-debate-button.test.tsx +++ b/apps/web/core/debates/profile-debate-button.test.tsx @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ disabledRequestsOutboundRequest: null as { id: string } | null, outboundChallenge: null as { id: string } | null, outboundChallengeDirectionUnknown: false, + outboundRequestCreationPending: false, })); vi.mock('./hooks', () => ({ @@ -32,17 +33,14 @@ vi.mock('./matchmaking/hooks', () => ({ }), })); -vi.mock('./matchmaking/use-outbound-debate-challenge', () => ({ - useOutboundDebateChallenge: () => ({ +vi.mock('./matchmaking/debate-challenge-state-provider', () => ({ + useSharedOutboundRequestState: () => ({ outboundChallenge: mocks.outboundChallenge, outboundChallengeDirectionUnknown: mocks.outboundChallengeDirectionUnknown, + outboundRequestCreationPending: mocks.outboundRequestCreationPending, }), })); -vi.mock('./use-current-geo-chat-user-id', () => ({ - useCurrentGeoChatUserId: () => 'viewer-user', -})); - const { ProfileDebateButton } = await import('./profile-debate-button'); beforeEach(() => { @@ -53,6 +51,7 @@ beforeEach(() => { mocks.disabledRequestsOutboundRequest = null; mocks.outboundChallenge = null; mocks.outboundChallengeDirectionUnknown = false; + mocks.outboundRequestCreationPending = false; mocks.createChallenge.mockReset(); }); @@ -90,6 +89,15 @@ 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(); diff --git a/apps/web/core/debates/profile-debate-button.tsx b/apps/web/core/debates/profile-debate-button.tsx index a16994fdce..69407770b5 100644 --- a/apps/web/core/debates/profile-debate-button.tsx +++ b/apps/web/core/debates/profile-debate-button.tsx @@ -5,11 +5,10 @@ import * as React from 'react'; import { Text } from '~/design-system/text'; import { useCreateDebateChallenge, useDebateActivity, useDebateProfile } from './hooks'; +import { useSharedOutboundRequestState } from './matchmaking/debate-challenge-state-provider'; import { useDebateRequests } from './matchmaking/hooks'; -import { useOutboundDebateChallenge } from './matchmaking/use-outbound-debate-challenge'; import { RequestBlockedReasonTooltip } from './request-blocked-reason-tooltip'; import { PENDING_OUTBOUND_REQUEST_REASON } from './request-gate'; -import { useCurrentGeoChatUserId } from './use-current-geo-chat-user-id'; /** * Challenges the owner of a personal space to a debate with no claim attached. @@ -23,15 +22,19 @@ export function ProfileDebateButton({ spaceId }: { spaceId: string }) { // 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 currentUserId = useCurrentGeoChatUserId(); - const { outboundChallenge, outboundChallengeDirectionUnknown } = useOutboundDebateChallenge(activity, currentUserId); + 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 blockedReason = - requests?.outbound || activity?.outbound_request || outboundChallenge || outboundChallengeDirectionUnknown + requests?.outbound || + activity?.outbound_request || + outboundChallenge || + outboundChallengeDirectionUnknown || + outboundRequestCreationPending ? PENDING_OUTBOUND_REQUEST_REASON : null; diff --git a/apps/web/core/debates/request-gate.ts b/apps/web/core/debates/request-gate.ts index eef5321a2f..6e37717405 100644 --- a/apps/web/core/debates/request-gate.ts +++ b/apps/web/core/debates/request-gate.ts @@ -65,6 +65,9 @@ export const REQUEST_PENDING_LABEL = 'Publishing your position…'; 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; 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; +} From ed7716afe03a3e4ea483d9127f0309b5a99b931f Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 16:50:14 -0700 Subject: [PATCH 14/16] fix: hold request gate through reconciliation --- .../core/debates/matchmaking/hooks.test.tsx | 23 +++++++++++++++++++ apps/web/core/debates/matchmaking/hooks.ts | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/web/core/debates/matchmaking/hooks.test.tsx b/apps/web/core/debates/matchmaking/hooks.test.tsx index d4f42adc0c..ccb46e2cc7 100644 --- a/apps/web/core/debates/matchmaking/hooks.test.tsx +++ b/apps/web/core/debates/matchmaking/hooks.test.tsx @@ -124,6 +124,29 @@ describe('outbound request creation gate', () => { ).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)); + }); }); /** diff --git a/apps/web/core/debates/matchmaking/hooks.ts b/apps/web/core/debates/matchmaking/hooks.ts index 9a67b96c51..408de4df10 100644 --- a/apps/web/core/debates/matchmaking/hooks.ts +++ b/apps/web/core/debates/matchmaking/hooks.ts @@ -334,7 +334,7 @@ export function useCreateDebateRequest() { return useMutation({ mutationKey: outboundRequestCreationMutationKey(accountKey), mutationFn: (request: CreateDebateRequestBody) => createDebateRequest(request, getPrivyIdentityToken, accountKey), - onSuccess: () => void invalidateDebatesOutsideRematchClaims(queryClient), + onSuccess: () => invalidateDebatesOutsideRematchClaims(queryClient), }); } From c862d84d47329ac0515e44033efb68a04a8203d9 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 17:28:16 -0700 Subject: [PATCH 15/16] fix: prefer authoritative outbound request state --- .../claims/browse/use-claim-matchup.test.tsx | 12 +++++++++++- .../core/claims/browse/use-claim-matchup.ts | 4 ++-- .../core/debates/matchmaking/claims-tab.tsx | 3 ++- .../core/debates/matchmaking/matches-list.tsx | 3 ++- .../debates/matchmaking/people-tab.test.tsx | 9 ++++++++- .../core/debates/matchmaking/people-tab.tsx | 5 +++-- .../debates/matchmaking/requests-tab.test.tsx | 18 +++++++++++++++++- .../core/debates/matchmaking/requests-tab.tsx | 3 ++- .../debates/profile-debate-button.test.tsx | 1 + .../web/core/debates/profile-debate-button.tsx | 9 +++------ apps/web/core/debates/request-gate.ts | 13 +++++++++++++ 11 files changed, 64 insertions(+), 16 deletions(-) diff --git a/apps/web/core/claims/browse/use-claim-matchup.test.tsx b/apps/web/core/claims/browse/use-claim-matchup.test.tsx index 146f779114..bf9f388485 100644 --- a/apps/web/core/claims/browse/use-claim-matchup.test.tsx +++ b/apps/web/core/claims/browse/use-claim-matchup.test.tsx @@ -5,6 +5,7 @@ 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, @@ -13,7 +14,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('~/core/debates/hooks', () => ({ - useDebateActivity: () => ({ data: { available_to_debate: true, outbound_request: null } }), + useDebateActivity: () => ({ data: { available_to_debate: true, outbound_request: mocks.activityOutboundRequest } }), })); vi.mock('~/core/debates/matchmaking/hooks', () => ({ @@ -41,11 +42,20 @@ vi.mock('~/core/debates/use-current-geo-chat-user-id', () => ({ 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; diff --git a/apps/web/core/claims/browse/use-claim-matchup.ts b/apps/web/core/claims/browse/use-claim-matchup.ts index e5596d6fea..96fd2a1121 100644 --- a/apps/web/core/claims/browse/use-claim-matchup.ts +++ b/apps/web/core/claims/browse/use-claim-matchup.ts @@ -4,7 +4,7 @@ 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 } from '~/core/debates/request-gate'; +import { PENDING_OUTBOUND_REQUEST_REASON, resolveOutboundRequest } from '~/core/debates/request-gate'; import { ID } from '~/core/id'; /** @@ -45,7 +45,7 @@ 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 diff --git a/apps/web/core/debates/matchmaking/claims-tab.tsx b/apps/web/core/debates/matchmaking/claims-tab.tsx index c6adfcc73c..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, @@ -219,7 +220,7 @@ 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); diff --git a/apps/web/core/debates/matchmaking/matches-list.tsx b/apps/web/core/debates/matchmaking/matches-list.tsx index 2dc9499ae9..ff5e95c303 100644 --- a/apps/web/core/debates/matchmaking/matches-list.tsx +++ b/apps/web/core/debates/matchmaking/matches-list.tsx @@ -9,6 +9,7 @@ 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'; @@ -88,7 +89,7 @@ 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); diff --git a/apps/web/core/debates/matchmaking/people-tab.test.tsx b/apps/web/core/debates/matchmaking/people-tab.test.tsx index 4fe6f5c43a..2cc6b2dcbe 100644 --- a/apps/web/core/debates/matchmaking/people-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.test.tsx @@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({ 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, @@ -129,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 @@ -319,6 +324,7 @@ beforeEach(() => { 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'; @@ -874,6 +880,7 @@ describe('PeopleTab', () => { it('leaves the other blocked reasons alone', () => { mocks.outboundRequest = { id: 'request-1' }; + mocks.requestsDataAvailable = false; render(); expect(card()).not.toBeInTheDocument(); diff --git a/apps/web/core/debates/matchmaking/people-tab.tsx b/apps/web/core/debates/matchmaking/people-tab.tsx index d943b93b74..d13e57fe05 100644 --- a/apps/web/core/debates/matchmaking/people-tab.tsx +++ b/apps/web/core/debates/matchmaking/people-tab.tsx @@ -28,7 +28,7 @@ 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 } from '../request-gate'; +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'; @@ -78,6 +78,7 @@ 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, @@ -300,7 +301,7 @@ export function PeopleTab({ onTabChange }: { onTabChange: (tab: DebatesHubTab) = ? 'You have a debate request awaiting a reply.' : activeDebate(activity) ? "You're already in a debate." - : activity?.outbound_request || requests?.outbound || outboundRequestCreationPending + : outboundRequest || outboundRequestCreationPending ? PENDING_OUTBOUND_REQUEST_REASON : null; diff --git a/apps/web/core/debates/matchmaking/requests-tab.test.tsx b/apps/web/core/debates/matchmaking/requests-tab.test.tsx index 915b60452a..d89cc7fb42 100644 --- a/apps/web/core/debates/matchmaking/requests-tab.test.tsx +++ b/apps/web/core/debates/matchmaking/requests-tab.test.tsx @@ -9,6 +9,7 @@ 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, @@ -32,7 +33,11 @@ vi.mock('../hooks', async importOriginal => ({ useDebateSchedule: () => ({ blocks: [], isSet: false }), useSaveDebateSchedule: () => ({ mutate: vi.fn(), isPending: false }), useDebateActivity: () => ({ - data: { challenge: mocks.challenge, outbound_challenge: mocks.outboundChallenge, outbound_request: null }, + 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 }), @@ -133,6 +138,7 @@ 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; @@ -159,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'); diff --git a/apps/web/core/debates/matchmaking/requests-tab.tsx b/apps/web/core/debates/matchmaking/requests-tab.tsx index bfad340da1..bc996ac269 100644 --- a/apps/web/core/debates/matchmaking/requests-tab.tsx +++ b/apps/web/core/debates/matchmaking/requests-tab.tsx @@ -8,6 +8,7 @@ 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'; @@ -70,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), diff --git a/apps/web/core/debates/profile-debate-button.test.tsx b/apps/web/core/debates/profile-debate-button.test.tsx index 406541e3f0..869c928e05 100644 --- a/apps/web/core/debates/profile-debate-button.test.tsx +++ b/apps/web/core/debates/profile-debate-button.test.tsx @@ -124,6 +124,7 @@ describe('ProfileDebateButton', () => { 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' }); diff --git a/apps/web/core/debates/profile-debate-button.tsx b/apps/web/core/debates/profile-debate-button.tsx index 69407770b5..0c334f7843 100644 --- a/apps/web/core/debates/profile-debate-button.tsx +++ b/apps/web/core/debates/profile-debate-button.tsx @@ -8,7 +8,7 @@ import { useCreateDebateChallenge, useDebateActivity, useDebateProfile } from '. 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 } from './request-gate'; +import { PENDING_OUTBOUND_REQUEST_REASON, resolveOutboundRequest } from './request-gate'; /** * Challenges the owner of a personal space to a debate with no claim attached. @@ -29,12 +29,9 @@ export function ProfileDebateButton({ spaceId }: { spaceId: string }) { if (!profileQuery.data?.can_challenge) return null; const error = createChallenge.error instanceof Error ? createChallenge.error.message : null; + const outboundRequest = resolveOutboundRequest(requests, activity); const blockedReason = - requests?.outbound || - activity?.outbound_request || - outboundChallenge || - outboundChallengeDirectionUnknown || - outboundRequestCreationPending + outboundRequest || outboundChallenge || outboundChallengeDirectionUnknown || outboundRequestCreationPending ? PENDING_OUTBOUND_REQUEST_REASON : null; diff --git a/apps/web/core/debates/request-gate.ts b/apps/web/core/debates/request-gate.ts index 6e37717405..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. * @@ -69,6 +71,17 @@ export const PENDING_OUTBOUND_REQUEST_REASON = 'You can only have one pending ou 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, localPosition, From 09a9f1bc6f1fd0ffdcbb58bfc95a1c2a876a6f07 Mon Sep 17 00:00:00 2001 From: Preston Mantel Date: Thu, 24 Sep 2026 17:47:33 -0700 Subject: [PATCH 16/16] fix: wait for server clock before expiring requests --- .../matchmaking/use-request-countdown.test.ts | 45 ++++++++++++++++++ .../matchmaking/use-request-countdown.ts | 47 ++++++++++++------- 2 files changed, 75 insertions(+), 17 deletions(-) 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) {