Files
silo-server/web/src/pages/Profiles.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

170 lines
5.9 KiB
TypeScript

import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { Lock, Plus } from "lucide-react";
import { toast } from "sonner";
import type { Profile } from "@/api/types";
import { ProfileEditorDialog } from "@/components/profiles/ProfileEditorDialog";
import { ProfilePinDialog } from "@/components/profiles/ProfilePinDialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { useAuth } from "@/hooks/useAuth";
import { useAvailableUserLibraries } from "@/hooks/queries/libraries";
import { useProfiles } from "@/hooks/queries/profiles";
import { sanitizeAuthRedirect } from "@/lib/authRedirect";
export default function Profiles() {
const { data: profiles = [], isLoading: profilesLoading, avatarUploadEnabled } = useProfiles();
const { data: libraries = [], isLoading: librariesLoading } = useAvailableUserLibraries();
const [editorOpen, setEditorOpen] = useState(false);
const [pinProfile, setPinProfile] = useState<Profile | null>(null);
const { selectProfile, verifyProfilePin, logout } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const redirectTarget = sanitizeAuthRedirect(searchParams.get("redirect"));
useDocumentTitle("Profiles");
const isLoading = profilesLoading || librariesLoading;
function enterApp() {
navigate(redirectTarget ?? "/");
}
function handleSelect(profile: Profile) {
if (profile.has_pin) {
setPinProfile(profile);
return;
}
selectProfile(profile);
enterApp();
}
async function handleCreateSuccess(
profile: Profile,
context: {
pin: string;
},
) {
if (context.pin === "") {
selectProfile(profile);
enterApp();
return;
}
try {
const response = await verifyProfilePin(profile.id, context.pin);
if (response.valid && response.profile_token) {
selectProfile(profile, response.profile_token);
enterApp();
return;
}
} catch {
// fall through to the toast below
}
toast.error("Profile created, but PIN verification failed");
}
if (isLoading) {
return (
<div className="auth-shell flex-col gap-8">
<Skeleton className="h-12 w-64" />
<div className="flex flex-wrap justify-center gap-5">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="flex w-[148px] flex-col items-center gap-3 p-5">
<Skeleton className="h-20 w-20 rounded-full" />
<Skeleton className="h-4 w-16 rounded" />
</div>
))}
</div>
</div>
);
}
if (profiles.length === 0) {
return (
<div className="auth-shell flex-col gap-6">
<div className="relative z-10 flex flex-col items-center gap-6">
<div className="w-full max-w-md space-y-3 text-center">
<h1 className="page-title text-[clamp(2.1rem,6vw,4rem)]">Create your first profile</h1>
<p className="text-muted-foreground text-sm">
You need a profile before you can enter the app.
</p>
</div>
<Button onClick={() => setEditorOpen(true)}>
<Plus className="h-4 w-4" />
Create profile
</Button>
<Button variant="outline" size="sm" onClick={logout}>
Sign out
</Button>
</div>
<ProfileEditorDialog
open={editorOpen}
libraries={libraries}
avatarUploadEnabled={avatarUploadEnabled}
onOpenChange={setEditorOpen}
onSaveSuccess={(profile, context) => void handleCreateSuccess(profile, context)}
/>
</div>
);
}
return (
<div className="auth-shell flex-col gap-8">
<div className="relative z-10 text-center">
<h1 className="page-title text-[clamp(2.2rem,7vw,4.5rem)]">Who&apos;s watching?</h1>
</div>
<div className="relative z-10 flex max-w-5xl flex-wrap justify-center gap-5">
{profiles.map((profile) => (
<div key={profile.id} className="group relative">
<button
onClick={() => handleSelect(profile)}
aria-label={profile.has_pin ? `${profile.name} (PIN protected)` : profile.name}
className="surface-panel hover:border-primary flex w-[148px] flex-col items-center gap-3 rounded-[1.75rem] p-5 transition-all duration-150 hover:-translate-y-1"
>
<div className="relative">
<Avatar className="ring-border group-hover:ring-primary/30 h-20 w-20 ring-2 transition-colors">
{profile.avatar_url ? <AvatarImage src={profile.avatar_url} alt="" /> : null}
<AvatarFallback className="bg-surface text-primary text-2xl font-bold">
{profile.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{profile.has_pin ? (
<span className="bg-surface border-border text-muted-foreground absolute -right-0.5 -bottom-0.5 flex h-6 w-6 items-center justify-center rounded-full border">
<Lock className="h-3 w-3" aria-hidden="true" />
</span>
) : null}
</div>
<span className="text-sm font-medium">{profile.name}</span>
</button>
</div>
))}
</div>
<Button variant="outline" size="sm" onClick={logout} className="relative z-10 mt-2">
Sign out
</Button>
<ProfilePinDialog
profile={pinProfile}
onClose={() => setPinProfile(null)}
onVerified={(profile, token) => {
setPinProfile(null);
selectProfile(profile, token);
enterApp();
}}
verifyPin={verifyProfilePin}
/>
</div>
);
}