diff --git a/apps/web/src/chats/ChatNavigation.ts b/apps/web/src/chats/ChatNavigation.ts index f7d6488..d5ecc89 100644 --- a/apps/web/src/chats/ChatNavigation.ts +++ b/apps/web/src/chats/ChatNavigation.ts @@ -1,27 +1,28 @@ -import { router } from "@/router"; -import { makeChatNavigation } from "./ChatNavigationState"; +import type { ShowId } from "@showtime/contracts"; +import { makeChatNavigation, type ChatOpenRequest } from "./ChatNavigationState"; export type { ChatOpenRequest } from "./ChatNavigationState"; const eventName = "showtime-chat-open"; -const activeShowId = () => { - for (const match of router.state.matches) { - const showId = (match.params as { readonly showId?: unknown }).showId; - if (typeof showId === "string") return showId; - } - return undefined; -}; +let chatNavigation: ReturnType | undefined; -const chatNavigation = makeChatNavigation({ - getActiveShowId: activeShowId, - navigateToShow: (showId) => router.navigate({ to: "/shows/$showId", params: { showId } }), - publishOpenRequest: () => window.dispatchEvent(new Event(eventName)), -}); +export const configureChatNavigation = (options: { + readonly getActiveShowId: () => string | undefined; + readonly navigateToShow: (showId: ShowId) => Promise; +}) => { + chatNavigation = makeChatNavigation({ + ...options, + publishOpenRequest: () => window.dispatchEvent(new Event(eventName)), + }); +}; -export const openChat = chatNavigation.open; +export const openChat = (request: ChatOpenRequest) => { + if (!chatNavigation) throw new Error("Chat navigation has not been configured"); + return chatNavigation.open(request); +}; -export const consumeChatOpenRequest = chatNavigation.consume; +export const consumeChatOpenRequest = (showId: ShowId) => chatNavigation?.consume(showId); export const subscribeChatOpenRequests = (listener: () => void) => { window.addEventListener(eventName, listener); diff --git a/apps/web/src/components/chats/ChatDrawer.tsx b/apps/web/src/components/chats/ChatDrawer.tsx index 1c048e1..5c1510b 100644 --- a/apps/web/src/components/chats/ChatDrawer.tsx +++ b/apps/web/src/components/chats/ChatDrawer.tsx @@ -92,7 +92,6 @@ function ChatDrawerView({ const open = controlledOpen ?? internalOpen; const setOpen = onOpenChange ?? setInternalOpen; const [selectedChannelId, setSelectedChannelId] = React.useState(); - const [pendingAnswers, setPendingAnswers] = React.useState>([]); const newestSequences = React.useRef(undefined); const selectChannel = React.useCallback( (channelId: ChatChannelId) => { @@ -113,54 +112,17 @@ function ChatDrawerView({ return () => query.removeEventListener("change", update); }, []); - React.useLayoutEffect(() => { - if (!profile || open) return; - return registerChatAnswerDialog(showId, profile.id); - }, [open, profile, showId]); + const openRequestedChat = React.useEffectEvent(() => { + const request = consumeChatOpenRequest(showId); + if (!request) return; + selectChannel(request.channelId); + setOpen(true); + }); React.useEffect(() => { - const openRequestedChat = () => { - const request = consumeChatOpenRequest(showId); - if (!request) return; - selectChannel(request.channelId); - setOpen(true); - }; openRequestedChat(); return subscribeChatOpenRequests(openRequestedChat); - }, [selectChannel, setOpen, showId]); - - React.useEffect(() => { - if (!snapshot || !profile) return; - const { requests, sequences } = planChatAnswerRequests({ - channels: snapshot.channels, - profileId: profile.id, - previousSequences: newestSequences.current, - shouldPrompt: !open, - }); - newestSequences.current = sequences; - if (requests.length > 0) - setPendingAnswers((current) => [ - ...current, - ...requests.filter((request) => !current.some((item) => item.id === request.id)), - ]); - }, [open, profile, snapshot]); - - React.useEffect(() => { - if (open) setPendingAnswers([]); - }, [open]); - - const pendingAnswer = pendingAnswers[0]; - const pendingChannel = pendingAnswer - ? snapshot?.channels.find((channel) => channel.id === pendingAnswer.channelId) - : undefined; - const pendingAnswered = Boolean( - pendingAnswer && - pendingChannel?.messages.some( - (message) => - message.replyToMessageId === pendingAnswer.id && message.senderProfileId === profile?.id, - ), - ); - const dismissPendingAnswer = () => setPendingAnswers((current) => current.slice(1)); + }, []); return ( <> @@ -189,26 +151,90 @@ function ChatDrawerView({ {profile && ( - { - if (!nextOpen) dismissPendingAnswer(); - }} + candidate.id === pendingAnswer?.senderProfileId)?.name ?? - "the sender" - } - answered={pendingAnswered} - onAnswered={dismissPendingAnswer} + open={open} + profile={profile} + profiles={profiles} + snapshot={snapshot} + newestSequences={newestSequences} /> )} ); } +function ChatAnswerPrompt({ + showId, + open, + profile, + profiles, + snapshot, + newestSequences, +}: { + readonly showId: ShowId; + readonly open: boolean; + readonly profile: Profile; + readonly profiles: ReadonlyArray; + readonly snapshot: ChatSnapshot | undefined; + readonly newestSequences: React.RefObject; +}) { + const [pendingAnswers, setPendingAnswers] = React.useState>([]); + + React.useLayoutEffect(() => { + if (open) return; + return registerChatAnswerDialog(showId, profile.id); + }, [open, profile.id, showId]); + + React.useEffect(() => { + if (!snapshot) return; + const { requests, sequences } = planChatAnswerRequests({ + channels: snapshot.channels, + profileId: profile.id, + previousSequences: newestSequences.current, + shouldPrompt: !open, + }); + newestSequences.current = sequences; + if (requests.length > 0) + setPendingAnswers((current) => [ + ...current, + ...requests.filter((request) => !current.some((item) => item.id === request.id)), + ]); + }, [newestSequences, open, profile.id, snapshot]); + + const pendingAnswer = pendingAnswers[0]; + const pendingChannel = pendingAnswer + ? snapshot?.channels.find((channel) => channel.id === pendingAnswer.channelId) + : undefined; + const pendingAnswered = Boolean( + pendingAnswer && + pendingChannel?.messages.some( + (message) => + message.replyToMessageId === pendingAnswer.id && message.senderProfileId === profile.id, + ), + ); + const dismissPendingAnswer = () => setPendingAnswers((current) => current.slice(1)); + + return ( + { + if (!nextOpen) dismissPendingAnswer(); + }} + showId={showId} + profileId={profile.id} + request={pendingAnswer} + senderName={ + profiles.find((candidate) => candidate.id === pendingAnswer?.senderProfileId)?.name ?? + "the sender" + } + answered={pendingAnswered} + onAnswered={dismissPendingAnswer} + /> + ); +} + export function ChatUnreadBadge({ showId }: { readonly showId: ShowId }) { const profilesResult = useAtomValue(profileAtoms.state); const profileState = AsyncResult.isSuccess(profilesResult) ? profilesResult.value : undefined; diff --git a/apps/web/src/components/chats/ChatMessageBody.tsx b/apps/web/src/components/chats/ChatMessageBody.tsx index 8005236..3bd7f60 100644 --- a/apps/web/src/components/chats/ChatMessageBody.tsx +++ b/apps/web/src/components/chats/ChatMessageBody.tsx @@ -11,14 +11,17 @@ export function ChatMessageBody({ readonly parts?: ReadonlyArray; }) { if (!parts?.length) return body; - return parts.map((part, index) => { - if (part.type === "text") return {part.text}; + let characterOffset = 0; + return parts.map((part) => { + const key = `${part.type}:${characterOffset}:${part.text}`; + characterOffset += part.text.length; + if (part.type === "text") return {part.text}; const colors = microphoneColorClassNames[part.color]; const label = part.type === "microphone" ? "Microphone" : "Mix"; const Icon = part.type === "microphone" ? Mic2Icon : SpeakerIcon; return ( ({ type: "list" }); - React.useEffect(() => { - if (!open) setMode({ type: "list" }); - }, [open]); - const back = () => setMode({ type: "list" }); + const changeOpen = (nextOpen: boolean) => { + if (!nextOpen) back(); + onOpenChange(nextOpen); + }; const title = mode.type === "list" ? "Message presets" @@ -124,7 +126,7 @@ export function ChatPresetDialog({ : "New preset"; return ( - +
@@ -221,7 +223,7 @@ function PresetList({
) : ( presets.map((preset) => ( - + }> ))} - @@ -546,9 +570,10 @@ function PresetEditor({ const fields = chatPresetFieldsFromDrafts(drafts); const answerFields = chatPresetFieldsFromDrafts(answerDrafts); const messageFieldNames = chatPresetPlaceholderNames(template); + const messageFieldNameSet = new Set(messageFieldNames); const answerFieldNames = new Set(answerFields.map((field) => field.name)); const inheritedAnswerNames = chatPresetPlaceholderNames(answerTemplate).filter( - (name) => messageFieldNames.includes(name) && !answerFieldNames.has(name), + (name) => messageFieldNameSet.has(name) && !answerFieldNames.has(name), ); const definitionError = template.trim() ? validateChatPresetDefinition({ template: template.trim(), fields }) @@ -572,29 +597,32 @@ function PresetEditor({ if (!canSave || saving) return; setSaving(true); setError(undefined); - const common = { - showId, - name: name.trim() as ChatPresetName, - template: template.trim() as ChatPresetTemplate, - fields, - ...(answerEnabled - ? { - answer: { - template: answerTemplate.trim() as ChatPresetTemplate, - fields: answerFields, - } satisfies ChatPresetAnswer, - } - : {}), - }; - const exit = preset - ? await updatePreset({ - payload: { ...common, presetId: preset.id }, - reactivityKeys: chatsSyncKey(showId), - }) - : await createPreset({ payload: common, reactivityKeys: chatsSyncKey(showId) }); - if (Exit.isFailure(exit)) setError(rpcErrorMessageFromCause(exit.cause)); - else onSaved(exit.value); - setSaving(false); + try { + const common = { + showId, + name: name.trim() as ChatPresetName, + template: template.trim() as ChatPresetTemplate, + fields, + ...(answerEnabled + ? { + answer: { + template: answerTemplate.trim() as ChatPresetTemplate, + fields: answerFields, + } satisfies ChatPresetAnswer, + } + : {}), + }; + const exit = preset + ? await updatePreset({ + payload: { ...common, presetId: preset.id }, + reactivityKeys: chatsSyncKey(showId), + }) + : await createPreset({ payload: common, reactivityKeys: chatsSyncKey(showId) }); + if (Exit.isFailure(exit)) setError(rpcErrorMessageFromCause(exit.cause)); + else onSaved(exit.value); + } finally { + setSaving(false); + } }; return ( @@ -685,13 +713,16 @@ function DeletePreset({ const confirm = async () => { setDeleting(true); setError(undefined); - const exit = await remove({ - payload: { showId, presetId: preset.id }, - reactivityKeys: chatsSyncKey(showId), - }); - if (Exit.isFailure(exit)) setError(rpcErrorMessageFromCause(exit.cause)); - else onDeleted(); - setDeleting(false); + try { + const exit = await remove({ + payload: { showId, presetId: preset.id }, + reactivityKeys: chatsSyncKey(showId), + }); + if (Exit.isFailure(exit)) setError(rpcErrorMessageFromCause(exit.cause)); + else onDeleted(); + } finally { + setDeleting(false); + } }; return ( <> diff --git a/apps/web/src/components/chats/ChatPresetDrafts.ts b/apps/web/src/components/chats/ChatPresetDrafts.ts index 84d4949..6386cf7 100644 --- a/apps/web/src/components/chats/ChatPresetDrafts.ts +++ b/apps/web/src/components/chats/ChatPresetDrafts.ts @@ -21,9 +21,11 @@ export const chatPresetDraftsForTemplate = ( inheritedNames: ReadonlySet = new Set(), ): Array => { const previousByName = new Map(previous.map((field) => [field.name, field])); - return chatPresetPlaceholderNames(template) - .filter((name) => !inheritedNames.has(name) || previousByName.has(name)) - .map((name) => previousByName.get(name) ?? { name, type: "text", options: [] }); + return chatPresetPlaceholderNames(template).flatMap((name) => + !inheritedNames.has(name) || previousByName.has(name) + ? [previousByName.get(name) ?? { name, type: "text", options: [] }] + : [], + ); }; export const chatPresetFieldsFromDrafts = ( @@ -34,7 +36,10 @@ export const chatPresetFieldsFromDrafts = ( ? { name: field.name, type: "select", - options: field.options.map((option) => option.trim()).filter(Boolean), + options: field.options.flatMap((option) => { + const trimmed = option.trim(); + return trimmed ? [trimmed] : []; + }), } : { name: field.name, type: field.type }, ); diff --git a/apps/web/src/components/chats/ChatPresetFieldState.ts b/apps/web/src/components/chats/ChatPresetFieldState.ts new file mode 100644 index 0000000..e0c0ea5 --- /dev/null +++ b/apps/web/src/components/chats/ChatPresetFieldState.ts @@ -0,0 +1,78 @@ +import { + resolveChatPresetTemplate, + type ChatMessagePart, + type ChatPresetAnswer, + type ChatPresetField, + type Microphone, + type Mix, +} from "@showtime/contracts"; + +type TemplateDefinition = Pick; + +export const chatPresetOptionsUseButtons = (options: ReadonlyArray) => options.length < 5; + +export const readableChatPresetFieldName = (name: string) => + name.replace(/[-_]+/g, " ").replace(/^./, (letter: string) => letter.toUpperCase()); + +export function initialChatPresetValues( + fields: ReadonlyArray, + microphones: ReadonlyArray, + mixes: ReadonlyArray, +) { + return Object.fromEntries( + fields.map((field) => [ + field.name, + field.type === "microphone" + ? (microphones[0]?.id ?? "") + : field.type === "mix" + ? (mixes[0]?.id ?? "") + : field.type === "select" + ? (field.options[0] ?? "") + : "", + ]), + ); +} + +export function resolveChatPresetDefinition( + definition: TemplateDefinition, + values: Readonly>, + microphones: ReadonlyArray, + mixes: ReadonlyArray, +) { + const parts = new Map( + definition.context?.map((item) => [item.name, item.part]), + ); + const microphonesById = new Map(microphones.map((microphone) => [microphone.id, microphone])); + const mixesById = new Map(mixes.map((mix) => [mix.id, mix])); + for (const field of definition.fields) { + const value = values[field.name]?.trim() ?? ""; + if (!value) return undefined; + if (field.type === "microphone") { + const microphone = microphonesById.get(value as Microphone["id"]); + if (!microphone) return undefined; + parts.set(field.name, { + type: "microphone", + id: microphone.id, + number: microphone.number, + color: microphone.color, + ...(microphone.name ? { name: microphone.name } : {}), + text: `Mic ${microphone.number}${microphone.name ? ` (${microphone.name})` : ""}`, + }); + } else if (field.type === "mix") { + const mix = mixesById.get(value as Mix["id"]); + if (!mix) return undefined; + parts.set(field.name, { + type: "mix", + id: mix.id, + number: mix.number, + color: mix.color, + ...(mix.name ? { name: mix.name } : {}), + text: `Mix ${mix.number}${mix.name ? ` (${mix.name})` : ""}`, + }); + } else { + parts.set(field.name, { type: "text", text: value }); + } + } + const resolved = resolveChatPresetTemplate(definition.template, parts); + return resolved ? { ...resolved, values: parts } : undefined; +} diff --git a/apps/web/src/components/chats/ChatPresetFields.test.ts b/apps/web/src/components/chats/ChatPresetFields.test.ts index 59826df..8f1b35b 100644 --- a/apps/web/src/components/chats/ChatPresetFields.test.ts +++ b/apps/web/src/components/chats/ChatPresetFields.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { chatPresetOptionsUseButtons } from "./ChatPresetFields"; +import { chatPresetOptionsUseButtons } from "./ChatPresetFieldState"; describe("chat preset option inputs", () => { it("uses buttons for fewer than five options and a select from five onward", () => { diff --git a/apps/web/src/components/chats/ChatPresetFields.tsx b/apps/web/src/components/chats/ChatPresetFields.tsx index 472ec06..b26f5a3 100644 --- a/apps/web/src/components/chats/ChatPresetFields.tsx +++ b/apps/web/src/components/chats/ChatPresetFields.tsx @@ -2,9 +2,6 @@ import * as React from "react"; import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { - resolveChatPresetTemplate, - type ChatMessagePart, - type ChatPresetAnswer, type ChatPresetField, type Microphone, type Mix, @@ -12,6 +9,10 @@ import { } from "@showtime/contracts"; import { microphoneAtoms, mixAtoms } from "@/client"; import { colorPreviewClassNames } from "@/components/color"; +import { + chatPresetOptionsUseButtons, + readableChatPresetFieldName, +} from "@/components/chats/ChatPresetFieldState"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -22,13 +23,6 @@ import { SelectValue, } from "@/components/ui/select"; -type TemplateDefinition = Pick; - -export const chatPresetOptionsUseButtons = (options: ReadonlyArray) => options.length < 5; - -export const readableChatPresetFieldName = (name: string) => - name.replace(/[-_]+/g, " ").replace(/^./, (letter: string) => letter.toUpperCase()); - function ChannelIdentity({ channel, }: { @@ -76,67 +70,6 @@ export function useChatPresetResources(showId: ShowId) { return { microphones, mixes } as const; } -export function initialChatPresetValues( - fields: ReadonlyArray, - microphones: ReadonlyArray, - mixes: ReadonlyArray, -) { - return Object.fromEntries( - fields.map((field) => [ - field.name, - field.type === "microphone" - ? (microphones[0]?.id ?? "") - : field.type === "mix" - ? (mixes[0]?.id ?? "") - : field.type === "select" - ? (field.options[0] ?? "") - : "", - ]), - ); -} - -export function resolveChatPresetDefinition( - definition: TemplateDefinition, - values: Readonly>, - microphones: ReadonlyArray, - mixes: ReadonlyArray, -) { - const parts = new Map( - definition.context?.map((item) => [item.name, item.part]), - ); - for (const field of definition.fields) { - const value = values[field.name]?.trim() ?? ""; - if (!value) return undefined; - if (field.type === "microphone") { - const microphone = microphones.find((item) => item.id === value); - if (!microphone) return undefined; - parts.set(field.name, { - type: "microphone", - id: microphone.id, - number: microphone.number, - color: microphone.color, - ...(microphone.name ? { name: microphone.name } : {}), - text: `Mic ${microphone.number}${microphone.name ? ` (${microphone.name})` : ""}`, - }); - } else if (field.type === "mix") { - const mix = mixes.find((item) => item.id === value); - if (!mix) return undefined; - parts.set(field.name, { - type: "mix", - id: mix.id, - number: mix.number, - color: mix.color, - ...(mix.name ? { name: mix.name } : {}), - text: `Mix ${mix.number}${mix.name ? ` (${mix.name})` : ""}`, - }); - } else { - parts.set(field.name, { type: "text", text: value }); - } - } - const resolved = resolveChatPresetTemplate(definition.template, parts); - return resolved ? { ...resolved, values: parts } : undefined; -} - export function ChatPresetFieldInputs({ fields, values, @@ -150,6 +83,8 @@ export function ChatPresetFieldInputs({ readonly mixes: ReadonlyArray; readonly onValueChange: (name: string, value: string) => void; }) { + const microphonesById = new Map(microphones.map((microphone) => [microphone.id, microphone])); + const mixesById = new Map(mixes.map((mix) => [mix.id, mix])); return (
{fields.map((field, index) => { @@ -185,7 +120,7 @@ export function ChatPresetFieldInputs({ item.id === values[field.name])} + channel={microphonesById.get(values[field.name] as Microphone["id"])} placeholder="Choose microphone" /> @@ -207,7 +142,7 @@ export function ChatPresetFieldInputs({ item.id === values[field.name])} + channel={mixesById.get(values[field.name] as Mix["id"])} placeholder="Choose mix" /> diff --git a/apps/web/src/components/chats/ChatWorkspace.tsx b/apps/web/src/components/chats/ChatWorkspace.tsx index 6a62eab..b9df148 100644 --- a/apps/web/src/components/chats/ChatWorkspace.tsx +++ b/apps/web/src/components/chats/ChatWorkspace.tsx @@ -130,33 +130,19 @@ function ProfileChatWorkspace({ }) { const atoms = chatAtoms(showId, profile.id); const result = useAtomValue(atoms.state); - const [selectedChannelId, setSelectedChannelId] = React.useState( - requestedChannelId, - ); + const [internalSelectedChannelId, setInternalSelectedChannelId] = React.useState(); const snapshot = AsyncResult.isSuccess(result) ? result.value : undefined; + const selectedChannelId = requestedChannelId ?? internalSelectedChannelId; const selectedChannel = snapshot?.channels.find((channel) => channel.id === selectedChannelId) ?? snapshot?.channels[0]; const selectChannel = React.useCallback( (channelId: ChatChannelId) => { - setSelectedChannelId(channelId); + setInternalSelectedChannelId(channelId); onSelectedChannelChange?.(channelId); }, [onSelectedChannelChange], ); - React.useEffect(() => { - if (selectedChannel && selectedChannel.id !== selectedChannelId) - selectChannel(selectedChannel.id); - }, [selectChannel, selectedChannel, selectedChannelId]); - - React.useEffect(() => { - if ( - requestedChannelId && - snapshot?.channels.some((channel) => channel.id === requestedChannelId) - ) - selectChannel(requestedChannelId); - }, [requestedChannelId, selectChannel, snapshot?.channels]); - if (!snapshot || !selectedChannel) { return (
@@ -284,13 +270,16 @@ function ChannelTabs({ if (!target || deleting || channels.length === 1) return; setDeleting(true); setError(undefined); - const exit = await deleteChannel({ - payload: { showId, channelId: target.id }, - ...mutationOptions, - }); - if (Exit.isFailure(exit)) setError(rpcErrorMessageFromCause(exit.cause)); - else setDeleteTarget(undefined); - setDeleting(false); + try { + const exit = await deleteChannel({ + payload: { showId, channelId: target.id }, + ...mutationOptions, + }); + if (Exit.isFailure(exit)) setError(rpcErrorMessageFromCause(exit.cause)); + else setDeleteTarget(undefined); + } finally { + setDeleting(false); + } }; return ( diff --git a/apps/web/src/components/connections/ConnectToShowtime.tsx b/apps/web/src/components/connections/ConnectToShowtime.tsx index 0c73347..bd6320d 100644 --- a/apps/web/src/components/connections/ConnectToShowtime.tsx +++ b/apps/web/src/components/connections/ConnectToShowtime.tsx @@ -37,7 +37,11 @@ export function ConnectToShowtime({ error }: { readonly error?: string }) {

- + ); } diff --git a/apps/web/src/components/connections/ConnectionDialog.tsx b/apps/web/src/components/connections/ConnectionDialog.tsx index 3d984c0..a2b5b21 100644 --- a/apps/web/src/components/connections/ConnectionDialog.tsx +++ b/apps/web/src/components/connections/ConnectionDialog.tsx @@ -59,7 +59,8 @@ import { cn } from "@/lib/utils"; import { copyText } from "@/clipboard"; import { profileAtoms } from "@/client"; import { useProfileSelection } from "@/profiles"; -import { currentProfilesState, ProfileControl } from "@/components/profiles/ProfileSwitcher"; +import { ProfileControl } from "@/components/profiles/ProfileSwitcher"; +import { currentProfilesState } from "@/profiles/currentProfilesState"; import { showColorClassNames } from "@/components/shows/show-color"; const emptyState: ShowtimeConnectionsState = { @@ -69,6 +70,67 @@ const emptyState: ShowtimeConnectionsState = { clients: [], }; +type PairClientState = { + readonly candidates: ReadonlyArray; + readonly selectedUrl: string; + readonly discovery: ShowtimeLocalDiscoveryState; + readonly qrCode?: string; + readonly copied: boolean; + readonly error?: string; +}; + +const initialPairClientState: PairClientState = { + candidates: [], + selectedUrl: "", + discovery: { kind: "disabled" }, + copied: false, +}; + +type PairClientAction = + | { readonly type: "reset" } + | { + readonly type: "loaded"; + readonly discovery: ShowtimeLocalDiscoveryState; + readonly candidates: ReadonlyArray; + readonly error?: string; + } + | { readonly type: "select"; readonly url: string } + | { readonly type: "prepare-qr-code" } + | { readonly type: "qr-code"; readonly value?: string } + | { readonly type: "copied" } + | { readonly type: "error"; readonly message: string }; + +const reducePairClientState = ( + state: PairClientState, + action: PairClientAction, +): PairClientState => { + switch (action.type) { + case "reset": + return { ...initialPairClientState, discovery: { kind: "probing" } }; + case "loaded": { + const selectedUrl = selectPairingCandidateUrl(action.candidates, state.selectedUrl); + return { + ...state, + discovery: action.discovery, + candidates: action.candidates, + selectedUrl, + ...(selectedUrl !== state.selectedUrl ? { qrCode: undefined, copied: false } : {}), + error: action.error, + }; + } + case "select": + return { ...state, selectedUrl: action.url, qrCode: undefined, copied: false }; + case "prepare-qr-code": + return { ...state, qrCode: undefined, copied: false, error: undefined }; + case "qr-code": + return { ...state, qrCode: action.value }; + case "copied": + return { ...state, copied: true, error: undefined }; + case "error": + return { ...state, error: action.message }; + } +}; + const timeUntil = (expiresAt: string, now: number) => { const remaining = Math.max(0, Date.parse(expiresAt) - now); if (remaining === 0) return "Link expired"; @@ -185,7 +247,7 @@ export function ConnectionDialog({ {manager.isOwner && ( <> - }> + }> Allow connections @@ -195,6 +257,7 @@ export function ConnectionDialog({ (); - React.useEffect(() => { - if (!open) return; - setDraft(currentState.hostName); - setConfirming(false); - setSaving(false); - setError(undefined); - }, [open, currentState.hostName]); - const candidate = draft.trim() ? normalizeShowtimeHostName(draft) : undefined; const hostname = candidate ? `showtime-${candidate}.local` : undefined; const changed = candidate !== undefined && candidate !== currentState.hostName; @@ -532,7 +588,7 @@ function CreateClientDialog({ loadResult={profilesResult} fullWidth /> - }> + }> Allow this client to manage connections Let it view, add, connect, and remove other clients. @@ -540,6 +596,7 @@ function CreateClientDialog({ void; }) { - const [candidates, setCandidates] = React.useState>( - [], - ); - const [selectedUrl, setSelectedUrl] = React.useState(""); - const [discovery, setDiscovery] = React.useState({ - kind: "disabled", - }); - const [qrCode, setQrCode] = React.useState(); - const [copied, setCopied] = React.useState(false); - const [error, setError] = React.useState(); + const [{ candidates, selectedUrl, discovery, qrCode, copied, error }, dispatch] = + React.useReducer(reducePairClientState, initialPairClientState); React.useEffect(() => { if (!client) return; let active = true; - setCandidates([]); - setSelectedUrl(""); - setDiscovery({ kind: "probing" }); - setQrCode(undefined); - setCopied(false); - setError(undefined); + dispatch({ type: "reset" }); let timer: number | undefined; let consecutiveFailures = 0; let expiresAt = Date.parse(client.expiresAt); let hasRequestedPairingInfo = false; - const setExpiredError = () => setError("This connection link has expired."); + const setExpiredError = () => + dispatch({ type: "error", message: "This connection link has expired." }); const hasExpired = () => pairingInfoRetryWait(expiresAt, 0) === undefined; const scheduleLoad = (delay: number) => { const wait = pairingInfoRetryWait(expiresAt, delay); @@ -621,16 +666,14 @@ function PairClientDialog({ return; } consecutiveFailures = 0; - setDiscovery(info.discovery); - setCandidates(info.candidates); - setSelectedUrl((currentUrl) => selectPairingCandidateUrl(info.candidates, currentUrl)); - if (info.discovery.kind === "probing") { - setError(undefined); - } else if (info.candidates.length === 0) { - setError("No local network was found on this computer."); - } else { - setError(undefined); - } + dispatch({ + type: "loaded", + discovery: info.discovery, + candidates: info.candidates, + ...(info.discovery.kind !== "probing" && info.candidates.length === 0 + ? { error: "No local network was found on this computer." } + : {}), + }); if (shouldPollPairingInfo(info.discovery)) scheduleLoad(pairingInfoPollDelay); }, () => { @@ -640,7 +683,10 @@ function PairClientDialog({ return; } consecutiveFailures += 1; - setError("Showtime could not refresh the connection link. Retrying…"); + dispatch({ + type: "error", + message: "Showtime could not refresh the connection link. Retrying…", + }); scheduleLoad(pairingInfoRetryDelay(consecutiveFailures)); }, ); @@ -652,13 +698,11 @@ function PairClientDialog({ }; }, [client, manager]); React.useEffect(() => { - setQrCode(undefined); - setCopied(false); - setError(undefined); + dispatch({ type: "prepare-qr-code" }); if (!selectedUrl) return; let active = true; void QRCode.toDataURL(selectedUrl, { errorCorrectionLevel: "M", margin: 2, width: 320 }).then( - (value) => active && setQrCode(value), + (value) => active && dispatch({ type: "qr-code", value }), ); return () => { active = false; @@ -675,7 +719,10 @@ function PairClientDialog({ {candidates.length > 0 && ( - value && dispatch({ type: "select", url: value })} + > {selected?.label ?? "Choose network"} @@ -727,12 +774,13 @@ function PairClientDialog({ onClick={async () => { try { await copyText(selectedUrl); - setCopied(true); - setError(undefined); + dispatch({ type: "copied" }); } catch { - setError( - "Could not copy automatically. Press and hold the connection link above to copy it.", - ); + dispatch({ + type: "error", + message: + "Could not copy automatically. Press and hold the connection link above to copy it.", + }); } }} > diff --git a/apps/web/src/components/connections/ConnectionLinkDialog.tsx b/apps/web/src/components/connections/ConnectionLinkDialog.tsx index be5bb7d..f538a2e 100644 --- a/apps/web/src/components/connections/ConnectionLinkDialog.tsx +++ b/apps/web/src/components/connections/ConnectionLinkDialog.tsx @@ -26,16 +26,6 @@ export function ConnectionLinkDialog({ const [message, setMessage] = React.useState(); const [connectionUrl, setConnectionUrl] = React.useState(""); - React.useEffect(() => { - if (!open) { - setConnectionUrl(""); - return; - } - handledRef.current = false; - setState("idle"); - setMessage(undefined); - }, [open]); - const connect = React.useCallback(async (value: string) => { const standalone = isStandalonePwa(); const pairingUrl = showtimePairingNavigationUrl(value, window.location.href, standalone); diff --git a/apps/web/src/components/live/LiveSongNavigation.tsx b/apps/web/src/components/live/LiveSongNavigation.tsx index 0efb7d2..46b2da4 100644 --- a/apps/web/src/components/live/LiveSongNavigation.tsx +++ b/apps/web/src/components/live/LiveSongNavigation.tsx @@ -14,6 +14,9 @@ export function LiveSongNavigation({ readonly onPrevious: () => void; readonly onNext: () => void; }) { + const handlePrevious = React.useEffectEvent(onPrevious); + const handleNext = React.useEffectEvent(onNext); + React.useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if ( @@ -38,15 +41,15 @@ export function LiveSongNavigation({ const direction = liveSongNavigationDirection(event.key); if (direction === "previous" && previous) { event.preventDefault(); - onPrevious(); + handlePrevious(); } else if (direction === "next" && next) { event.preventDefault(); - onNext(); + handleNext(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [next, onNext, onPrevious, previous]); + }, [next, previous]); return (