+ );
+}
+
function RequestSection({ label, children }: { label: string; children: React.ReactNode }) {
return (
diff --git a/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts b/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts
new file mode 100644
index 0000000000..cf0afa8b36
--- /dev/null
+++ b/apps/web/core/debates/matchmaking/use-outbound-debate-challenge.ts
@@ -0,0 +1,73 @@
+'use client';
+
+import * as React from 'react';
+
+import type { DebateActivity, DebateChallenge } from '../api';
+import { useUnexpiredRequests } from './use-request-countdown';
+
+export type DebateChallengeRole = 'requester' | 'recipient';
+
+/**
+ * Every live person-request state the matchmaking surfaces need, resolved once.
+ *
+ * Keeping the server-reported challenge and the client-retained outbound challenge separate is
+ * load-bearing: both can exist at once, and the former may be inbound while the latter is outbound.
+ */
+export function useDebateChallengeState(
+ activity: DebateActivity | null | undefined,
+ currentUserId: string | null
+): {
+ challenge: DebateChallenge | null;
+ challengeRole: DebateChallengeRole | null;
+ outboundChallenge: DebateChallenge | null;
+ outboundChallengeDirectionUnknown: boolean;
+} {
+ const reported = activity?.challenge?.status === 'pending' ? activity.challenge : null;
+ const retained = activity?.outbound_challenge?.status === 'pending' ? activity.outbound_challenge : null;
+ const pendingChallenges = React.useMemo(() => {
+ if (!reported) return retained ? [retained] : [];
+ return retained && retained.id !== reported.id ? [reported, retained] : [reported];
+ }, [reported, retained]);
+ const liveChallenges = useUnexpiredRequests(pendingChallenges);
+ const challenge = reported ? (liveChallenges.find(candidate => candidate.id === reported.id) ?? null) : null;
+ const retainedOutbound = retained ? (liveChallenges.find(candidate => candidate.id === retained.id) ?? null) : null;
+ const challengeRole =
+ !challenge || !currentUserId
+ ? null
+ : challenge.requester.user_id === currentUserId
+ ? 'requester'
+ : challenge.recipient.user_id === currentUserId
+ ? 'recipient'
+ : null;
+ const outboundChallenge = retainedOutbound ?? (challengeRole === 'requester' ? challenge : null);
+
+ return {
+ challenge,
+ challengeRole,
+ outboundChallenge,
+ outboundChallengeDirectionUnknown: Boolean(challenge && !retainedOutbound && !currentUserId),
+ };
+}
+
+/**
+ * The live person-to-person request sent by the viewer.
+ *
+ * geo-chat's activity payload currently exposes whichever challenge involves the viewer in
+ * `challenge`, regardless of direction. The client-retained `outbound_challenge` removes that
+ * ambiguity when an incoming challenge and a newly sent challenge coexist; after a reload, the
+ * current user id identifies whether the server's single challenge is outbound.
+ */
+export function useOutboundDebateChallenge(
+ activity: DebateActivity | null | undefined,
+ currentUserId: string | null
+): {
+ outboundChallenge: DebateChallenge | null;
+ outboundChallengeDirectionUnknown: boolean;
+} {
+ const { outboundChallenge, outboundChallengeDirectionUnknown } = useDebateChallengeState(activity, currentUserId);
+
+ return {
+ outboundChallenge,
+ outboundChallengeDirectionUnknown,
+ };
+}
diff --git a/apps/web/core/debates/matchmaking/use-request-countdown.test.ts b/apps/web/core/debates/matchmaking/use-request-countdown.test.ts
index 89ef513550..6be85e9543 100644
--- a/apps/web/core/debates/matchmaking/use-request-countdown.test.ts
+++ b/apps/web/core/debates/matchmaking/use-request-countdown.test.ts
@@ -6,6 +6,7 @@ import { formatCountdown } from './use-request-countdown';
const mocks = vi.hoisted(() => ({
serverTimeMs: 0,
+ clockBarrier: null as Promise | null,
}));
// The clock is synchronized against the server so a skewed client still counts down correctly.
@@ -17,6 +18,7 @@ vi.mock('../server-clock', () => ({
createLocalServerClock: () => ({ now: () => Date.now(), roundTripMs: null }),
// The synchronized clock keeps advancing with (fake) time; only its offset comes from the server.
synchronizeServerClock: vi.fn(async () => {
+ await mocks.clockBarrier;
const offsetMs = mocks.serverTimeMs - Date.now();
return { now: () => Date.now() + offsetMs, roundTripMs: 0 };
}),
@@ -42,6 +44,7 @@ describe('useRequestCountdown', () => {
beforeEach(() => {
vi.useFakeTimers();
mocks.serverTimeMs = new Date('2026-08-05T12:00:00.000Z').getTime();
+ mocks.clockBarrier = null;
});
afterEach(() => {
@@ -64,6 +67,27 @@ describe('useRequestCountdown', () => {
expect(result.current.label).toBe('Expires in 25m');
});
+ it('does not report expiry from a skewed device clock while server time is resolving', async () => {
+ vi.setSystemTime(new Date('2026-08-05T13:00:00.000Z'));
+ let releaseClock!: () => void;
+ mocks.clockBarrier = new Promise(resolve => {
+ releaseClock = resolve;
+ });
+ const { useRequestCountdown } = await import('./use-request-countdown');
+
+ const { result } = renderHook(() => useRequestCountdown('2026-08-05T12:25:00.000Z'));
+
+ expect(result.current.expired).toBe(false);
+ expect(result.current.label).toBe('Expires later');
+
+ releaseClock();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(result.current.expired).toBe(false);
+ expect(result.current.label).toBe('Expires in 25m');
+ });
+
it('marks a request expired once the deadline passes', async () => {
vi.setSystemTime(new Date('2026-08-05T12:00:00.000Z'));
const { useRequestCountdown } = await import('./use-request-countdown');
@@ -111,6 +135,7 @@ describe('useUnexpiredRequests', () => {
beforeEach(() => {
vi.useFakeTimers();
mocks.serverTimeMs = new Date('2026-08-05T12:00:00.000Z').getTime();
+ mocks.clockBarrier = null;
});
afterEach(() => {
@@ -142,6 +167,26 @@ describe('useUnexpiredRequests', () => {
expect(vi.getTimerCount()).toBe(1);
});
+ it('keeps a server-live request while the skewed device clock is being corrected', async () => {
+ vi.setSystemTime(new Date('2026-08-05T13:00:00.000Z'));
+ let releaseClock!: () => void;
+ mocks.clockBarrier = new Promise(resolve => {
+ releaseClock = resolve;
+ });
+ const { useUnexpiredRequests } = await import('./use-request-countdown');
+ const requests = [{ id: 'live', expires_at: '2026-08-05T12:25:00.000Z' }];
+
+ const { result } = renderHook(() => useUnexpiredRequests(requests));
+
+ expect(result.current).toEqual(requests);
+
+ releaseClock();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(result.current).toEqual(requests);
+ });
+
it('filters requests that are already expired on mount and keeps unparseable ones', async () => {
vi.setSystemTime(new Date('2026-08-05T12:00:00.000Z'));
const { useUnexpiredRequests } = await import('./use-request-countdown');
diff --git a/apps/web/core/debates/matchmaking/use-request-countdown.ts b/apps/web/core/debates/matchmaking/use-request-countdown.ts
index 92cbfe675a..2b1b2a1eb3 100644
--- a/apps/web/core/debates/matchmaking/use-request-countdown.ts
+++ b/apps/web/core/debates/matchmaking/use-request-countdown.ts
@@ -63,14 +63,23 @@ export function useRequestCountdown(expiresAt: string): RequestCountdown {
const expiresAtMs = React.useMemo(() => new Date(expiresAt).getTime(), [expiresAt]);
const [now, setNow] = React.useState(() => Date.now());
- const remainingMs = Math.max(0, expiresAtMs - now);
+ // A server expiry cannot be judged against the device clock while synchronization is pending.
+ // Keep the useful local countdown when it is plausible, but never call a server-live request
+ // expired solely because a fast device clock says its timestamp has passed.
+ const localRemainingMs = Math.max(0, expiresAtMs - now);
+ const remainingMs = clock
+ ? Math.max(0, expiresAtMs - clock.now())
+ : localRemainingMs > 0
+ ? localRemainingMs
+ : Number.NaN;
// The tick rate only changes when we cross the final minute, so the effect re-arms then — and
// once more at expiry, where it clears the interval for good.
- const isFinalMinute = remainingMs <= MINUTE_MS;
- const expired = Number.isFinite(expiresAtMs) && remainingMs <= 0;
+ const isFinalMinute = Number.isFinite(remainingMs) && remainingMs <= MINUTE_MS;
+ const expired = Boolean(clock) && Number.isFinite(expiresAtMs) && remainingMs <= 0;
React.useEffect(() => {
- const read = () => (clock ? clock.now() : Date.now());
+ if (!clock) return;
+ const read = () => clock.now();
setNow(read());
if (expired || !Number.isFinite(expiresAtMs) || expiresAtMs - read() <= 0) return;
@@ -96,19 +105,22 @@ export function useUnexpiredRequests(requests:
// An empty list is the common case on the surfaces that mount globally, and it has no expiry to
// get wrong — so it doesn't pay for the clock. The sync starts when the first request lands.
const clock = useServerClock(requests.length > 0);
- const [now, setNow] = React.useState(() => Date.now());
+ const [, setNow] = React.useState(() => Date.now());
+ const serverNow = clock?.now();
const nextExpiryMs = React.useMemo(() => {
+ if (serverNow === undefined) return Infinity;
let next = Infinity;
for (const request of requests) {
const expires = new Date(request.expires_at).getTime();
- if (Number.isFinite(expires) && expires > now && expires < next) next = expires;
+ if (Number.isFinite(expires) && expires > serverNow && expires < next) next = expires;
}
return next;
- }, [now, requests]);
+ }, [requests, serverNow]);
React.useEffect(() => {
- const read = () => (clock ? clock.now() : Date.now());
+ if (!clock) return;
+ const read = () => clock.now();
setNow(read());
if (!Number.isFinite(nextExpiryMs)) return;
@@ -116,15 +128,16 @@ export function useUnexpiredRequests(requests:
return () => clearTimeout(timeout);
}, [clock, nextExpiryMs]);
- return React.useMemo(
- () =>
- requests.filter(request => {
- const expires = new Date(request.expires_at).getTime();
- // Unparseable expiries stay visible — matching the card, which only hides a finite past.
- return !Number.isFinite(expires) || expires > now;
- }),
- [now, requests]
- );
+ return React.useMemo(() => {
+ // Until synchronization resolves there is no trustworthy basis for declaring a server
+ // timestamp expired. Keeping the raw pending rows is the safe side of the one-request gate.
+ if (serverNow === undefined) return requests;
+ return requests.filter(request => {
+ const expires = new Date(request.expires_at).getTime();
+ // Unparseable expiries stay visible — matching the card, which only hides a finite past.
+ return !Number.isFinite(expires) || expires > serverNow;
+ });
+ }, [requests, serverNow]);
}
export function formatCountdown(remainingMs: number) {
diff --git a/apps/web/core/debates/profile-debate-button.test.tsx b/apps/web/core/debates/profile-debate-button.test.tsx
index 1b94ba4090..869c928e05 100644
--- a/apps/web/core/debates/profile-debate-button.test.tsx
+++ b/apps/web/core/debates/profile-debate-button.test.tsx
@@ -7,10 +7,17 @@ const mocks = vi.hoisted(() => ({
canChallenge: true,
createChallenge: vi.fn(),
isPending: false,
+ activityOutboundRequest: null as { id: string } | null,
+ requestsOutboundRequest: null as { id: string } | null,
+ disabledRequestsOutboundRequest: null as { id: string } | null,
+ outboundChallenge: null as { id: string } | null,
+ outboundChallengeDirectionUnknown: false,
+ outboundRequestCreationPending: false,
}));
vi.mock('./hooks', () => ({
useDebateProfile: () => ({ data: { can_challenge: mocks.canChallenge } }),
+ useDebateActivity: () => ({ data: { outbound_request: mocks.activityOutboundRequest } }),
useCreateDebateChallenge: () => ({
mutate: mocks.createChallenge,
isPending: mocks.isPending,
@@ -18,11 +25,33 @@ vi.mock('./hooks', () => ({
}),
}));
+vi.mock('./matchmaking/hooks', () => ({
+ useDebateRequests: (enabled: boolean) => ({
+ data: {
+ outbound: enabled ? mocks.requestsOutboundRequest : mocks.disabledRequestsOutboundRequest,
+ },
+ }),
+}));
+
+vi.mock('./matchmaking/debate-challenge-state-provider', () => ({
+ useSharedOutboundRequestState: () => ({
+ outboundChallenge: mocks.outboundChallenge,
+ outboundChallengeDirectionUnknown: mocks.outboundChallengeDirectionUnknown,
+ outboundRequestCreationPending: mocks.outboundRequestCreationPending,
+ }),
+}));
+
const { ProfileDebateButton } = await import('./profile-debate-button');
beforeEach(() => {
mocks.canChallenge = true;
mocks.isPending = false;
+ mocks.activityOutboundRequest = null;
+ mocks.requestsOutboundRequest = null;
+ mocks.disabledRequestsOutboundRequest = null;
+ mocks.outboundChallenge = null;
+ mocks.outboundChallengeDirectionUnknown = false;
+ mocks.outboundRequestCreationPending = false;
mocks.createChallenge.mockReset();
});
@@ -60,6 +89,51 @@ describe('ProfileDebateButton', () => {
expect(screen.getByRole('button', { name: 'Requesting...' })).toBeDisabled();
});
+ it('blocks while a request is being created by another control', () => {
+ mocks.outboundRequestCreationPending = true;
+ render();
+
+ const button = screen.getByRole('button', { name: 'Request debate' });
+ expect(button).toBeDisabled();
+ expect(screen.getByTitle('You can only have one pending outbound request at a time.')).toContainElement(button);
+ });
+
+ it('blocks another person request while an outbound person challenge is pending', () => {
+ mocks.outboundChallenge = { id: 'challenge-outbound' };
+ render();
+
+ const button = screen.getByRole('button', { name: 'Request debate' });
+ expect(button).toBeDisabled();
+ expect(screen.getByTitle('You can only have one pending outbound request at a time.')).toContainElement(button);
+
+ fireEvent.click(button);
+ expect(mocks.createChallenge).not.toHaveBeenCalled();
+ });
+
+ it('blocks another person request while an outbound claim request is pending', () => {
+ mocks.requestsOutboundRequest = { id: 'claim-request-outbound' };
+ render();
+
+ const button = screen.getByRole('button', { name: 'Request debate' });
+ expect(button).toBeDisabled();
+ expect(screen.getByTitle('You can only have one pending outbound request at a time.')).toContainElement(button);
+
+ fireEvent.click(button);
+ expect(mocks.createChallenge).not.toHaveBeenCalled();
+ });
+
+ it('stops blocking after the authoritative request list clears a stale cached outbound request', () => {
+ mocks.disabledRequestsOutboundRequest = { id: 'stale-claim-request' };
+ mocks.activityOutboundRequest = { id: 'stale-activity-request' };
+ render();
+
+ const button = screen.getByRole('button', { name: 'Request debate' });
+ expect(button).toBeEnabled();
+
+ fireEvent.click(button);
+ expect(mocks.createChallenge).toHaveBeenCalledWith({ recipient_profile_space_id: 'profile-them' });
+ });
+
it('stays hidden when the server says this person cannot be challenged', () => {
mocks.canChallenge = false;
const { container } = render();
diff --git a/apps/web/core/debates/profile-debate-button.tsx b/apps/web/core/debates/profile-debate-button.tsx
index 984bda6273..0c334f7843 100644
--- a/apps/web/core/debates/profile-debate-button.tsx
+++ b/apps/web/core/debates/profile-debate-button.tsx
@@ -4,7 +4,11 @@ import * as React from 'react';
import { Text } from '~/design-system/text';
-import { useCreateDebateChallenge, useDebateProfile } from './hooks';
+import { useCreateDebateChallenge, useDebateActivity, useDebateProfile } from './hooks';
+import { useSharedOutboundRequestState } from './matchmaking/debate-challenge-state-provider';
+import { useDebateRequests } from './matchmaking/hooks';
+import { RequestBlockedReasonTooltip } from './request-blocked-reason-tooltip';
+import { PENDING_OUTBOUND_REQUEST_REASON, resolveOutboundRequest } from './request-gate';
/**
* Challenges the owner of a personal space to a debate with no claim attached.
@@ -13,25 +17,41 @@ import { useCreateDebateChallenge, useDebateProfile } from './hooks';
*/
export function ProfileDebateButton({ spaceId }: { spaceId: string }) {
const profileQuery = useDebateProfile(spaceId);
+ const { data: activity } = useDebateActivity();
+ // The request list is authoritative for claim requests while this control is visible. It must be
+ // enabled rather than cache-only: `debate.requests_changed` invalidates this key when a request
+ // ends, and a disabled observer would keep gating on its stale outbound row indefinitely.
+ const { data: requests } = useDebateRequests(profileQuery.data?.can_challenge === true);
+ const { outboundChallenge, outboundChallengeDirectionUnknown, outboundRequestCreationPending } =
+ useSharedOutboundRequestState();
const createChallenge = useCreateDebateChallenge();
if (!profileQuery.data?.can_challenge) return null;
const error = createChallenge.error instanceof Error ? createChallenge.error.message : null;
+ const outboundRequest = resolveOutboundRequest(requests, activity);
+ const blockedReason =
+ outboundRequest || outboundChallenge || outboundChallengeDirectionUnknown || outboundRequestCreationPending
+ ? PENDING_OUTBOUND_REQUEST_REASON
+ : null;
+
+ const button = (
+
+ );
return (
-
+ {blockedReason ? : button}
{error && (
{error}
diff --git a/apps/web/core/debates/request-blocked-reason-tooltip.tsx b/apps/web/core/debates/request-blocked-reason-tooltip.tsx
new file mode 100644
index 0000000000..7c438a85fd
--- /dev/null
+++ b/apps/web/core/debates/request-blocked-reason-tooltip.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import type { ReactNode } from 'react';
+
+import cx from 'classnames';
+
+import { Tooltip } from '~/design-system/tooltip';
+
+/**
+ * Makes the reason for a disabled debate-request control reachable by pointer, touch, and keyboard.
+ *
+ * A disabled button does not reliably emit the pointer events Radix needs, so the wrapper owns the
+ * interactions while leaving the button's disabled semantics intact.
+ */
+export function RequestBlockedReasonTooltip({
+ reason,
+ trigger,
+ align = 'end',
+ fullWidth = false,
+}: {
+ reason: string;
+ trigger: ReactNode;
+ align?: 'start' | 'center' | 'end';
+ fullWidth?: boolean;
+}) {
+ return (
+
+ {trigger}
+
+ }
+ />
+ );
+}
diff --git a/apps/web/core/debates/request-debate-control.tsx b/apps/web/core/debates/request-debate-control.tsx
index ad79eae14d..747f18ca74 100644
--- a/apps/web/core/debates/request-debate-control.tsx
+++ b/apps/web/core/debates/request-debate-control.tsx
@@ -4,7 +4,7 @@ import * as React from 'react';
import cx from 'classnames';
-import { Tooltip } from '~/design-system/tooltip';
+import { RequestBlockedReasonTooltip } from './request-blocked-reason-tooltip';
/**
* The offer to debate a claim, as one control (GEO-2825).
@@ -115,19 +115,11 @@ export function RequestDebateControl({
return (
{blockedReason ? (
-
- {button}
-
- }
+ fullWidth={variant === 'block'}
+ trigger={button}
/>
) : (
button
diff --git a/apps/web/core/debates/request-gate.ts b/apps/web/core/debates/request-gate.ts
index d18a761bd0..ccda142356 100644
--- a/apps/web/core/debates/request-gate.ts
+++ b/apps/web/core/debates/request-gate.ts
@@ -1,3 +1,5 @@
+import type { DebateActivity, DebateRequest, DebateRequestsResponse } from './api';
+
/**
* Determines whether a debate request can be created for a claim.
*
@@ -63,6 +65,22 @@ export type DebateRequestGate = {
export const REQUEST_PENDING_LABEL = 'Publishing your position…';
/** The publish landed and the confirmation is late; pointing at the transaction would mislead. */
export const REQUEST_PENDING_DELAYED_LABEL = 'Still confirming your position…';
+/** One account may have only one unanswered request, regardless of whether it targets a claim or person. */
+export const PENDING_OUTBOUND_REQUEST_REASON = 'You can only have one pending outbound request at a time.';
+/** Both create APIs share one account-scoped key so every request control sees either one in flight. */
+export const outboundRequestCreationMutationKey = (accountKey: string | null) =>
+ ['debates', 'account', accountKey, 'create-outbound-request'] as const;
+
+/**
+ * The request list is authoritative once it has loaded, including when it explicitly says there
+ * is no outbound request. Activity is only the cold-load fallback while that list is unavailable.
+ */
+export function resolveOutboundRequest(
+ requests: DebateRequestsResponse | undefined,
+ activity: DebateActivity | undefined
+): DebateRequest | null {
+ return requests === undefined ? (activity?.outbound_request ?? null) : requests.outbound;
+}
export function debateRequestGate({
chatPosition,
diff --git a/apps/web/core/debates/use-outbound-request-creation.ts b/apps/web/core/debates/use-outbound-request-creation.ts
new file mode 100644
index 0000000000..83c6120b93
--- /dev/null
+++ b/apps/web/core/debates/use-outbound-request-creation.ts
@@ -0,0 +1,12 @@
+'use client';
+
+import { useIsMutating } from '@tanstack/react-query';
+
+import { useGeoChatAuth } from './hooks';
+import { outboundRequestCreationMutationKey } from './request-gate';
+
+/** Whether either outbound request API is currently submitting for this query-client/account scope. */
+export function useOutboundRequestCreationPending(): boolean {
+ const { accountKey } = useGeoChatAuth();
+ return useIsMutating({ mutationKey: outboundRequestCreationMutationKey(accountKey), exact: true }) > 0;
+}