Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 67 additions & 64 deletions apps/web/app/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -132,72 +133,74 @@ export function App({ children }: { children: React.ReactNode }) {
}, [fullscreenActive]);

return (
<DebateMediaSessionProvider>
<div className="flex min-h-[100dvh] items-stretch">
<React.Suspense fallback={null}>
<PageViewTracker />
</React.Suspense>
<div className="mobile:hidden">{!fullscreenActive && <BrowseSidebar />}</div>
<div className="flex min-w-0 flex-1 flex-col">
<Navbar
browseOpen={mobileBrowseOpen && !fullscreenActive}
browseButtonRef={mobileBrowseButtonRef}
navbarRef={navbarRef}
onBrowseClick={() => setMobileBrowseOpen(true)}
onSearchClick={() => setOpen(true)}
hideLogo={sidebarOpen && !fullscreenActive}
showBrowseButton={!fullscreenActive}
/>
<MobileBrowseDrawer
open={mobileBrowseOpen && !fullscreenActive}
fallbackFocusRef={navbarRef}
fullscreenFocusTarget={rankingFullscreenFocusTarget}
onOpenChange={setMobileBrowseOpen}
triggerRef={mobileBrowseButtonRef}
/>
<SearchDialog open={open} onDone={() => setOpen(false)} />
<div className="min-w-0 flex-1 2xl:px-[2ch]">
<Main>{children}</Main>
</div>
</div>
<SlideUpBodyState />
<EntitySidePanel />
<PlaybackDiagnostics />
<EntityCommentsPanelHost />
{/* Client-side rendered due to `window.localStorage` usage */}
<ClientOnly>
<OnboardingDialog />
<PendingPersonalSpaceRunner />
<PendingActionsRunner />
<CreateSpaceDialog />
<PendingCreatedSpaceRunner />
<PendingCreatedSpaceStatus />
<SignInPrompt />
<PostAuthRedirect />
<DebateChallengeStateProvider>
<DebateMediaSessionProvider>
<div className="flex min-h-[100dvh] items-stretch">
<React.Suspense fallback={null}>
<DeepLinkHandler />
<PageViewTracker />
</React.Suspense>
<Toast />
<GovernanceReopenEditLoadingBar />
<FlowBar />
<StatusBar />
<ReviewChanges />
<ChatWidget />
<FeatureFlagsDialog />
<DebateCoordinator />
{/* Suspense: the panel reads `useSearchParams` to tell a debates deep link apart from
<div className="mobile:hidden">{!fullscreenActive && <BrowseSidebar />}</div>
<div className="flex min-w-0 flex-1 flex-col">
<Navbar
browseOpen={mobileBrowseOpen && !fullscreenActive}
browseButtonRef={mobileBrowseButtonRef}
navbarRef={navbarRef}
onBrowseClick={() => setMobileBrowseOpen(true)}
onSearchClick={() => setOpen(true)}
hideLogo={sidebarOpen && !fullscreenActive}
showBrowseButton={!fullscreenActive}
/>
<MobileBrowseDrawer
open={mobileBrowseOpen && !fullscreenActive}
fallbackFocusRef={navbarRef}
fullscreenFocusTarget={rankingFullscreenFocusTarget}
onOpenChange={setMobileBrowseOpen}
triggerRef={mobileBrowseButtonRef}
/>
<SearchDialog open={open} onDone={() => setOpen(false)} />
<div className="min-w-0 flex-1 2xl:px-[2ch]">
<Main>{children}</Main>
</div>
</div>
<SlideUpBodyState />
<EntitySidePanel />
<PlaybackDiagnostics />
<EntityCommentsPanelHost />
{/* Client-side rendered due to `window.localStorage` usage */}
<ClientOnly>
<OnboardingDialog />
<PendingPersonalSpaceRunner />
<PendingActionsRunner />
<CreateSpaceDialog />
<PendingCreatedSpaceRunner />
<PendingCreatedSpaceStatus />
<SignInPrompt />
<PostAuthRedirect />
<React.Suspense fallback={null}>
<DeepLinkHandler />
</React.Suspense>
<Toast />
<GovernanceReopenEditLoadingBar />
<FlowBar />
<StatusBar />
<ReviewChanges />
<ChatWidget />
<FeatureFlagsDialog />
<DebateCoordinator />
{/* Suspense: the panel reads `useSearchParams` to tell a debates deep link apart from
an ordinary navigation. */}
<React.Suspense fallback={null}>
<DebatesHubPanel />
</React.Suspense>
<DebateRecordingUploadCoordinator />
<Persistence />
</ClientOnly>
{process.env.NODE_ENV === 'production' && <Analytics />}
</div>
<React.Suspense fallback={null}>
<PersonalProfileCreatePostSidePanelSync />
</React.Suspense>
</DebateMediaSessionProvider>
<React.Suspense fallback={null}>
<DebatesHubPanel />
</React.Suspense>
<DebateRecordingUploadCoordinator />
<Persistence />
</ClientOnly>
{process.env.NODE_ENV === 'production' && <Analytics />}
</div>
<React.Suspense fallback={null}>
<PersonalProfileCreatePostSidePanelSync />
</React.Suspense>
</DebateMediaSessionProvider>
</DebateChallengeStateProvider>
);
}
73 changes: 73 additions & 0 deletions apps/web/core/claims/browse/use-claim-matchup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { renderHook } from '@testing-library/react';

import { beforeEach, describe, expect, it, vi } from 'vitest';

import { PENDING_OUTBOUND_REQUEST_REASON } from '~/core/debates/request-gate';

const mocks = vi.hoisted(() => ({
activityOutboundRequest: null as { id: string } | null,
outboundRequestCreationPending: false,
resolveOutboundChallenge: vi.fn(() => ({
outboundChallenge: null,
outboundChallengeDirectionUnknown: false,
})),
}));

vi.mock('~/core/debates/hooks', () => ({
useDebateActivity: () => ({ data: { available_to_debate: true, outbound_request: mocks.activityOutboundRequest } }),
}));

vi.mock('~/core/debates/matchmaking/hooks', () => ({
useMatchmakingMatches: () => ({ data: { matches: [] } }),
useDebateRequests: () => ({ data: { outbound: null } }),
useCreateDebateRequest: () => ({ mutate: vi.fn(), isPending: false, error: null }),
}));

vi.mock('~/core/debates/matchmaking/use-outbound-debate-challenge', () => ({
useOutboundDebateChallenge: () => mocks.resolveOutboundChallenge(),
}));

vi.mock('~/core/debates/matchmaking/debate-challenge-state-provider', () => ({
useSharedOutboundRequestState: () => ({
outboundChallenge: null,
outboundChallengeDirectionUnknown: false,
outboundRequestCreationPending: mocks.outboundRequestCreationPending,
}),
}));

vi.mock('~/core/debates/use-current-geo-chat-user-id', () => ({
useCurrentGeoChatUserId: () => 'viewer-user',
}));

const { useClaimMatchup } = await import('./use-claim-matchup');

beforeEach(() => {
mocks.activityOutboundRequest = null;
mocks.outboundRequestCreationPending = false;
mocks.resolveOutboundChallenge.mockClear();
});

describe('useClaimMatchup', () => {
it('trusts an authoritative empty request list over stale activity', () => {
mocks.activityOutboundRequest = { id: 'stale-activity-request' };

const { result } = renderHook(() => useClaimMatchup({ claimId: 'claim-1', spaceId: 'space-1' }));

expect(result.current.blockedReason).toBeUndefined();
});

it('blocks while another control is creating an outbound request', () => {
mocks.outboundRequestCreationPending = true;

const { result } = renderHook(() => useClaimMatchup({ claimId: 'claim-1', spaceId: 'space-1' }));

expect(result.current.blockedReason).toBe(PENDING_OUTBOUND_REQUEST_REASON);
});

it('does not resolve account-level challenge identity and expiry once per claim card', () => {
renderHook(() => useClaimMatchup({ claimId: 'claim-1', spaceId: 'space-1' }));
renderHook(() => useClaimMatchup({ claimId: 'claim-2', spaceId: 'space-1' }));

expect(mocks.resolveOutboundChallenge).not.toHaveBeenCalled();
});
});
10 changes: 7 additions & 3 deletions apps/web/core/claims/browse/use-claim-matchup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import type { DebateClaimPositionSummary } from '~/core/debates/api';
import { useDebateActivity } from '~/core/debates/hooks';
import { useSharedOutboundRequestState } from '~/core/debates/matchmaking/debate-challenge-state-provider';
import { useCreateDebateRequest, useDebateRequests, useMatchmakingMatches } from '~/core/debates/matchmaking/hooks';
import { PENDING_OUTBOUND_REQUEST_REASON, resolveOutboundRequest } from '~/core/debates/request-gate';
import { ID } from '~/core/id';

/**
Expand All @@ -29,6 +31,8 @@ export function useClaimMatchup({
const matchesQuery = useMatchmakingMatches(enabled);
const requestsQuery = useDebateRequests(enabled);
const { data: activity } = useDebateActivity(enabled);
const { outboundChallenge, outboundChallengeDirectionUnknown, outboundRequestCreationPending } =
useSharedOutboundRequestState();
const createRequest = useCreateDebateRequest();

// `enabled: false` only stops this query from *fetching*. React Query still hands back whatever
Expand All @@ -41,13 +45,13 @@ export function useClaimMatchup({
candidate => ID.equals(candidate.claim.claim_entity_id, claimId) && ID.equals(candidate.claim.space_id, spaceId)
) ?? null);

const outbound = requestsQuery.data?.outbound ?? activity?.outbound_request ?? null;
const outbound = resolveOutboundRequest(requestsQuery.data, activity);
// Only when the server actually says so — a missing field must not block requesting.
const unavailable = activity?.available_to_debate === false;
const blockedReason = unavailable
? 'Switch yourself to available to send a request.'
: outbound
? 'Withdraw your open request to send another.'
: outbound || outboundChallenge || outboundChallengeDirectionUnknown || outboundRequestCreationPending
? PENDING_OUTBOUND_REQUEST_REASON
: undefined;

return {
Expand Down
7 changes: 7 additions & 0 deletions apps/web/core/debates/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,13 @@ export type DebateActivity = {
debate: Debate | null;
rematch: DebateRematchSession | null;
challenge: DebateChallenge | null;
/**
* Client-retained copy of a challenge the viewer sent while `challenge` still reports an inbound
* one. The activity endpoint currently exposes only one challenge even though both can coexist.
*/
outbound_challenge?: DebateChallenge | null;
/** Local monotonic instant when `outbound_challenge` was cached; never supplied by geo-chat. */
outbound_challenge_cached_at_monotonic_ms?: number | null;
/**
* The single debate request the viewer currently has awaiting a response. Optional until
* geo-chat ships `debate_matchmaking_v1`.
Expand Down
86 changes: 86 additions & 0 deletions apps/web/core/debates/debate-coordinator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DebateCoordinator />);
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(<DebateCoordinator />);
mocks.requests = { outbound: null, incoming: [request] };
view.rerender(<DebateCoordinator />);

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 () => {
Expand All @@ -474,6 +494,54 @@ describe('DebateCoordinator', () => {
expect(screen.getByRole('button', { name: 'Explore claims' })).toBeInTheDocument();
});

it('prompts an inbound person request while retaining a separate outbound person request', async () => {
mocks.currentUserId = 'user-recipient';
const inbound = pendingChallenge();
mocks.activity = {
...idleActivity(),
challenge: inbound,
outbound_challenge: {
...inbound,
id: 'challenge-outbound',
requester: inbound.recipient,
recipient: inbound.requester,
},
};

render(<DebateCoordinator />);

expect(await screen.findByText('Debate request')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Explore claims' })).toBeInTheDocument();
});

it('does not reopen a snoozed challenge when activity switches between inbound and outbound', async () => {
mocks.currentUserId = 'user-recipient';
const inbound = pendingChallenge();
mocks.activity = { ...idleActivity(), challenge: inbound };

const view = render(<DebateCoordinator />);
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(<DebateCoordinator />);
mocks.activity = { ...idleActivity(), challenge: inbound };
view.rerender(<DebateCoordinator />);

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() };
Expand All @@ -484,6 +552,24 @@ describe('DebateCoordinator', () => {
expect(screen.queryByText(/Waiting for .* to accept/)).not.toBeInTheDocument();
});

it('still prompts for an incoming claim request while an outbound person request is pending', async () => {
mocks.currentUserId = 'user-requester';
const outboundChallenge = pendingChallenge();
mocks.activity = {
...idleActivity(),
challenge: outboundChallenge,
outbound_challenge: outboundChallenge,
incoming_request_count: 1,
};
mocks.requests = { outbound: null, incoming: [incomingRequest()] };

render(<DebateCoordinator />);

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.
Expand Down
Loading
Loading