* 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>
170 lines
5.9 KiB
TypeScript
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'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>
|
|
);
|
|
}
|