diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index 65189eea31..72f07c5b08 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -209,8 +209,12 @@ vi.mock('next/navigation', () => ({ vi.mock('./rematch-voice', () => ({ // Renders its `leaveAction`, which really does live in the header now — stubbing it away would // take the Leave button off the page for every test below. - RematchVoiceHeader: ({ leaveAction }: { leaveAction?: ReactNode }) => ( -
{leaveAction}
+ // `exiting` is surfaced because that flag is where the header's layout stability is decided, and + // the bug it exists for was in the wiring here rather than inside the header. + RematchVoiceHeader: ({ leaveAction, exiting }: { leaveAction?: ReactNode; exiting?: boolean }) => ( +
+ {leaveAction} +
), })); @@ -1139,6 +1143,82 @@ describe('DebateRematchPageClient', () => { expect(within(claimCard!).getByRole('button', { name: /^Dispute/ })).toBeEnabled(); }); + // The header used to grow the claim and two position chips the moment a request went out, moving + // everything under it at the exact moment the viewer was watching for an answer. The same facts + // arrive as a card in the content instead — the shape the debates panel uses for a request you + // sent. + it('draws a card for the request the viewer sent, leaving the header alone', async () => { + mocks.session = session({ + status: 'request_pending', + request: { + id: 'request-1', + status: 'pending', + claim: claimSummary(CLAIM_SHARED, 'A claim both participants chose'), + requester_user_id: 'user-local', + recipient_user_id: 'user-remote', + requester_position: true, + requester_position_label: 'Agree', + recipient_position: false, + recipient_position_label: 'Disagree', + response_kind: 'stance', + turn_format_id: 'standard', + created_at: '2026-07-10T10:00:00.000Z', + expires_at: new Date(Date.now() + 5 * 60_000).toISOString(), + }, + }); + + render(); + + const card = await screen.findByTestId('rematch-outbound-request'); + expect(within(card).getByText('A claim both participants chose')).toBeInTheDocument(); + expect(within(card).getByText('Awaiting response')).toBeInTheDocument(); + // Both sides and what each of them holds — the chips that used to sit in the header. + expect(within(card).getByText('You')).toBeInTheDocument(); + expect(within(card).getByText('Agree')).toBeInTheDocument(); + expect(within(card).getByText('Disagree')).toBeInTheDocument(); + + // Pinned with the filters and the search box, where the hub's claims tab keeps it — so a + // request stays on screen while the viewer keeps browsing rather than scrolling away. + const pinned = screen.getByRole('textbox', { name: 'Search claims' }).closest('.sticky'); + expect(card.closest('.sticky')).toBe(pinned); + // Above both of them, not wedged between. + const filters = screen.getByRole('button', { name: /Any space/ }); + expect(card.compareDocumentPosition(filters) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // And the viewer is the requester, so no dialog asking them to answer their own request. + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + // The hub never draws an expired request — `useUnexpiredRequests` filters them out before its card + // sees one. This card is drawn from the session, which says `request_pending` until geo-chat's + // next answer, so the lapse is reachable here and "Awaiting response" through it would be waiting + // on something that is over. + it('says so when the request the viewer sent has lapsed', async () => { + mocks.session = session({ + status: 'request_pending', + request: { + id: 'request-1', + status: 'pending', + claim: claimSummary(CLAIM_SHARED, 'A claim both participants chose'), + requester_user_id: 'user-local', + recipient_user_id: 'user-remote', + requester_position: true, + requester_position_label: 'Agree', + recipient_position: false, + recipient_position_label: 'Disagree', + response_kind: 'stance', + turn_format_id: 'standard', + created_at: '2026-07-10T10:00:00.000Z', + expires_at: new Date(Date.now() - 60_000).toISOString(), + }, + }); + + render(); + + const card = await screen.findByTestId('rematch-outbound-request'); + expect(within(card).getByText('This request has expired.')).toBeInTheDocument(); + expect(within(card).queryByText('Awaiting response')).toBeNull(); + }); + it('shows authoritative stance labels in the incoming request dialog and preserves rematch actions', async () => { mocks.session = session({ status: 'request_pending', @@ -1313,16 +1393,17 @@ describe('DebateRematchPageClient', () => { // The whole tagged corpus is the wider net; the curated cut is one pick below it, which is the // order the hub's Explore menu opens in too. - it('opens on All claims when no curator has a page for this pairing', async () => { - mocks.featuredClaims = [featuredTag()]; - mocks.entities = [sharedEntity(), publishedEntity(), featuredEntity()]; + // A menu of one is a control that cannot do anything, so without a curated page for this + // pairing Explore draws none at all — the shape the hub's claims tab has. + it('draws no source menu when no curator has a page for this pairing', async () => { + mocks.entities = [sharedEntity(), publishedEntity()]; render(); await showExplore(); expect(screen.getByRole('button', { name: 'Explore' })).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getByRole('button', { name: 'All claims' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'All claims' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Recommended' })).toBeNull(); expect(screen.getByText('A newly published claim')).toBeInTheDocument(); - expect(screen.queryByText('A featured claim')).toBeNull(); }); /** @@ -1392,7 +1473,8 @@ describe('DebateRematchPageClient', () => { await showExplore(); expect(screen.queryByRole('button', { name: 'Recommended' })).toBeNull(); - expect(screen.getByRole('button', { name: 'All claims' })).toBeInTheDocument(); + // And with only one source left there is no menu at all. + expect(screen.queryByRole('button', { name: 'All claims' })).toBeNull(); expect(screen.queryByText(/Nothing recommended/)).toBeNull(); }); @@ -1401,6 +1483,7 @@ describe('DebateRematchPageClient', () => { // as one control — so the menus go back to being a run on the left, and the end of the row // belongs to the switch. it('groups the source, space and topic menus on the left, in that order', async () => { + curatedPage(); render(); await showExplore(); @@ -1440,6 +1523,11 @@ describe('DebateRematchPageClient', () => { }); it('attributes shared filters and switches to rematch rather than the debate hub', async () => { + // A curated page, so the source menu has two entries and therefore exists at all. Positions + // left it for a tab of its own (GEO-2992) and Featured went with the menu it lived in, so a + // pairing with no curator has a single source and no trigger to attribute. + curatedPage(); + mocks.entities = [sharedEntity(), publishedEntity()]; render(); await showExplore(); @@ -1467,35 +1555,10 @@ describe('DebateRematchPageClient', () => { openSourceMenu(); - const labels = ['Recommended', 'All claims', 'Featured', 'My positions']; + const labels = ['Recommended', 'All claims']; const options = screen.getAllByRole('button').filter(button => labels.includes(button.textContent?.trim() ?? '')); // The trigger carries the current label too, and it is rendered ahead of the options. - expect(options.slice(-4).map(button => button.textContent?.trim())).toEqual(labels); - }); - - it('leaves Recommended out of the menu when there is no curated page', async () => { - render(); - - openSourceMenu(); - - expect(screen.queryByRole('button', { name: 'Recommended' })).toBeNull(); - // Twice: the trigger reads All claims, and so does the option it opened onto. - expect(screen.getAllByRole('button', { name: 'All claims' })).toHaveLength(2); - }); - - // Unlike Recommended, Featured is a tag any space can carry, so it fans out across the corpus - // the way All claims does -- and is bounded the same way. - it('drops tagged claims from spaces outside the viewer’s allowed set', async () => { - mocks.spaceAllowlist = new Set([SPACE_1.replace(/-/g, '')]); - mocks.featuredClaims = [featuredTag()]; - mocks.entities = [sharedEntity(), featuredEntity()]; - render(); - await showFeatured(); - - await settleTabSwap(); - - expect(screen.queryByText('A featured claim')).toBeNull(); - expect(screen.getByText('No featured claims are available to debate yet.')).toBeInTheDocument(); + expect(options.slice(-2).map(button => button.textContent?.trim())).toEqual(labels); }); // The space ranking picks the highest-ranked space a claim is *named* in and knows nothing of @@ -1504,7 +1567,7 @@ describe('DebateRematchPageClient', () => { it('draws a tagged claim in the space it was featured in, not the highest-ranked one', async () => { // SPACE_1 (Crypto, rank 2) outranks SPACE_2, and the claim is named in both. mocks.spaceAllowlist = new Set([SPACE_2.replace(/-/g, '')]); - mocks.featuredClaims = [featuredTag(FEATURED, 'A featured claim', SPACE_2)]; + mocks.debateTagClaims = [featuredTag(FEATURED, 'A featured claim', SPACE_2)]; mocks.entities = [ sharedEntity(), { @@ -1517,36 +1580,13 @@ describe('DebateRematchPageClient', () => { }, ]; render(); - await showFeatured(); - - await settleTabSwap(); + await showAllClaims(); expect(screen.getByText('A featured claim')).toBeInTheDocument(); expect(mocks.rematchClaimIds.flat()).toContain(FEATURED); expect(screen.queryByText('No featured claims are available to debate yet.')).toBeNull(); }); - // `enabled: false` leaves react-query's cached rows in place, and the hub shares this query key - // -- so a catalog fetched there arrives pre-populated and would keep the hydration mounted. - it('asks for nothing while Featured is off screen, even with a cached catalog', async () => { - curatedPage(); - mocks.featuredClaims = [featuredTag()]; - mocks.entities = [sharedEntity(), featuredEntity()]; - render(); - - // Read by *tag* rather than by the enabled flag: the tab opens on All claims, so the same - // hook is legitimately running — for the Debate tag. What must stay quiet is Featured's tag - // and the geo-chat rows behind it. There is no entity lookup to hold off any more; the page - // carries what a row is built from. - expect(mocks.taggedClaimsAskedFor).not.toContain('ec3086a54ddf43d8aaefd6cc6e1b0556'); - expect(mocks.rematchClaimIds.flat()).not.toContain(FEATURED); - - await chooseSource('Featured'); - - expect(mocks.taggedClaimsAskedFor).toContain('ec3086a54ddf43d8aaefd6cc6e1b0556'); - await waitFor(() => expect(mocks.rematchClaimIds.flat()).toContain(FEATURED)); - }); - /** * GEO-2861 moved the landing tab to the opponent's positions, which left Explore's whole chain * to start from cold on the click that opens it: a paged catalog and two facets, and then @@ -1670,20 +1710,6 @@ describe('DebateRematchPageClient', () => { expect(screen.queryByText('No featured claims are available to debate yet.')).toBeNull(); }); - - it('says nothing is featured rather than nothing is debatable', async () => { - // Nothing carries the tag, so the source has nothing to show. It used to be expressed as a - // tagged claim with no entity behind it; the page carries its own now, so an untagged corpus - // is the honest way to say it. - mocks.featuredClaims = []; - mocks.entities = [sharedEntity()]; - render(); - await showFeatured(); - - await settleTabSwap(); - - expect(screen.getByText('No featured claims are available to debate yet.')).toBeInTheDocument(); - }); }); /** @@ -1713,7 +1739,7 @@ describe('DebateRematchPageClient', () => { viewerOnlyClaim(); render(); await showExplore(); - await chooseSource('My positions'); + await showMyPositions(); expect(await screen.findByText('Only mine')).toBeInTheDocument(); // A claim nobody has answered belongs to the corpus, not to this list. Waited for rather than @@ -1727,18 +1753,96 @@ describe('DebateRematchPageClient', () => { * entities behind them and geo-chat's rows for those are not, and the picker lands somewhere * else. A returning pair should not wait behind a lookup for a list nobody has asked for. */ - it('asks for nothing until the source is on screen', async () => { + // It used to wait for the tab to be opened. The tab carries a count now, and the count is of + // the rows the tab will list — which `participantClaimRows` filters — so the lookup behind them + // has to have run before the viewer gets there. A number that waits is a number that reads `0` + // for a viewer with a backlog, which is what GEO-2656 took out of the other tab. + it('looks the viewer’s own claims up with the opponent’s, so the tab can count them', async () => { viewerOnlyClaim(); + // A position on the claim this session excludes — the one the pair just debated. It is one of + // the viewer's answered ids and never one of the tab's rows, which is exactly the difference + // between counting ids and counting the list. + mocks.positions = [...mocks.positions, position('profile-local', CLAIM_SOURCE, SPACE_1, true)]; render(); - await showExplore(); - expect(mocks.entityIdLookups.flat()).not.toContain(VIEWER_ONLY); - expect(mocks.rematchClaimIds.flat()).not.toContain(VIEWER_ONLY); + await waitFor(() => expect(mocks.entityIdLookups.flat()).toContain(VIEWER_ONLY)); + await waitFor(() => expect(mocks.rematchClaimIds.flat()).toContain(VIEWER_ONLY)); - await chooseSource('My positions'); + // And the number is on the tab before it is opened, and is of the rows the tab then lists — + // not of the ids behind them, which `participantClaimRows` filters on the way to becoming + // rows. + const badge = ( + screen.getByRole('button', { name: /^Positions/ }).textContent?.replace('Positions', '') ?? '' + ).trim(); + expect(badge).toMatch(/^\d+$/); + + await showMyPositions(); + expect(screen.getAllByRole('article')).toHaveLength(Number(badge)); + }); + + /** + * And it keeps counting them while the space types are still out. + * + * An unresolved type reads as publishable on purpose, so a slow lookup cannot empty a list — + * which means the rows are on screen during that window and the number narrows with them as + * the types land. Holding the badge back through it would put a skeleton on the tab over a + * list already showing rows: the count and the thing it counts have to agree, and agreeing + * with the list is the whole contract of the test above. + */ + it('keeps counting the rows it draws while the space types are unresolved', async () => { + viewerOnlyClaim(); + mocks.spacesHeldOver = true; + render(); - await waitFor(() => expect(mocks.entityIdLookups.flat()).toContain(VIEWER_ONLY)); await waitFor(() => expect(mocks.rematchClaimIds.flat()).toContain(VIEWER_ONLY)); + + const badge = ( + screen.getByRole('button', { name: /^Positions/ }).textContent?.replace('Positions', '') ?? '' + ).trim(); + expect(badge).toMatch(/^\d+$/); + + await showMyPositions(); + expect(screen.getAllByRole('article')).toHaveLength(Number(badge)); + }); + + /** + * A lookup that failed is not an answer about the viewer either. + * + * react-query drops `isLoading` on failure, so an outage leaves every flag false and the list + * empty — which reads from the badge exactly like somebody who has answered nothing. The tab + * underneath says the lookup failed; the number beside it would be saying, confidently, that + * there is nothing to look at. `Counting your positions` is the honest thing to show over an + * error the viewer can retry. + */ + it('counts nothing rather than zero when the lookup failed', async () => { + mocks.positions = []; + mocks.positionsError = new Error('positions exploded'); + render(); + await settleTabSwap(); + + expect( + within(screen.getByRole('button', { name: /^Positions/ })).getByLabelText('Counting your positions') + ).toBeInTheDocument(); + }); + + // The other half of that, and the reason the failure sits inside the `length === 0` guard rather + // than in front of it: a refetch that failed has not invalidated the rows already drawn. Going + // to a skeleton over a list still on screen would be the flicker the guard exists to prevent. + it('keeps the count it has when a refetch fails under it', async () => { + viewerOnlyClaim(); + const view = render(); + await settleTabSwap(); + + const badge = () => + (screen.getByRole('button', { name: /^Positions/ }).textContent?.replace('Positions', '') ?? '').trim(); + await waitFor(() => expect(badge()).toMatch(/^[1-9]\d*$/)); + const settled = badge(); + + // react-query keeps the data it has and reports the failure alongside it. + mocks.positionsError = new Error('positions exploded'); + view.rerender(); + + expect(badge()).toBe(settled); }); // The same window the opponent's tab waits out, on the other side of it: the ids here are the @@ -1749,7 +1853,7 @@ describe('DebateRematchPageClient', () => { mocks.currentUserId = null; render(); await showExplore(); - await chooseSource('My positions'); + await showMyPositions(); expect(screen.queryByText('You haven’t taken a position on any claims yet.')).toBeNull(); }); @@ -1760,7 +1864,7 @@ describe('DebateRematchPageClient', () => { mocks.positions = [position('profile-remote', CLAIM_SHARED, SPACE_1, false)]; render(); await showExplore(); - await chooseSource('My positions'); + await showMyPositions(); expect(await screen.findByText('You haven’t taken a position on any claims yet.')).toBeInTheDocument(); @@ -1779,7 +1883,7 @@ describe('DebateRematchPageClient', () => { mocks.memberSpaceIds = new Set([SPACE_1.replace(/-/g, '')]); render(); await showExplore(); - await chooseSource('My positions'); + await showMyPositions(); // Seeded to the space the viewer belongs to, so their position in the other one is filtered // out — and clearing the filter brings it back. @@ -2328,7 +2432,9 @@ describe('DebateRematchPageClient', () => { mocks.recommendedLoading = true; const { rerender } = render(); - await chooseSource('All claims'); + // Explicitly, rather than leaning on where the picker happens to land: the subject here is what + // Explore does when a curated page arrives after the viewer is already looking at something. + await showAllClaims(); expect(screen.getByText('A newly published claim')).toBeInTheDocument(); mocks.recommendedLoading = false; @@ -3390,6 +3496,34 @@ describe('DebateRematchPageClient', () => { expect(screen.getByText('A newly published claim').closest('.sticky')).toBeNull(); }); + // Leaving is not the only way out. The other person leaving ends the session too, and it reaches + // this page as a status change with no mutation of ours behind it — so a header that only knew + // about our own Leave button rebuilt itself in the render before the redirect landed. + it('tells the header it is on its way out however the session ended', async () => { + for (const status of ['ended', 'expired'] as const) { + mocks.session = session({ status }); + const { unmount } = render(); + + expect(screen.getByTestId('rematch-pair-header')).toHaveAttribute('data-exiting', 'true'); + unmount(); + } + }); + + // And a request being accepted, which walks the pair into the debate room. + it('tells the header it is on its way out when the session converts to a debate', async () => { + mocks.session = session({ status: 'converted', converted_debate_id: 'debate-9' }); + render(); + + expect(screen.getByTestId('rematch-pair-header')).toHaveAttribute('data-exiting', 'true'); + }); + + // The ordinary case stays ordinary: nothing is exiting, so nothing is frozen. + it('does not tell the header it is exiting while the session is live', async () => { + render(); + + expect(screen.getByTestId('rematch-pair-header')).not.toHaveAttribute('data-exiting'); + }); + // This page is a `fixed inset-0` layer over the whole app, and Leave is the only way off it. // Moving it into the pair header put it behind `session && currentUserId` for a release — // signed out, mid identity exchange, or on a failed session lookup the picker covered the screen @@ -3424,6 +3558,37 @@ describe('DebateRematchPageClient', () => { expect(header.compareDocumentPosition(tabs) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); + // GEO-2863 promoted the hub's "My positions" out of Explore's source menu and into a tab of its + // own; this does the same here, so the two surfaces name the same lists the same way. + it('gives the viewer’s own positions a tab rather than a source', async () => { + render(); + + const positions = screen.getByRole('button', { name: /^Positions/ }); + expect(positions).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(positions); + await settleTabSwap(); + + expect(screen.getByRole('button', { name: /^Positions/ })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Explore' })).toHaveAttribute('aria-pressed', 'false'); + // And it is no longer reachable as a source, from Explore or anywhere else. + expect(screen.queryByRole('button', { name: 'My positions' })).toBeNull(); + }); + + // The tab draws the same list the source did — the viewer's own side of the lookup the opponent's + // tab reads — so the promotion is a move, not a rewrite. + it('lists the claims the viewer holds a side on under Positions', async () => { + mocks.positions = [ + position('profile-local', CLAIM_SHARED, SPACE_1, true), + position('profile-remote', CLAIM_MORE, SPACE_1, false), + ]; + render(); + await showMyPositions(); + + expect(await screen.findByText('A claim both participants chose')).toBeInTheDocument(); + expect(screen.queryByText('A newly published claim')).toBeNull(); + }); + // The hub panel's tab row, reused (GEO-2992): two surfaces doing the same job had two different // tab treatments. it('names the tabs Lobby and Explore, in the hub panel\u2019s styles', async () => { @@ -3472,35 +3637,6 @@ describe('DebateRematchPageClient', () => { expect(screen.getByText('A newly published claim')).toBeInTheDocument(); }); - /** - * Reported: flipping between Featured and All on first load left the list unchanged, and only - * started switching after a few goes. - * - * `useLastSettled` holds the last settled list while a new one loads, keyed on the session — which - * does not change when the source does. Before GEO-2771 that was safe, because Featured was the - * only tagged source and All came from a different variable entirely. Routing both through - * `taggedClaims` made the hold bridge two genuinely different lists: switch, and the previous - * source's rows stay up for as long as the new tag takes to fetch. Once both catalogs are cached - * the fetch is instant, which is why it comes right after a few switches. - */ - it('does not hold the previous source’s claims while the new tag loads', async () => { - const ONLY_FEATURED = '019fedc4-5555-7000-8000-000000000005'; - mocks.featuredClaims = [debateTag(ONLY_FEATURED, 'Only featured')]; - mocks.debateTagClaims = [debateTag()]; - mocks.entities = [sharedEntity(), publishedEntity(), publishedEntity(ONLY_FEATURED, 'Only featured')]; - - render(); - - await showExplore(); - await waitFor(() => expect(screen.getByText('A newly published claim')).toBeInTheDocument()); - - // Featured's tag has never been fetched, so switching to it starts a load. - mocks.featuredCatalogLoading = true; - await chooseSource('Featured'); - - await waitFor(() => expect(screen.queryByText('A newly published claim')).toBeNull()); - }); - // Every other entity lookup on this page is gated by the source that shows its rows. Ungated, this // one fanned out graph batches behind the opponent's tab and Recommended, which never list them. it('does not hydrate the saved claims on tabs that do not show them', async () => { @@ -4121,7 +4257,7 @@ function openSourceMenu() { const explore = screen.queryByRole('button', { name: 'Explore' }); if (explore && explore.getAttribute('aria-pressed') !== 'true') fireEvent.click(explore); - const label = (['Recommended', 'Featured', 'All claims', 'My positions'] as const).find( + const label = (['Recommended', 'All claims'] as const).find( name => screen.queryAllByRole('button', { name }).length > 0 ); fireEvent.click(screen.getAllByRole('button', { name: label! })[0]!); @@ -4153,16 +4289,21 @@ async function showRecommended() { await chooseSource('Recommended'); } -/** Explore opens on All claims since the two menus were aligned, so Featured is a pick away. */ -async function showFeatured() { +/** + * The picker opens on the opponent's positions (GEO-2861); most assertions want the browse index. + * + * One click now. Explore is a single list unless a curator has made a page for this pairing, which + * is the only thing left in its menu. + */ +async function showAllClaims() { fireEvent.click(screen.getByRole('button', { name: 'Explore' })); - await chooseSource('Featured'); + await settleTabSwap(); } -/** The picker opens on the opponent's positions (GEO-2861); most assertions want the browse index. */ -async function showAllClaims() { - fireEvent.click(screen.getByRole('button', { name: 'Explore' })); - await chooseSource('All claims'); +/** The viewer's own backlog, a tab of its own since it left Explore's menu. */ +async function showMyPositions() { + fireEvent.click(screen.getByRole('button', { name: /^Positions/ })); + await settleTabSwap(); } /** The opponent's own responses, which are a tab of their own rather than a source of Explore. */ @@ -4508,7 +4649,7 @@ describe('Hide my positions', () => { it('is not offered on My positions', async () => { render(); await showExplore(); - await chooseSource('My positions'); + await showMyPositions(); expect(screen.queryByRole('switch', SWITCH)).toBeNull(); }); @@ -4673,7 +4814,7 @@ describe('the matches-only default', () => { mocks.positions = []; render(); - expect(await screen.findByRole('button', { name: 'All claims' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Explore' })).toHaveAttribute('aria-pressed', 'true'); }); }); @@ -5295,6 +5436,34 @@ describe('inside a debate room', () => { expect(mocks.back).not.toHaveBeenCalled(); }); + /** + * The exit freeze is about navigating, not about ending. + * + * Outside a room an ended session is a redirect one render away, so the pair header holds its + * shape rather than rebuilding for a page nobody will see. In a room the same status is rejoined + * in place — nothing navigates — and a header frozen on it would stay frozen over a session that + * is coming back. A room's own Leave is a `router.push` with no mutation behind it, so there is + * no window to hold open here either. + */ + it.each([['ended'], ['expired']] as const)('does not freeze the header on a %s session', async status => { + mocks.session = session({ status }); + + render(inRoom()); + + await waitFor(() => expect(screen.getByTestId('rematch-pair-header')).toBeInTheDocument()); + expect(screen.getByTestId('rematch-pair-header')).not.toHaveAttribute('data-exiting'); + }); + + // Converted is not excepted: a request accepted in a room walks the pair into the debate the same + // way it does anywhere else, so the header holds through that redirect as it always has. + it('still freezes the header when the session converts to a debate', async () => { + mocks.session = session({ status: 'converted', converted_debate_id: 'debate-9' }); + + render(inRoom()); + + expect(screen.getByTestId('rematch-pair-header')).toHaveAttribute('data-exiting', 'true'); + }); + const waitingFor = (children: React.ReactElement) => ( = { +/** + * What Explore's menu can still offer. + * + * `mine` left it for a tab of its own, the way GEO-2863 promoted the hub's, and Featured left with + * it — a curated cut of the same tag behind a menu most viewers never opened, which is the reason + * the hub gives for dropping it there. What remains is the curator's page for this pairing, where + * one exists, and everything else; the menu draws itself only when both are on offer. + */ +type ExploreSource = Exclude; + +const CLAIMS_SOURCE_LABELS: Record = { recommended: 'Recommended', all: 'All claims', - featured: 'Featured', - mine: 'My positions', }; /** Stable identity so the hydration below doesn't restart whenever Featured isn't the source. */ @@ -212,7 +220,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // Left unset until the viewer picks one: Recommended is the best default when a curator has put // something together for this pairing, and it doesn't exist otherwise. Deciding in state would // fix the default before that lookup settles. - const [chosenSource, setChosenSource] = React.useState(null); + const [chosenSource, setChosenSource] = React.useState(null); // The opponent's positions is where this opens (GEO-2861). A returning pair are here *because* // they just debated each other, so a general catalogue is not the first thing they came for — @@ -372,6 +380,9 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { /** Whether the viewer is in the browse tab, which is the only way to be in it. */ const browsing = chosenForSession === 'explore'; + /** Likewise for their own positions, which is now a tab rather than a source inside that one. */ + const viewingPositions = chosenForSession === 'positions'; + // GEO-2683. Fetched only when Featured is the source on screen — it is one option in a menu, and // the other two answer for themselves. // @@ -405,20 +416,24 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { * later. */ const chosenRecommendedIsGone = chosenSource === 'recommended' && !recommendedLoading && !hasRecommended; - const source: ClaimsSource = chosenRecommendedIsGone ? 'all' : (chosenSource ?? 'all'); + // The tab answers first: Positions is its own now, so the menu below only ever chooses between + // the curator's page and everything else. + const source: ClaimsSource = viewingPositions ? 'mine' : chosenRecommendedIsGone ? 'all' : (chosenSource ?? 'all'); /** - * "My positions": the viewer's own side of the lookup the opponent's tab reads. - * - * `positions` already covers both debaters, so the ids are free — what this costs is the claim - * entities behind them and geo-chat's rows for those, and it spends that only once the source is - * on screen. The picker lands on the opponent's positions, and a returning pair should not wait - * behind a lookup for a list nobody has asked for. Same shape as `taggedEnabled` below. + * "Positions": the viewer's own side of the lookup the opponent's tab reads. + * + * `positions` already covers both debaters, so the ids are free. The entities behind them and + * geo-chat's rows for those used to wait until the tab was opened — a lookup for a list nobody + * had asked for. The tab carries a count now, and a count has to be of the rows the tab will + * actually list: `participantClaimRows` drops claims this session has already ruled out and + * claims in spaces that cannot carry a published debate, so a number taken from `positions` + * alone would sit above the list it describes. That is the same confident-and-wrong badge + * GEO-2656 took out of the opponent's tab, so the lookup runs with the opponent's instead. */ - const viewerSourced = browsing && source === 'mine'; const viewerClaimIds = React.useMemo( - () => (viewerSourced ? claimIdsAnsweredBy(positions.byClaim, localParticipant?.profile_space_id ?? null) : []), - [localParticipant, positions.byClaim, viewerSourced] + () => claimIdsAnsweredBy(positions.byClaim, localParticipant?.profile_space_id ?? null), + [localParticipant, positions.byClaim] ); const viewerEntitiesQuery = useClaimEntitiesByIds(viewerClaimIds); // Both graph-sourced options, one pipeline (GEO-2771). @@ -446,7 +461,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // Explore pays for is a page and two counts, not a corpus. // // The effect that ends it is below `taggedClaimsQuery`, which is the last hop it waits for. - const claimsTagId = source === 'featured' ? FEATURED_TAG_ID : DEBATE_TAG_ID; + const claimsTagId = DEBATE_TAG_ID; // Kept with the session it was spent on, the way `useCurrentGeoChatUserId` keeps its id with the // account. The route reuses this component when it moves between rematches — `useLastSettled` // takes `sessionId` as its reset key for the same reason — so a bare boolean would say "already @@ -454,7 +469,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // would open Explore cold. The rows lookup is keyed on the session; the warm-up has to be too. const [warmedSessionId, setWarmedSessionId] = React.useState(null); const browseWarmed = warmedSessionId === sessionId; - const taggedEnabled = (browsing || !browseWarmed) && (source === 'featured' || source === 'all'); + const taggedEnabled = (browsing || !browseWarmed) && source === 'all'; // What goes to the server, so the page and both facet menus describe the same set of spaces. // // Two of the three gates can be sent; one cannot. The viewer's allowlist and the acceptor's @@ -1284,6 +1299,12 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { [opponentClaims, opponentPositionOf] ); + /** The viewer's own, counted off the same list the tab draws — see `opponentPositionCount`. */ + const viewerPositionCount = React.useMemo( + () => viewerClaims.filter(claim => viewerPositionOf(claim) !== null).length, + [viewerClaims, viewerPositionOf] + ); + /** * GEO-2656. The badge drew `0` from the very first paint, because the count is derived from a * list that is empty until three dependent round trips land — positions, then the claim @@ -1312,22 +1333,61 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { const opponentCountPending = opponentClaims.length === 0 && (sessionQuery.isLoading || positions.isLoading || opponentClaimsSettling); + /** + * The same rule for the same reason: `0` is a claim about the viewer's own backlog, and it is + * wrong for as long as the chain behind it is still running. + * + * Not `publishabilityPending`, though it is tempting here — a debater's own responses often live + * in their personal space, `canPublishDebateIn` fails open until the space types land, and the + * number can therefore settle high and narrow as they resolve. It narrows because *the list + * narrows*: fail-open is what keeps a slow lookup from emptying the tab, and this number is of + * the rows the tab draws. Holding it back through that window would put a skeleton on the tab + * over a list already showing rows, which is a worse thing to be told than a count that follows + * what is under it. (Behind the `length === 0` guard the term did nothing either way: fail-open + * means the provisional rows are already there, so the guard is false wherever it would matter.) + */ + /** + * The viewer's own two sources, as `opponentTabError` is the opponent's — and the same pair the + * tab below draws its error state from, so the number and the list are answering one question. + * Named up here because the badge is decided long before `tabError`, which is the composite. + */ + const viewerTabError = sessionQuery.error ?? positions.error ?? viewerEntitiesQuery.error; + const viewerCountPending = + viewerClaims.length === 0 && + // The failure belongs with the loading flags rather than beside them: react-query drops + // `isLoading` on failure, so an outage leaves every flag false over an empty list and reads + // from here exactly like somebody who has answered nothing. Inside the `length === 0` guard, + // so a held list keeps its number through a refetch that failed — those rows are still right. + (sessionQuery.isLoading || positions.isLoading || viewerClaimsSettling || Boolean(viewerTabError)); + // Recommended is offered only when a curator has a page for this pairing; the order is fixed, so // a source that appears doesn't reshuffle the ones already in the menu. The rest are in the hub's // order — All claims, Featured, My positions — so the same menu means the same thing on both // surfaces. - const sourceOptions = React.useMemo[]>( + /** + * `hasRecommended` alone would drop the option — and with it the whole menu — for as long as the + * next pairing's lookup is out, then put it back. Holding it while it is the source on screen is + * the same rule `chosenRecommendedIsGone` applies to the selection: let go once the answer is in, + * not while it is in flight. + */ + const offersRecommended = hasRecommended || source === 'recommended'; + const sourceOptions = React.useMemo[]>( () => - (hasRecommended - ? (['recommended', 'all', 'featured', 'mine'] as const) - : (['all', 'featured', 'mine'] as const) - ).map(value => ({ + (offersRecommended ? (['recommended', 'all'] as const) : (['all'] as const)).map(value => ({ value, label: CLAIMS_SOURCE_LABELS[value], })), - [hasRecommended] + [offersRecommended] ); + /** + * A menu of one is not a choice. + * + * Without a curated page for this pairing there is nothing to pick between, and Explore reads as + * the hub's does: a search box and the two facet menus over one list. + */ + const offersSourceMenu = sourceOptions.length > 1; + const claims = tab === 'opponent' ? opponentClaims @@ -1343,7 +1403,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { taggedClaims; // Whether the list on screen was narrowed by its own query. Only the tagged sources are. - const graphFiltered = tab === 'explore' && (source === 'featured' || source === 'all'); + const graphFiltered = tab === 'explore' && source === 'all'; // Only the tagged sources are narrowed by their query. The opponent's tab, Recommended and My // positions are lists fetched by id, so nothing narrowed them on the way in and the filters below @@ -1726,11 +1786,13 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { positions.isLoading || opponentClaimsSettling : tab === 'related' ? relatedClaimsSettling - : source === 'recommended' - ? recommendedLoading || curatedClaimsQuery.isLoading - : source === 'mine' - ? viewerClaimsSettling - : taggedClaimsSettling); + : tab === 'positions' + ? viewerClaimsSettling + : source === 'recommended' + ? recommendedLoading || curatedClaimsQuery.isLoading + : source === 'mine' + ? viewerClaimsSettling + : taggedClaimsSettling); // The menu, and the handlers that drive it. Defaults to the spaces the viewer belongs to // (GEO-2789). @@ -1788,9 +1850,10 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // still fail is turning those ids into rows. relatedRowsError : source === 'mine' - ? // The same two lookups the opponent's tab is built from, asked about the viewer. - (positions.error ?? viewerEntitiesQuery.error) - : source === 'featured' || source === 'all' + ? // The same two lookups the opponent's tab is built from, asked about the viewer. Shared + // with the Positions badge, which has to call an outage an outage rather than a zero. + viewerTabError + : source === 'all' ? // The page is the list, and it carries everything a row is built from — so its failure // is the only one that leaves nothing to show. geo-chat's row lookup is metadata beside // it: losing it costs the faces and the readiness, not the claims, and blanking the tab @@ -1893,7 +1956,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { ? opponentClaimsQuery.isLoading || Boolean(opponentClaimsQuery.error) : source === 'mine' ? viewerClaimsQuery.isLoading || Boolean(viewerClaimsQuery.error) - : source === 'featured' || source === 'all' + : source === 'all' ? taggedClaimsQuery.isLoading || Boolean(taggedClaimsQuery.error) : curatedClaimsQuery.isLoading || Boolean(curatedClaimsQuery.error), }); @@ -1905,6 +1968,26 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { const requestSpaceId = session?.request?.claim.space_id ?? null; if (session && requestSpaceId) requestSpaceRef.current = { sessionId: session.id, spaceId: requestSpaceId }; + /** + * The session is over and this page is about to navigate away — whoever ended it. + * + * Leaving is not the only way out: the other person leaving ends the session too, and so does a + * request being accepted or the browsing window lapsing. All of them land here as a status the + * effect below redirects on, and in the render before that redirect the header must not start + * rebuilding itself for a page nobody will see. + * + * `inDebateRoom` is the exception, and it is about the navigating rather than the ending: in a + * room an expired session is rejoined in place (see the effect), so the page stays. Freezing the + * header there would hold it frozen over a session that is coming back, which is the one thing + * this flag must never outlive. A room's own Leave is a `router.push` with no mutation behind + * it, so there is no window to hold open either. Converted is not excepted — that redirects out + * of a room as much as into one. + */ + const sessionEnded = + session !== null && + ((session.status === 'converted' && Boolean(session.converted_debate_id)) || + ((session.status === 'ended' || session.status === 'expired') && !inDebateRoom)); + React.useEffect(() => { if (!session) return; if (session.status === 'converted' && session.converted_debate_id) { @@ -1998,6 +2081,8 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { const pendingRequest = session?.status === 'request_pending' ? session.request : null; const incomingRequest = pendingRequest?.recipient_user_id === currentUserId ? pendingRequest : null; + // The other side of the same pending request: the viewer asked, and is waiting to hear back. + const outboundRequest = pendingRequest?.requester_user_id === currentUserId ? pendingRequest : null; const incomingRequestParticipants = incomingRequest && session ? session.participants.map(participant => { @@ -2068,7 +2153,17 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { with no header. */}
{session && currentUserId ? ( - + ) : (
{leaveButton}
)} @@ -2119,6 +2214,24 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { setTab('explore')}> Explore + {/* Promoted out of Explore's source menu, the way GEO-2863 promoted the hub's. It is + the viewer's own backlog rather than a way of browsing, which is the same reason + the hub gives for it being a tab rather than an option inside one. */} + setTab('positions')}> + Positions + + {viewerCountPending ? ( + + ) : ( + viewerPositionCount + )} + +
{/* Outside the scroll container so the rule spans the visible row rather than the scrollable width, and `z-0` so the active tab's marker paints over it rather than @@ -2128,6 +2241,14 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
+ {/* Pinned above the filters and the search box, where the hub's claims tab keeps it — + and inside the sticky block rather than over it, because two stickies would both + claim `top-0` and overlap. A request sent from here otherwise left no trace on the + surface that sent it: the claim card looks exactly as it did before. */} + {outboundRequest && currentUserId ? ( + + ) : null} + ) : null @@ -2263,9 +2386,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { ? `Nothing recommended for you and ${remoteName} yet.` : source === 'mine' ? 'You haven’t taken a position on any claims yet.' - : source === 'featured' - ? 'No featured claims are available to debate yet.' - : 'No other eligible claims are available yet.' + : 'No other eligible claims are available yet.' } // Four dead ends, and each has a different way out. Ordered by how much the viewer has // to give up: clearing their filters, then dropping the toggle, then leaving the tab or @@ -2295,9 +2416,9 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // resolve, and the catalogue next door is the whole of the way out of it. { label: 'Explore claims', onClick: () => setTab('explore') } : source === 'mine' - ? // The same dead end one level down: a viewer who has answered nothing cannot - // fill this list from here, and the whole corpus is one pick away. - { label: 'Show all claims', onClick: () => setChosenSource('all') } + ? // The same dead end one tab over: a viewer who has answered nothing + // cannot fill this list from here, and the whole corpus is next door. + { label: 'Show all claims', onClick: () => setTab('explore') } : undefined } > diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-pair-header.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-pair-header.tsx index bc6252a083..476552cb9b 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-pair-header.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-pair-header.tsx @@ -72,9 +72,6 @@ export type PairHeaderToast = | { kind: 'opponent-talking'; onUnmute: () => void; onDismiss: () => void } | { kind: 'talking-while-muted'; onUnmute: () => void; onDismiss: () => void }; -/** Both sides of a locked pairing, once the pair have agreed what they are debating. */ -export type PairHeaderPositions = { localAgrees: boolean; opponentAgrees: boolean }; - /** * "Jenna Ruiz" -> "Jenna". The notice and the toasts address the opponent directly, and a display * handle read out in the middle of a sentence reads like a username, not like the person talking. @@ -90,9 +87,6 @@ type RematchPairHeaderProps = { voice: PairHeaderVoice; notice?: PairHeaderNotice | null; toast?: PairHeaderToast | null; - /** Set once the pair lock a claim — the claim sits above the cards and each card takes a side chip. */ - lockedClaim?: { claim: string; spaceName?: string | null } | null; - positions?: PairHeaderPositions | null; /** Opens the opponent's profile in the side panel — see `useOpenDebaterProfile`. */ onOpenOpponentSpace: (event: React.MouseEvent) => void; /** @@ -112,8 +106,6 @@ export function RematchPairHeader({ voice, notice, toast, - lockedClaim, - positions, onOpenOpponentSpace, leaveAction, }: RematchPairHeaderProps) { @@ -131,17 +123,10 @@ export function RematchPairHeader({ * other person mutes, and a control whose name moves is harder to use than one that is quiet. */ const opponentStatusId = React.useId(); - const opponentStatus = [ - voice.kind === 'live' ? opponentMicLabel(voice.opponentState, opponentName) : null, - positions ? `${opponentName} ${positions.opponentAgrees ? 'agrees' : 'disagrees'}` : null, - ] - .filter(Boolean) - .join('. '); + const opponentStatus = voice.kind === 'live' ? opponentMicLabel(voice.opponentState, opponentName) : ''; return (
- {lockedClaim ? : null} - {/* Them, then the badge, then you — the home side of a scoreboard, and the side a reader's eye lands on last. Three tracks on desktop, stacked on a phone: two 150px cards side by side at 375px leave no room for a labelled pill, and a labelled pill is the entire point @@ -152,18 +137,11 @@ export function RematchPairHeader({ name={opponentName} state={voice.kind === 'live' ? voice.opponentState : 'waiting'} showMicState={voice.kind === 'live'} - agrees={positions?.opponentAgrees} onOpen={onOpenOpponentSpace} describedBy={opponentStatusId} /> - +
{/* The spoken half of the nudges: a region that is always mounted and only changes text. A @@ -191,15 +169,6 @@ function toastAnnouncement(toast: PairHeaderToast, opponentName: string) { : `You’re talking while muted.`; } -function LockedClaim({ claim, spaceName }: { claim: string; spaceName?: string | null }) { - return ( -
- {spaceName ? {spaceName} : null} -

{claim}

-
- ); -} - function VsBadge() { return (
@@ -251,7 +220,6 @@ function PairCardBody({ nameTitle, state, action, - footer, }: { avatar: React.ReactNode; name: string; @@ -260,8 +228,6 @@ function PairCardBody({ state?: React.ReactNode; /** The corner: Leave on your side, View profile on theirs. */ action?: React.ReactNode; - /** Below the whole row, spanning the card — the locked position chip. */ - footer?: React.ReactNode; }) { return ( <> @@ -275,7 +241,6 @@ function PairCardBody({
{action ?
{action}
: null}
- {footer} ); } @@ -284,13 +249,11 @@ function YouCard({ local, voice, opponentName, - agrees, leaveAction, }: { local: PairHeaderParticipant | null; voice: PairHeaderVoice; opponentName: string; - agrees?: boolean; leaveAction?: React.ReactNode; }) { const live = voice.kind === 'live'; @@ -393,7 +356,6 @@ function YouCard({ } action={leaveAction} - footer={agrees === undefined ? null : } /> ); @@ -404,7 +366,6 @@ function OpponentCard({ name, state, showMicState, - agrees, onOpen, describedBy, }: { @@ -412,7 +373,6 @@ function OpponentCard({ name: string; state: PairMicState; showMicState: boolean; - agrees?: boolean; onOpen: (event: React.MouseEvent) => void; /** The header's live region, which also serves as this button's description. */ describedBy: string; @@ -446,7 +406,6 @@ function OpponentCard({ } - footer={agrees === undefined ? null : } /> ); @@ -540,39 +499,6 @@ function TalkingBars() { ); } -/** Which side of the locked claim this person is on. */ -function PositionChip({ agrees }: { agrees: boolean }) { - return ( - - - {agrees ? 'Agree' : 'Disagree'} - - ); -} - -function ThumbIcon({ down }: { down: boolean }) { - return ( - - - - - ); -} - /** * Says out loud what the header only implies: there is another person on the line right now, and * the viewer is muted by a default they did not choose. diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx new file mode 100644 index 0000000000..8940315a6e --- /dev/null +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-request-card.tsx @@ -0,0 +1,94 @@ +'use client'; + +import * as React from 'react'; + +import cx from 'classnames'; +import { motion } from 'framer-motion'; + +import type { DebateRematchParticipant, DebateRematchRequest } from '~/core/debates/api'; +import { hubCardMotion } from '~/core/debates/matchmaking/hub-motion'; +import { SpaceChip } from '~/core/debates/matchmaking/matchmaking-claim-card'; +import { RequestParties } from '~/core/debates/matchmaking/request-parties'; +import { useRequestCountdown } from '~/core/debates/matchmaking/use-request-countdown'; +import { responsePositionLabel } from '~/core/responses/entity-response'; + +import { Time } from '~/design-system/icons/time'; +import { Text } from '~/design-system/text'; + +/** + * The request the viewer has sent from the claim picker, waiting on the other side. + * + * Built from the hub's pieces — `SpaceChip`, `RequestParties`, `useRequestCountdown` — rather than + * reusing `OutboundRequestCard` itself, which takes a `DebateRequest` whose parties carry + * matchmaking presence this session has no source for. Faking that to reach the component would be + * a worse kind of reuse than sharing the parts it is made of. + * + * The one real difference from the hub's card: a rematch request cannot be withdrawn — geo-chat has + * no endpoint for it — so this says what is happening and waits, rather than offering a way out + * that does not exist. + */ +export function RematchRequestCard({ + request, + participants, + currentUserId, +}: { + request: DebateRematchRequest; + participants: readonly DebateRematchParticipant[]; + currentUserId: string; +}) { + const countdown = useRequestCountdown(request.expires_at); + + // Each side as the parties row wants them: the person, plus the side they took on this claim. + const parties = participants.map(participant => { + const isRequester = participant.user_id === request.requester_user_id; + const position = isRequester ? request.requester_position : request.recipient_position; + const label = isRequester ? request.requester_position_label : request.recipient_position_label; + + return { + ...participant, + position, + position_label: label ?? responsePositionLabel(request.response_kind ?? null, position), + }; + }); + + const viewer = parties.find(party => party.user_id === currentUserId) ?? null; + const opponent = parties.find(party => party.user_id !== currentUserId); + if (!opponent) return null; + + return ( + +
+ + {/* The clock is the only thing on this card that changes, and it is the reason to look at + it twice: a request lapses on its own whether or not anyone is watching. */} + + +
+ + + {request.claim.claim} + + + + + {/* The hub never draws an expired request — `useUnexpiredRequests` filters them out before its + card sees one. Here the card is drawn from the session, which says `request_pending` until + geo-chat's next answer, so the lapse is reachable and saying "Awaiting response" through it + would be waiting on something that is over. */} + + {countdown.expired ? 'This request has expired.' : 'Awaiting response'} + +
+ ); +} diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx index 5d24063871..b7582d0af0 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx @@ -1729,17 +1729,347 @@ describe('RematchVoiceHeader', () => { expect(screen.getByTestId('rematch-opponent-card')).toHaveAttribute('aria-label', 'Open Salina’s personal space'); }); - // Same label, same problem: once the pair lock a claim the opponent's side is a chip inside that - // button, and the accessible name hides it too. - it('carries the opponent locked position in the card description', async () => { + // The header used to grow a claim heading and two position chips the moment a request went out, + // pushing everything under it down at exactly the moment the viewer was watching for an answer. + // That belongs in a card in the content — see `RematchRequestCard` — not in the sticky block. + it('holds its shape when the pair lock a claim', async () => { mocks.remoteParticipants = [remoteOpponent()]; mocks.opponentMicPublication = { isMuted: true }; - render(); + const browsing = makeSession('browsing'); + const { rerender } = render(); await flushOwnership(); + const before = screen.getByTestId('rematch-you-card').className; - // Visible on the card, and reachable from the control that hides it. - expect(within(screen.getByTestId('rematch-opponent-card')).getByText('Disagree')).toBeInTheDocument(); - expect(screen.getByTestId('rematch-opponent-status')).toHaveTextContent('Salina is muted. Salina disagrees'); + rerender(); + + expect(screen.queryByText('A man should always pay for the first date')).toBeNull(); + expect(screen.queryByText('Agree')).toBeNull(); + expect(screen.queryByText('Disagree')).toBeNull(); + expect(screen.getByTestId('rematch-you-card').className).toBe(before); + // The control the header exists for is untouched by the lock. + expect(screen.getByRole('button', { name: /^(Mute|Unmute) microphone$/ })).toBeInTheDocument(); + }); + + // The other person dropping out is not a reason to move everything under the notice. It says the + // viewer is muted and names who they came here to talk to, and neither stops being true of the + // visit when the other side reconnects — or goes for good, taking the session with it. + it('keeps the unmute notice when the opponent drops out of the room', async () => { + mocks.isMicrophoneEnabled = false; + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + + mocks.remoteParticipants = []; + rerender(); + + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + // The opponent's own card still reports the truth — they are not here. + expect(screen.getByTitle('Waiting for Salina to join')).toBeInTheDocument(); + }); + + // A blip is the same shift on a shorter clock. + it('keeps the unmute notice across a reconnect', async () => { + mocks.isMicrophoneEnabled = false; + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + + mocks.connectionState = 'reconnecting'; + rerender(); + + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + }); + + // Holding voice open on the way out must not be the same thing as starting it. Open a link to a + // rematch that is already over and the page is exiting from its very first render — so a flag + // that only says "exiting" would take the tab lock, fetch a token and publish the microphone + // into a session the viewer is not in, on a page that is busy redirecting. + it('does not arm voice on a session that was already over when the page loaded', async () => { + const carriedOver = { ...makeSession('ended'), source_debate_id: 'debate-1' }; + const { container } = render( + Leave debate} + exiting + /> + ); + await flushOwnership(); + + expect(mocks.joinCalls.every(call => !call.enabled)).toBe(true); + expect(container.querySelector('[data-testid="livekit-room"]')).toBeNull(); + expect(mocks.getUserMedia).not.toHaveBeenCalled(); + // The pair are still drawn; there is simply no voice to arm. + expect(screen.getByText('Salina')).toBeInTheDocument(); + }); + + // The status is not the room. A rematch that ended while this page was still working through + // ownership and the token answers `browsing` for a render or two longer — React Query serves the + // stale one — so latching "voice was live" off the status latched it where nothing was live. With + // `exiting`, holding that open is not holding anything: it is taking the tab lock, minting a + // token and publishing the microphone into a session the viewer has already left. + it('does not arm voice for a status that went stale before the room came up', async () => { + const carriedOver = { ...makeSession('browsing'), source_debate_id: 'debate-1' }; + // No `flushOwnership` between the two: the point is that the exit lands while the ladder is + // still several awaits from a room. + const { container, rerender } = render(); + + rerender( + + ); + await flushOwnership(); + + expect(container.querySelector('[data-testid="livekit-room"]')).toBeNull(); + expect(mocks.joinCalls.every(call => !call.enabled)).toBe(true); + expect(mocks.getUserMedia).not.toHaveBeenCalled(); + }); + + // Leaving is a mutation, and the session answers voice-capable for the whole round trip after the + // click. So `exiting` arrives while the status still says voice is on — and a keep-alive that + // reads the status at all would let the ladder finish the lock, the token and the connection + // during the request, publishing a microphone because the viewer asked to leave. + it('does not arm voice while the leave request is still in flight', async () => { + const session = { ...makeSession('browsing'), source_debate_id: 'debate-1' }; + // No `flushOwnership` before the click: the room has not come up yet, which is the only state + // where there is nothing to hold open and everything to avoid starting. + const { container, rerender } = render(); + + // What Leave does first. The mutation is pending, so the session is still `browsing`. + rerender(); + await flushOwnership(); + + expect(container.querySelector('[data-testid="livekit-room"]')).toBeNull(); + expect(mocks.joinCalls.every(call => !call.enabled)).toBe(true); + expect(mocks.getUserMedia).not.toHaveBeenCalled(); + // And the header has not collapsed for it: the line it was drawing is still the line it draws. + expect(screen.getByText('Connecting voice…')).toBeInTheDocument(); + }); + + // The nudge is the other thing in this header that can arrive on its own, and leaving is exactly + // the event that arms it: an unmuted viewer's microphone reads as muted the moment the room + // drops, and "they are talking" is still true on that render, so the one-shot fires — growing + // the header at the one moment `exiting` exists to hold it still. + it('does not raise the talking nudge on the way out', async () => { + mocks.remoteParticipants = [remoteOpponent()]; + mocks.opponentMicPublication = { isMuted: false }; + mocks.isSpeaking = true; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + // Unmuted while they talk, so the nudge is unspent and there is a live "they are audible". + expect(screen.queryByTestId('rematch-voice-toast-opponent-talking')).toBeNull(); + + // Leaving: the room drops, which takes the microphone and the remote participants with it. + mocks.isMicrophoneEnabled = false; + mocks.connectionState = 'disconnected'; + mocks.remoteParticipants = []; + rerender(); + + expect(screen.queryByTestId('rematch-voice-toast-opponent-talking')).toBeNull(); + }); + + // And the other direction: one already on screen must not time out mid-exit either. Ten seconds + // is long enough that a click can land nine into it, and the bubble going away on its own is the + // same shift upward that raising it is downward. + it('does not let the talking nudge time out while leaving', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + mocks.isMicrophoneEnabled = false; + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + + mocks.isSpeaking = true; + rerender(); + expect(screen.getByTestId('rematch-voice-toast-opponent-talking')).toBeInTheDocument(); + + rerender(); + await act(async () => { + vi.advanceTimersByTime(12_000); + }); + + expect(screen.getByTestId('rematch-voice-toast-opponent-talking')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + /** + * The freeze is over the nudge's own lifecycle, not over the gate that renders it. + * + * A reconnect during the exit is the one state where the toast still goes, and deliberately: the + * header has already swapped the cards for `Reconnecting…` — the exception this exit path keeps + * on purpose, because live mute controls over a room with no connection are worse than the + * change — and `OpponentPresence` is dropped with it, which is what was sourcing "they are + * talking". Holding the bubble through that would keep a claim about the other person after the + * subscription behind it is gone, over a card that says the room is reconnecting, offering an + * Unmute that is the only mute affordance left and cannot work. The unmute notice stays, and the + * asymmetry is the point: "you are muted" survives a blip, "they are talking" does not. + */ + it('lets a reconnect take the nudge down even while leaving', async () => { + mocks.isMicrophoneEnabled = false; + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + mocks.isSpeaking = true; + rerender(); + expect(screen.getByTestId('rematch-voice-toast-opponent-talking')).toBeInTheDocument(); + + mocks.connectionState = 'reconnecting'; + rerender(); + + expect(screen.queryByTestId('rematch-voice-toast-opponent-talking')).toBeNull(); + // What replaced it, and what did not: the room says what it is doing, and the notice — whose + // claim is about the viewer's own microphone — rides the blip out as it always has. + expect(screen.getByText('Reconnecting…')).toBeInTheDocument(); + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + }); + + // Letting that room go must not take the header's shape with it. The connection is held open for + // a room that was up; the shape is held whatever was drawn — here the one line the room itself + // draws while it connects, which is what was on screen a moment before the click. + it('keeps the connecting line when it leaves before the room connected', async () => { + mocks.connectionState = 'connecting'; + const session = makeSession('browsing'); + const { container, rerender } = render(); + await flushOwnership(); + expect(screen.getByTestId('livekit-room')).toBeInTheDocument(); + expect(screen.getByText('Connecting voice…')).toBeInTheDocument(); + + rerender(); + + expect(screen.getByText('Connecting voice…')).toBeInTheDocument(); + expect(container.querySelector('[data-testid="livekit-room"]')).toBeNull(); + }); + + // A room that has given up is not a blip. Its Unmute is a button that cannot work, and pressing + // it would still spend the notice's one dismissal on a click that did nothing — so here the + // notice gives way to the card's own "Voice disconnected · Retry", which is the actionable thing. + it('drops the unmute notice when the room is gone rather than away', async () => { + mocks.isMicrophoneEnabled = false; + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + + mocks.connectionState = 'disconnected'; + rerender(); + + expect(screen.queryByTestId('rematch-unmute-notice')).toBeNull(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); + + // Except on the way out, where the whole point is that nothing moves. + it('keeps the unmute notice over a dead room while leaving', async () => { + mocks.isMicrophoneEnabled = false; + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + + mocks.connectionState = 'disconnected'; + rerender(); + + expect(screen.getByTestId('rematch-unmute-notice')).toBeInTheDocument(); + }); + + // Keeping the notice through an exit is not the same as raising one there. A pair from a recorded + // debate join unmuted and never see it; leaving drops the room, which takes `isMicrophoneEnabled` + // with it — so it used to appear for the first time on the way out, growing the header at the one + // moment `exiting` exists to hold it still. + it('does not raise the unmute notice on the way out', async () => { + mocks.remoteParticipants = [remoteOpponent()]; + const carriedOver = { ...makeSession('browsing'), source_debate_id: 'debate-1' }; + const { rerender } = render(); + await flushOwnership(); + // Unmuted, so it has never been raised. + expect(screen.queryByTestId('rematch-unmute-notice')).toBeNull(); + + // Leaving: the session ends and the room goes with it. + mocks.isMicrophoneEnabled = false; + mocks.connectionState = 'disconnected'; + rerender( + + ); + + expect(screen.queryByTestId('rematch-unmute-notice')).toBeNull(); + }); + + // Suppressing the dead-room message on the way out must not suppress the rest of the ladder: a + // room that never connected would otherwise be drawn with live mute controls over nothing. + it('keeps saying a room is reconnecting even while leaving', async () => { + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + + mocks.connectionState = 'reconnecting'; + rerender(); + + expect(screen.getByText('Reconnecting…')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^(Mute|Unmute) microphone$/ })).toBeNull(); + }); + + // Leaving ends the session, and an ended session is not voice-capable — so the controls used to + // tear themselves down in the second before the redirect landed, collapsing the card in front of + // someone who had already left. + it('keeps the controls while the viewer is on their way out', async () => { + mocks.remoteParticipants = [remoteOpponent()]; + const browsing = makeSession('browsing'); + const { rerender } = render( + Leave debate} + /> + ); + await flushOwnership(); + expect(screen.getByRole('button', { name: /^(Mute|Unmute) microphone$/ })).toBeInTheDocument(); + + // What leaving does: the session comes back ended, and the page says it is on its way out. + rerender( + Leave debate} + exiting + /> + ); + + expect(screen.getByRole('button', { name: /^(Mute|Unmute) microphone$/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Leave debate' })).toBeInTheDocument(); + }); + + // A dropped room on the way out is the same layout shift wearing a different hat, and "Retry" is + // an offer of something the viewer has just declined. + it('does not swap in a connection message while leaving', async () => { + mocks.remoteParticipants = [remoteOpponent()]; + const session = makeSession('browsing'); + const { rerender } = render(); + await flushOwnership(); + + mocks.connectionState = 'disconnected'; + rerender(); + + expect(screen.queryByText('Voice disconnected')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull(); + expect(screen.getByRole('button', { name: /^(Mute|Unmute) microphone$/ })).toBeInTheDocument(); }); // A hover variant outranks a plain utility on specificity whichever order they are written in, diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx index 9b99e999f2..6bdcbeb056 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.tsx @@ -40,7 +40,6 @@ import { useElevatedPopoverPortal } from '~/design-system/use-elevated-popover-p import { PAIR_PILL, type PairHeaderParticipant, - type PairHeaderPositions, type PairHeaderToast, type PairHeaderVoice, type PairMicState, @@ -65,6 +64,13 @@ const NUDGE_MS = 10_000; type OwnershipState = 'pending' | 'owned' | 'elsewhere'; +/** + * Said from three places — before the tab lock, before the token, and from inside a room that has + * mounted but not connected — because to the viewer they are one wait. Shared so that the exit + * path, which stands in for the third, cannot drift from what it is standing in for. + */ +const CONNECTING_VOICE = { kind: 'message', message: 'Connecting voice…' } as const satisfies PairHeaderVoice; + function voiceCapable(status: DebateRematchSession['status']) { return status === 'browsing' || status === 'request_pending'; } @@ -152,15 +158,28 @@ function usePrimedMicrophonePermission(enabled: boolean, deviceId?: string) { * that shouts. `spentRef` is owned by the header's outermost component rather than declared here on * purpose: this hook's component is unmounted and rebuilt by every reconnect and every "audio is * blocked" detour, so a local ref would quietly reset the one-shot several times a session. + * + * `frozen` stops the clock in both directions, and leaving is the event it is for. Every input this + * hook reads moves at once when the room drops: an unmuted viewer's microphone reads as muted, and + * "they are talking" is still true on that render, so the one-shot fires and the bubble arrives on + * the way out — while one already up can reach its ten seconds in the same window and go. Both are + * the header changing size under someone who has already left. Whatever is on screen when the exit + * begins stays there until the page does; `dismiss` is exempt, because a viewer who presses the + * control has asked for the change. */ -function useMutedNudge(muted: boolean, opponentAudible: boolean, spentRef: React.MutableRefObject) { +function useMutedNudge( + muted: boolean, + opponentAudible: boolean, + spentRef: React.MutableRefObject, + frozen: boolean +) { const [visible, setVisible] = React.useState(false); React.useEffect(() => { - if (spentRef.current || !muted || !opponentAudible) return; + if (frozen || spentRef.current || !muted || !opponentAudible) return; spentRef.current = true; setVisible(true); - }, [muted, opponentAudible, spentRef]); + }, [frozen, muted, opponentAudible, spentRef]); // The dismissal clock is deliberately its own effect, keyed only on `visible`. Sharing the // effect above would put `opponentAudible` in its dependencies, and the opponent stops talking @@ -168,15 +187,17 @@ function useMutedNudge(muted: boolean, opponentAudible: boolean, spentRef: React // early-return instead of re-arming it, and the toast would sit there for the rest of the // session. React.useEffect(() => { - if (!visible) return; + // Freezing mid-count cancels the pending timeout through this effect's own cleanup, which is + // what stops a bubble nine seconds old from going out from under the exit. + if (!visible || frozen) return; const timer = setTimeout(() => setVisible(false), NUDGE_MS); return () => clearTimeout(timer); - }, [visible]); + }, [frozen, visible]); // Unmuting is what the nudge was asking for; leaving it up afterwards is just noise. React.useEffect(() => { - if (!muted) setVisible(false); - }, [muted]); + if (!muted && !frozen) setVisible(false); + }, [frozen, muted]); // For the mute button, which cannot wait for `muted` to catch up: unmuting leaves // `isMicrophoneEnabled` false for as long as the permission dialog is open, so the effect above @@ -199,8 +220,6 @@ type PairContext = { opponent: PairHeaderParticipant; opponentName: string; onOpenOpponentSpace: (event: React.MouseEvent) => void; - lockedClaim: { claim: string; spaceName?: string | null } | null; - positions: PairHeaderPositions | null; leaveAction?: React.ReactNode; }; @@ -230,6 +249,15 @@ type RematchVoiceHeaderProps = { currentUserId: string; /** The page's Leave button. It lives in your card's corner now, not at the end of the tab row. */ leaveAction?: React.ReactNode; + /** + * The viewer is on their way out, and the page is about to unmount. + * + * Leaving ends the session server-side, and an ended session is not voice-capable — so without + * this the controls tear themselves down a second or so before the redirect lands, and the card + * collapses in front of someone who has already left. Nothing about this header is worth + * re-laying-out on the way to somewhere else. + */ + exiting?: boolean; }; export function RematchVoiceHeader(props: RematchVoiceHeaderProps) { @@ -239,8 +267,53 @@ export function RematchVoiceHeader(props: RematchVoiceHeaderProps) { return ; } -function SessionRematchVoiceHeader({ session, currentUserId, leaveAction }: RematchVoiceHeaderProps) { - const voiceActive = voiceCapable(session.status); +function SessionRematchVoiceHeader({ session, currentUserId, leaveAction, exiting = false }: RematchVoiceHeaderProps) { + const voiceCapableNow = voiceCapable(session.status); + + /** + * Whether a room has actually come up this visit. Latched by `onConnected` and never released — + * a retry is still a room that was live a moment ago. + * + * The session's own status is the wrong thing to latch on, even though it is the thing that + * turns voice on. It reads voice-capable on the first render of a rematch that was already over + * when the link was opened — React Query serves the stale answer, and the ladder below is + * several awaits deep in ownership and token before anything connects — so a status latch is + * set in states where there is nothing up to keep. Paired with `exiting`, “hold what is there” + * then becomes “take the tab lock, mint a token and publish a microphone” into a session the + * viewer has just left. A debate-sourced rematch joins unmuted, so that is a live microphone + * going out after they are gone rather than a wasted request. + */ + const [roomWasLive, setRoomWasLive] = React.useState(false); + + /** + * Sticky on the way out, but only over a room that was already up. + * + * This decides whether to keep *connecting*, which is a different question from what the header + * draws — `heldVoiceRef` below holds that still on its own. So the connection outlives the exit + * only where there is a connection to outlive. + * + * Once the exit begins the status stops being consulted at all, rather than being one half of an + * `||`. Leaving is a mutation: `exiting` goes true on the click, and the session answers + * voice-capable for the whole round trip after it. Reading the status there would let the ladder + * finish the lock, the token and the connection *during* the request — publishing a microphone + * into a session because the viewer asked to leave it. + */ + const voiceActive = exiting ? roomWasLive : voiceCapableNow; + + /** + * The shape the header had before the exit began, `'room'` meaning the live tree itself. + * + * `exiting` freezes what is drawn so the card cannot re-lay-out between the click and the + * redirect — but freezing it by re-deriving it would mean keeping the connection open just to + * keep the derivation true, which is how the microphone got out. Holding the last shape instead + * separates the two: the room goes when it was never up, and the header still draws what it was + * drawing a moment ago. `null` is “nothing drawn yet”, which is a session already over on load. + * + * Written during render because the exit render is the one that needs it, and an effect runs a + * beat too late. The write is derived from the same props each time, so a double invocation + * under StrictMode stores the same value twice. + */ + const heldVoiceRef = React.useRef(null); const opponent = session.participants.find(participant => participant.user_id !== currentUserId) ?? null; const local = session.participants.find(participant => participant.user_id === currentUserId) ?? null; @@ -382,6 +455,11 @@ function SessionRematchVoiceHeader({ session, currentUserId, leaveAction }: Rema connectedRef.current = true; setConnectFailed(false); }, []); + + // Latched from inside the room, off the same connection state `everConnected` reads, so "a room + // was up" means one thing on both sides of the boundary. `` fires for a + // first connect only; this also covers a room that came back. + const handleRoomLive = React.useCallback(() => setRoomWasLive(true), []); const handleError = React.useCallback(() => { if (connectedRef.current) return; setConnectFailed(true); @@ -439,16 +517,6 @@ function SessionRematchVoiceHeader({ session, currentUserId, leaveAction }: Rema // local copy got wrong by leaving the card inert until it landed. const openOpponentProfile = useOpenDebaterProfile(opponent); - // Once the pair lock a claim the header stops being only about voice: it is who is arguing what, - // which side each of them took, and the claim itself above both cards. - const request = session.status === 'request_pending' ? session.request : null; - const lockedClaim = request ? { claim: request.claim.claim } : null; - const positions: PairHeaderPositions | null = request - ? request.requester_user_id === currentUserId - ? { localAgrees: request.requester_position, opponentAgrees: request.recipient_position } - : { localAgrees: request.recipient_position, opponentAgrees: request.requester_position } - : null; - // No pair to draw, but the viewer is still in a session they must be able to leave — and Leave // lives in the header now. The row is the header's, minus everything that needs two people. if (!opponent) return leaveAction ?
{leaveAction}
: null; @@ -458,44 +526,59 @@ function SessionRematchVoiceHeader({ session, currentUserId, leaveAction }: Rema opponent: toHeaderParticipant(opponent) as PairHeaderParticipant, opponentName, onOpenOpponentSpace: openOpponentProfile, - lockedClaim, - positions, leaveAction, }; const headerWith = (voice: PairHeaderVoice) => ; - if (!voiceActive) return headerWith({ kind: 'absent' }); + /** Everything the header can say before there is a room to say it from. `null` hands over. */ + const preRoomVoice = ((): PairHeaderVoice | null => { + if (!voiceActive) return { kind: 'absent' }; - if (ownership === 'elsewhere') { - return headerWith({ - kind: 'message', - message: 'Voice is active in another tab', - actionLabel: 'Use voice here', - onAction: takeOver, - }); - } + if (ownership === 'elsewhere') { + return { + kind: 'message', + message: 'Voice is active in another tab', + actionLabel: 'Use voice here', + onAction: takeOver, + }; + } - if (ownership === 'pending' || join.isLoading) return headerWith({ kind: 'message', message: 'Connecting voice…' }); + if (ownership === 'pending' || join.isLoading) return CONNECTING_VOICE; - if (join.error) { - // No backend support: LiveKit unconfigured (503) or the endpoint not deployed yet (404). The - // picker works exactly as before voice existed. A blocked state (400/403) likewise has no - // user-facing remedy here. - if (join.error instanceof GeoChatRequestError && [400, 403, 404].includes(join.error.status)) { - return headerWith({ kind: 'absent' }); + if (join.error) { + // No backend support: LiveKit unconfigured (503) or the endpoint not deployed yet (404). The + // picker works exactly as before voice existed. A blocked state (400/403) likewise has no + // user-facing remedy here. + if (join.error instanceof GeoChatRequestError && [400, 403, 404].includes(join.error.status)) { + return { kind: 'absent' }; + } + if (join.error instanceof GeoChatRequestError && join.error.code === 'livekit_not_configured') { + return { kind: 'absent' }; + } + return { kind: 'message', message: 'Voice is unavailable', actionLabel: 'Retry', onAction: retry }; } - if (join.error instanceof GeoChatRequestError && join.error.code === 'livekit_not_configured') { - return headerWith({ kind: 'absent' }); + + if (!join.data) return CONNECTING_VOICE; + + if (connectFailed) { + return { kind: 'message', message: 'Voice is unavailable', actionLabel: 'Retry', onAction: retry }; } - return headerWith({ kind: 'message', message: 'Voice is unavailable', actionLabel: 'Retry', onAction: retry }); - } - if (!join.data) return headerWith({ kind: 'message', message: 'Connecting voice…' }); + return null; + })(); + + if (!exiting) heldVoiceRef.current = preRoomVoice ?? 'room'; - if (connectFailed) { - return headerWith({ kind: 'message', message: 'Voice is unavailable', actionLabel: 'Retry', onAction: retry }); - } + const held = heldVoiceRef.current; + const voice = exiting ? (held === 'room' ? null : (held ?? { kind: 'absent' })) : preRoomVoice; + + if (voice) return headerWith(voice); + + // Only reachable on the way out, and only where the room was mounted but had not connected when + // the exit began: there is nothing to hold open, so the header keeps the one line the room was + // drawing from inside rather than collapsing to nothing. + if (!voiceActive || !join.data) return headerWith(CONNECTING_VOICE); return ( ; function VoiceHeaderBody({ pair, + exiting, + onRoomLive, opponentUserId, micFailure, onMicIntentChange, @@ -597,6 +684,9 @@ function VoiceHeaderBody({ analytics, }: { pair: PairContext; + exiting: boolean; + /** Tells the header above that a room has come up — what makes holding one open on exit legal. */ + onRoomLive: () => void; opponentUserId: string; micFailure: MediaDeviceFailure | null; onMicIntentChange: (enabled: boolean) => void; @@ -640,9 +730,10 @@ function VoiceHeaderBody({ React.useEffect(() => { if (connectionState === ConnectionState.Connected) { setEverConnected(true); + onRoomLive(); analytics.recordJoined(); } - }, [analytics, connectionState]); + }, [analytics, connectionState, onRoomLive]); const remoteParticipants = useRemoteParticipants(); const opponentParticipant = remoteParticipants.find(participant => participant.identity === opponentUserId) ?? null; @@ -657,6 +748,20 @@ function VoiceHeaderBody({ const [opponentMuted, setOpponentMuted] = React.useState(true); + /** + * Whether the opponent has been in the room at all this visit. + * + * The unmute notice is gated on this rather than on them being here right now. "Nobody to talk + * to" is a reason never to raise it, but once it is up, taking it away again when the other + * person drops — for a reconnect, or for good — moves everything under it at a moment the viewer + * did nothing to cause. What the notice says is still true of the visit: you are muted, and there + * is somebody you came here to talk to. + */ + const [opponentEverJoined, setOpponentEverJoined] = React.useState(false); + React.useEffect(() => { + if (opponentParticipant) setOpponentEverJoined(true); + }, [opponentParticipant]); + // Everything below used to live in a `ConnectedPairHeader` this rendered instead of a message. // Swapping one component for another at the same position is a remount, and this subtree is the // wrong place for one: it would close an open Audio settings popover, drop focus from the Leave @@ -695,7 +800,8 @@ function VoiceHeaderBody({ const { visible: nudgeVisible, dismiss: dismissNudge } = useMutedNudge( muted && !micFailed, opponentAudible, - nudgeSpentRef + nudgeSpentRef, + exiting ); React.useEffect(() => { @@ -726,13 +832,20 @@ function VoiceHeaderBody({ * opponent may well be talking, and the viewer simply cannot hear it until they click. */ const connectionMessage = ((): Extract | null => { + // Ending the session can drop a connected room within the second it takes the redirect to + // land, and swapping its controls for "Voice disconnected · Retry" on the way out is both a + // layout shift and an offer of something the viewer has just declined. Only that branch: a room + // that is still connecting, or reconnecting, has to keep saying so — leaving would otherwise + // put live mute controls over a room that has no connection at all. if (connectionState === ConnectionState.Disconnected && everConnected) { - return { kind: 'message', message: 'Voice disconnected', actionLabel: 'Retry', onAction: onRetry }; + return exiting + ? null + : { kind: 'message', message: 'Voice disconnected', actionLabel: 'Retry', onAction: onRetry }; } if (connectionState !== ConnectionState.Connected) { const reconnecting = connectionState === ConnectionState.Reconnecting || connectionState === ConnectionState.SignalReconnecting; - return { kind: 'message', message: reconnecting ? 'Reconnecting…' : 'Connecting voice…' }; + return reconnecting ? { kind: 'message', message: 'Reconnecting…' } : CONNECTING_VOICE; } if (!canPlayAudio) { return { @@ -765,12 +878,38 @@ function VoiceHeaderBody({ talkingWhileMuted: TALKING_WHILE_MUTED, }; - // Only while muted, only with somebody to talk to, only once the room is actually up, and only - // until the viewer has answered it once. + /** + * The room is gone, rather than merely away. + * + * A blip does not make the viewer un-muted or the other person un-paired, and unmuting through it + * records an intent the reconnect restores — so the notice rides a reconnect out rather than + * taking everything under it with it. A room that has given up is different: its Unmute is a + * button that cannot work, and pressing it would still spend the notice's one dismissal on a + * click that did nothing. Not while `exiting`, where nothing changes shape at all. + */ + const roomGone = !exiting && connectionState === ConnectionState.Disconnected && everConnected; + + // Only while muted, only once there has been somebody to talk to, and only until the viewer has + // answered it once. + const noticeApplies = muted && !micFailed && !noticeDismissed && opponentEverJoined && !roomGone; + + /** + * Keeping it through the exit is not the same as raising it there. + * + * A pair who arrived from a recorded debate join unmuted and never see this. Leaving drops the + * room, which takes `isMicrophoneEnabled` with it — so without this the notice appears for the + * first time on the way out, growing the header at the one moment this whole flag exists to hold + * it still. + */ + const [noticeWasShown, setNoticeWasShown] = React.useState(false); + React.useEffect(() => { + // `!exiting` matters as much as the condition itself: without it the latch is set by the very + // render it exists to suppress, and the notice arrives anyway one render later. + if (noticeApplies && !exiting) setNoticeWasShown(true); + }, [exiting, noticeApplies]); + const notice = - !connectionMessage && muted && !micFailed && !noticeDismissed && opponentParticipant - ? { onUnmute: unmute, onDismiss: onDismissNotice } - : null; + noticeApplies && (!exiting || noticeWasShown) ? { onUnmute: unmute, onDismiss: onDismissNotice } : null; const toast: PairHeaderToast | null = !connectionMessage && nudgeVisible ? { kind: 'opponent-talking', onUnmute: unmute, onDismiss: dismissNudge } : null;