Skip to content
Merged
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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
/**
Expand All @@ -112,8 +106,6 @@ export function RematchPairHeader({
voice,
notice,
toast,
lockedClaim,
positions,
onOpenOpponentSpace,
leaveAction,
}: RematchPairHeaderProps) {
Expand All @@ -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 (
<div className="flex flex-col gap-3">
{lockedClaim ? <LockedClaim claim={lockedClaim.claim} spaceName={lockedClaim.spaceName} /> : 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
Expand All @@ -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}
/>
<VsBadge />
<YouCard
local={local}
voice={voice}
opponentName={opponentName}
agrees={positions?.localAgrees}
leaveAction={leaveAction}
/>
<YouCard local={local} voice={voice} opponentName={opponentName} leaveAction={leaveAction} />
</div>

{/* The spoken half of the nudges: a region that is always mounted and only changes text. A
Expand Down Expand Up @@ -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 (
<div className="flex flex-col gap-1.5">
{spaceName ? <span className="text-footnoteMedium text-grey-04">{spaceName}</span> : null}
<h2 className="text-mediumTitle text-text">{claim}</h2>
</div>
);
}

function VsBadge() {
return (
<div className="flex items-center justify-center">
Expand Down Expand Up @@ -251,7 +220,6 @@ function PairCardBody({
nameTitle,
state,
action,
footer,
}: {
avatar: React.ReactNode;
name: string;
Expand All @@ -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 (
<>
Expand All @@ -275,7 +241,6 @@ function PairCardBody({
</div>
{action ? <div className="flex shrink-0 items-center">{action}</div> : null}
</div>
{footer}
</>
);
}
Expand All @@ -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';
Expand Down Expand Up @@ -393,7 +356,6 @@ function YouCard({
</>
}
action={leaveAction}
footer={agrees === undefined ? null : <PositionChip agrees={agrees} />}
/>
</div>
);
Expand All @@ -404,15 +366,13 @@ function OpponentCard({
name,
state,
showMicState,
agrees,
onOpen,
describedBy,
}: {
opponent: PairHeaderParticipant;
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;
Expand Down Expand Up @@ -446,7 +406,6 @@ function OpponentCard({
<ChevronRight />
</span>
}
footer={agrees === undefined ? null : <PositionChip agrees={agrees} />}
/>
);

Expand Down Expand Up @@ -540,39 +499,6 @@ function TalkingBars() {
);
}

/** Which side of the locked claim this person is on. */
function PositionChip({ agrees }: { agrees: boolean }) {
return (
<span
className={cx(
'flex min-h-7 items-center gap-1.5 self-start rounded-full px-3 text-button text-text',
agrees ? 'bg-successTertiary' : 'bg-errorTertiary'
)}
>
<ThumbIcon down={!agrees} />
{agrees ? 'Agree' : 'Disagree'}
</span>
);
}

function ThumbIcon({ down }: { down: boolean }) {
return (
<svg
viewBox="0 0 24 24"
aria-hidden
className={cx('size-3.5', down ? 'rotate-180 text-red-01' : 'text-green')}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M7 10v11H4V10h3z" />
<path d="M7 10l4-8a2 2 0 0 1 3 2l-1 5h6a2 2 0 0 1 2 2.3l-1.3 7A2 2 0 0 1 17.7 21H7" />
</svg>
);
}

/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<motion.article
{...hubCardMotion}
data-testid="rematch-outbound-request"
className="flex w-full flex-col gap-3 rounded-lg border border-grey-02 bg-white p-3"
>
<div className="flex items-center justify-between gap-2">
<SpaceChip spaceId={request.claim.space_id} />
{/* 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. */}
<span
className={cx(
'flex shrink-0 items-center gap-1 text-footnote',
countdown.expired ? 'text-red-01' : 'text-text'
)}
>
<Time />
{countdown.expired ? 'Expired' : countdown.label}
</span>
</div>

<Text as="p" variant="metadataMedium">
{request.claim.claim}
</Text>

<RequestParties viewer={viewer} opponent={opponent} />

{/* 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. */}
<span className="flex items-center justify-center text-footnote text-grey-04">
{countdown.expired ? 'This request has expired.' : 'Awaiting response'}
</span>
</motion.article>
);
}
Loading
Loading