Files
silo-server/web/src/pages/WatchTogetherJoin.tsx
T
b250dbb59b fix(watchtogether): harden realtime sync, room lifecycle, and lobby UX (#273)
* fix(watchtogether): harden realtime sync, room lifecycle, and lobby UX

Remediates all findings from a deep review of the Watch Together feature.

Server:
- Serialize every websocket write (pong/error replies bypassed the write
  mutex, racing broadcasts on the same gorilla conn)
- Send room_closed with a reason on terminal connect failures so clients
  stop reconnecting to dead rooms
- Persist room state outside the service-wide mutex via a shared
  generation-CAS helper; drop ~450 lines of dead duplicated methods
- Measure transport latency from server-side ping/pong RTT (was one-way
  client-clock delta, poisoned by clock skew) and clamp the lead time
- Re-evaluate readiness when a waiting participant disconnects and add a
  30s waiting deadline that skips stragglers (activates ignoreWait)
- Guard the host-disconnect close timer against reconnect races
- Clamp buffering-report anchor moves; clear stale member sessions on
  selection change
- Janitor: evict empty live rooms and close rooms idle >24h
- Snapshot gains an additive members list with profile display names

Web:
- Surface terminal room errors (REST 404/410/403 and WS error codes) as
  closedReason instead of reconnecting forever on "Connecting..."
- Memoize the playback-sync hook and narrow VideoPlayer's video-listener
  effect deps to stop re-subscribing 13 listeners on every render
- Preserve invite-link destination through login/profile guards
- Lobby: terminal ended/missing-token states with CTAs, End-room confirm
  dialog, toast feedback via shared action helpers (dedup with player),
  participant list with guest Leave, mobile-visible connection status,
  document title, a11y labels/focus reveal, unified status dot component
- Delete dead useWatchTogetherRoom hook (345 lines, zero importers)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(watchtogether): guard indexed access in join-page keyboard nav for noUncheckedIndexedAccess

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(watchtogether): reconcile CAS conflicts without regressing newer state; roll back unpersisted waiting-resume

Addresses Codex review on PR #273:
- persistRoomChangeLocked now undoes the failed writer's optimistic
  generation increment and only adopts the refreshed database row when it
  is at least as new as the local copy, so a stale conflict refresh can
  no longer overwrite a concurrent writer's newer in-memory state (and a
  failed write can no longer leave a phantom generation)
- maybeResumeFromWaitingLocked restores the waiting state and re-arms the
  deadline when the resume transition fails to persist, instead of
  broadcasting a resume the database never recorded

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:34:02 -04:00

325 lines
12 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { ApiClientError } from "@/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import {
createWatchTogetherRoom,
joinWatchTogetherRoom,
type WatchTogetherSelectionMode,
} from "@/lib/watchTogether";
function describeJoinError(error: unknown) {
if (error instanceof ApiClientError) {
if (error.status === 404) {
return "Room not found.";
}
if (error.status === 410) {
return "That room is no longer active.";
}
return error.message;
}
return error instanceof Error ? error.message : "Failed to join room.";
}
const selectionModeOptions = [
{
value: "host_pick",
title: "Host Picks",
caption: "Host decides what to watch.",
},
{
value: "vote",
title: "Vote Together",
caption: "Everyone suggests and votes.",
},
] as const satisfies ReadonlyArray<{
value: WatchTogetherSelectionMode;
title: string;
caption: string;
}>;
export default function WatchTogetherJoin() {
useDocumentTitle("Watch Party");
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get("token")?.trim() ?? "";
const [code, setCode] = useState("");
const [selectionMode, setSelectionMode] = useState<WatchTogetherSelectionMode>("host_pick");
const [creating, setCreating] = useState(false);
const [joining, setJoining] = useState(false);
const [error, setError] = useState<string | null>(null);
const modeButtonRefs = useRef<Array<HTMLButtonElement | null>>([]);
const hasInviteToken = token !== "";
const handleModeKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
let nextIndex: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
nextIndex = (index + 1) % selectionModeOptions.length;
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
nextIndex = (index - 1 + selectionModeOptions.length) % selectionModeOptions.length;
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = selectionModeOptions.length - 1;
}
if (nextIndex === null) {
return;
}
const nextOption = selectionModeOptions[nextIndex];
if (!nextOption) {
return;
}
event.preventDefault();
setSelectionMode(nextOption.value);
modeButtonRefs.current[nextIndex]?.focus();
},
[],
);
const joinRoom = useCallback(
async (input: { code?: string; join_token?: string }) => {
setJoining(true);
setError(null);
try {
const response = await joinWatchTogetherRoom(input);
if (!response.room_access_token) {
throw new Error("Room access token was missing from the join response.");
}
navigate(`/rooms/${response.room.room_id}?room_token=${response.room_access_token}`, {
replace: true,
viewTransition: true,
});
} catch (joinError) {
setError(describeJoinError(joinError));
} finally {
setJoining(false);
}
},
[navigate],
);
const createRoom = useCallback(async () => {
setCreating(true);
setError(null);
try {
const response = await createWatchTogetherRoom({ selection_mode: selectionMode });
if (!response.room_access_token) {
throw new Error("Room access token was missing from the create response.");
}
navigate(`/rooms/${response.room.room_id}?room_token=${response.room_access_token}`, {
replace: true,
viewTransition: true,
});
} catch (createError) {
setError(createError instanceof Error ? createError.message : "Failed to create room.");
} finally {
setCreating(false);
}
}, [navigate, selectionMode]);
useEffect(() => {
if (!hasInviteToken) {
return;
}
void joinRoom({ join_token: token });
}, [hasInviteToken, joinRoom, token]);
const headline = useMemo(
() => (hasInviteToken ? "Joining Watch Party" : "Create or Join a Watch Party"),
[hasInviteToken],
);
// While an invite token is being auto-joined (and hasn't failed yet), show a
// pending state instead of the full create/join form.
const autoJoinPending = hasInviteToken && !error;
if (autoJoinPending) {
return (
<div className="mx-auto flex w-full max-w-5xl flex-col items-center gap-4 px-6 py-24 text-center">
<div
aria-hidden="true"
className="border-foreground/20 border-t-foreground h-8 w-8 animate-spin rounded-full border-2"
/>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{headline}</h1>
<p className="text-muted-foreground max-w-sm text-sm" role="status">
Hang tight — we're taking you into the room.
</p>
</div>
);
}
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10 sm:px-8">
<div className="max-w-2xl">
<p className="text-muted-foreground text-[11px] font-semibold tracking-[0.18em] uppercase">
Watch Party
</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">{headline}</h1>
<p className="text-muted-foreground mt-3 max-w-2xl text-sm leading-6">
Start an empty room, invite people in, then choose what everyone watches from the lobby.
Got an invite link? Just open it — you'll join automatically.
</p>
</div>
{/* ─── How it works ─── */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="surface-panel-subtle rounded-xl p-4">
<div className="text-foreground/50 mb-1.5 text-[11px] font-semibold tracking-[0.15em] uppercase">
Synced Playback
</div>
<p className="text-foreground/65 text-sm leading-relaxed">
Everyone watches in sync. When someone seeks or the host picks new content, playback
pauses while all participants buffer, then resumes together.
</p>
</div>
<div className="surface-panel-subtle rounded-xl p-4">
<div className="text-foreground/50 mb-1.5 text-[11px] font-semibold tracking-[0.15em] uppercase">
Playback Controls
</div>
<p className="text-foreground/65 text-sm leading-relaxed">
By default only the host can play, pause, and seek. The host can enable "Allow Pause" to
let guests pause and resume, but seeking stays host-only.
</p>
</div>
<div className="surface-panel-subtle rounded-xl p-4">
<div className="text-foreground/50 mb-1.5 text-[11px] font-semibold tracking-[0.15em] uppercase">
Room & Invites
</div>
<p className="text-foreground/65 text-sm leading-relaxed">
Share the room code or invite link to let others join. If the host disconnects the room
stays open for 15 seconds — if they don't reconnect, the party ends automatically.
</p>
</div>
</div>
{error ? (
<div
role="alert"
className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200"
>
{error}
</div>
) : null}
<div className="grid gap-5 lg:grid-cols-2">
<section className="surface-panel rounded-xl p-5">
<div className="flex flex-col gap-3">
<div>
<h2 className="text-lg font-semibold">Create a room</h2>
<p className="text-foreground/55 mt-1 text-sm leading-6">
Open a lobby, copy the invite, and pick the movie or episode once everyone is in.
</p>
</div>
<div>
<label id="watch-selection-mode-label" className="text-sm font-medium">
Selection mode
</label>
<div
className="mt-2 flex gap-2"
role="radiogroup"
aria-labelledby="watch-selection-mode-label"
>
{selectionModeOptions.map((option, index) => {
const selected = selectionMode === option.value;
return (
<button
key={option.value}
ref={(element) => {
modeButtonRefs.current[index] = element;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => setSelectionMode(option.value)}
onKeyDown={(event) => handleModeKeyDown(event, index)}
className={`flex-1 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors ${
selected
? "border-foreground/50 bg-accent font-medium"
: "text-muted-foreground hover:bg-muted/60"
}`}
>
<div className="font-medium">{option.title}</div>
<div className="text-muted-foreground mt-0.5 text-xs">{option.caption}</div>
</button>
);
})}
</div>
</div>
<Button
type="button"
onClick={() => void createRoom()}
disabled={creating || joining}
className="h-11 rounded-lg px-5"
>
{creating ? "Creating..." : "Create Watch Party"}
</Button>
</div>
</section>
<section className="surface-panel rounded-xl p-5">
<div className="grid gap-4">
<div>
<h2 className="text-lg font-semibold">Join a room</h2>
<p className="text-foreground/55 mt-1 text-sm leading-6">
Have a room code from the host? Enter it here to join their party.
</p>
</div>
<div className="grid gap-2">
<label htmlFor="watch-room-code" className="text-sm font-medium">
Room code
</label>
<Input
id="watch-room-code"
value={code}
onChange={(event) => {
setCode(event.target.value.toUpperCase());
if (error) {
setError(null);
}
}}
maxLength={12}
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
placeholder="ABCD1234"
disabled={joining || creating}
className="h-11 text-sm tracking-[0.18em] uppercase"
/>
</div>
<Button
type="button"
onClick={() => void joinRoom({ code: code.trim().toUpperCase() })}
disabled={joining || creating || code.trim() === ""}
className="h-11 rounded-lg px-5"
>
{joining ? "Joining..." : "Join Watch Party"}
</Button>
{hasInviteToken ? (
<Button
type="button"
variant="outline"
onClick={() => void joinRoom({ join_token: token })}
disabled={joining || creating}
className="h-11 rounded-lg px-5"
>
Retry Invite Link
</Button>
) : null}
</div>
</section>
</div>
</div>
);
}