From cdad437ceae9fa0250c8ce889d9c7aa00cfaf518 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Wed, 30 Sep 2026 18:19:57 -0500 Subject: [PATCH] use typed errors for teleport dialog --- js/core/src/slash-commands.ts | 5 + js/core/src/slash-commands/teleport.test.ts | 50 ++++++++++ js/core/src/slash-commands/teleport.ts | 92 +++++++++++++------ js/i18n/locales/en-US/common.ftl | 1 - js/i18n/locales/es-ES/common.ftl | 1 - js/i18n/locales/fr-FR/common.ftl | 1 - js/i18n/locales/pt-BR/common.ftl | 1 - js/i18n/locales/ro-RO/common.ftl | 1 - js/i18n/locales/zh-Hans/common.ftl | 1 - js/i18n/locales/zh-Hant/common.ftl | 1 - js/web/src/components/stream/chat-input.tsx | 22 ++++- .../stream/teleport-dialog.test.tsx | 25 ++++- .../src/components/stream/teleport-dialog.tsx | 68 ++++++-------- 13 files changed, 191 insertions(+), 78 deletions(-) create mode 100644 js/core/src/slash-commands/teleport.test.ts diff --git a/js/core/src/slash-commands.ts b/js/core/src/slash-commands.ts index 179e74c50..4342ecb8e 100644 --- a/js/core/src/slash-commands.ts +++ b/js/core/src/slash-commands.ts @@ -1,6 +1,11 @@ export interface SlashCommandResult { handled: boolean; error?: string; + errorData?: { + type: string; + code: string; + params?: Record; + }; } export type SlashCommandHandler = ( diff --git a/js/core/src/slash-commands/teleport.test.ts b/js/core/src/slash-commands/teleport.test.ts new file mode 100644 index 000000000..35d65f761 --- /dev/null +++ b/js/core/src/slash-commands/teleport.test.ts @@ -0,0 +1,50 @@ +import type { StreamplaceAgent } from "streamplace"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { handleSlashCommand } from "../slash-commands"; +import { registerTeleportCommand } from "./teleport"; + +describe("teleport slash command errors", () => { + let unregister: (() => void) | undefined; + + afterEach(() => { + unregister?.(); + unregister = undefined; + }); + + function register(agent: Partial) { + unregister = registerTeleportCommand( + agent as StreamplaceAgent, + "did:plc:streamer", + () => ({ + uri: "at://did:plc:streamer/place.stream.livestream/stream", + cid: "stream-cid", + streamerDid: "did:plc:streamer", + }), + ); + } + + it("returns a stable code for invalid handles", async () => { + register({}); + + const result = await handleSlashCommand("/teleport invalid"); + + expect(result.errorData).toEqual({ + type: "teleport", + code: "handle-format", + }); + }); + + it("returns a stable code and handle when resolution fails", async () => { + register({ + resolveHandle: vi.fn().mockRejectedValue(new Error("network error")), + }); + + const result = await handleSlashCommand("/teleport @missing.example"); + + expect(result.errorData).toEqual({ + type: "teleport", + code: "resolve-handle", + params: { handle: "missing.example" }, + }); + }); +}); diff --git a/js/core/src/slash-commands/teleport.ts b/js/core/src/slash-commands/teleport.ts index 0349686f4..e3580b8c6 100644 --- a/js/core/src/slash-commands/teleport.ts +++ b/js/core/src/slash-commands/teleport.ts @@ -5,6 +5,43 @@ import { SlashCommandResult, } from "../slash-commands"; +const TELEPORT_ERROR_CODES = [ + "streamer-only", + "handle-format", + "countdown-number", + "countdown-range", + "self", + "resolve-handle", + "create", +] as const; + +export type TeleportErrorCode = (typeof TELEPORT_ERROR_CODES)[number]; + +export type TeleportErrorData = { + code: TeleportErrorCode; + params?: { handle?: string }; +}; + +export function isTeleportErrorCode(code: string): code is TeleportErrorCode { + return TELEPORT_ERROR_CODES.some((knownCode) => knownCode === code); +} + +function teleportError( + message: string, + code: TeleportErrorCode, + params?: TeleportErrorData["params"], +): SlashCommandResult { + return { + handled: true, + error: message, + errorData: { + type: "teleport", + code, + ...(params ? { params } : {}), + }, + }; +} + export async function deleteTeleport( pdsAgent: StreamplaceAgent, userDID: string, @@ -104,11 +141,10 @@ export function registerTeleportCommand( ): Promise => { const livestream = getLivestream?.() ?? null; if (!livestream || livestream.streamerDid !== userDID) { - return { - handled: true, - error: - "Only the streamer of the current livestream can start a teleport", - }; + return teleportError( + "Only the streamer of the current livestream can start a teleport", + "streamer-only", + ); } if (args.length === 0) { @@ -129,26 +165,26 @@ export function registerTeleportCommand( } if (!targetHandle.includes(".")) { - return { - handled: true, - error: "Invalid handle format. Expected: handle.bsky.social", - }; + return teleportError( + "Invalid handle format. Expected: handle.bsky.social", + "handle-format", + ); } let countdownSeconds = 10; if (args.length > 1) { const parsedDuration = parseInt(args[1], 10); if (isNaN(parsedDuration)) { - return { - handled: true, - error: "Countdown must be a number (seconds)", - }; + return teleportError( + "Countdown must be a number (seconds)", + "countdown-number", + ); } if (parsedDuration < 5 || parsedDuration > 300) { - return { - handled: true, - error: "Countdown must be between 5 seconds and 5 minutes", - }; + return teleportError( + "Countdown must be between 5 seconds and 5 minutes", + "countdown-range", + ); } countdownSeconds = parsedDuration; } @@ -164,17 +200,15 @@ export function registerTeleportCommand( }); targetDID = resolution.data.did; } catch (err) { - return { - handled: true, - error: `Could not resolve handle: ${targetHandle}`, - }; + return teleportError( + `Could not resolve handle: ${targetHandle}`, + "resolve-handle", + { handle: targetHandle }, + ); } if (targetDID === userDID) { - return { - handled: true, - error: "You cannot teleport to yourself", - }; + return teleportError("You cannot teleport to yourself", "self"); } const startsAt = new Date( @@ -200,10 +234,10 @@ export function registerTeleportCommand( return { handled: true }; } catch (err) { - return { - handled: true, - error: err instanceof Error ? err.message : "Failed to create teleport", - }; + return teleportError( + err instanceof Error ? err.message : "Failed to create teleport", + "create", + ); } }; diff --git a/js/i18n/locales/en-US/common.ftl b/js/i18n/locales/en-US/common.ftl index 970778937..f09e5ed20 100644 --- a/js/i18n/locales/en-US/common.ftl +++ b/js/i18n/locales/en-US/common.ftl @@ -308,7 +308,6 @@ teleport-error-countdown-number = Countdown must be a number of seconds. teleport-error-self = You cannot teleport to yourself. teleport-error-resolve-handle = Could not find @{ $handle }. teleport-error-create = Could not start the teleport. -teleport-error-generic = Could not start the teleport: { $message } ## Video Card views-count = { $count -> diff --git a/js/i18n/locales/es-ES/common.ftl b/js/i18n/locales/es-ES/common.ftl index 801a0d0a9..43c787db1 100644 --- a/js/i18n/locales/es-ES/common.ftl +++ b/js/i18n/locales/es-ES/common.ftl @@ -106,4 +106,3 @@ teleport-error-countdown-number = La cuenta atrás debe ser un número de segund teleport-error-self = No puedes teletransportarte a ti mismo. teleport-error-resolve-handle = No se encontró a @{ $handle }. teleport-error-create = No se pudo iniciar la teletransportación. -teleport-error-generic = No se pudo iniciar la teletransportación: { $message } diff --git a/js/i18n/locales/fr-FR/common.ftl b/js/i18n/locales/fr-FR/common.ftl index 913c570a2..a34859ffb 100644 --- a/js/i18n/locales/fr-FR/common.ftl +++ b/js/i18n/locales/fr-FR/common.ftl @@ -106,4 +106,3 @@ teleport-error-countdown-number = Le compte à rebours doit être un nombre de s teleport-error-self = Vous ne pouvez pas vous téléporter vers vous-même. teleport-error-resolve-handle = @{ $handle } est introuvable. teleport-error-create = Impossible de démarrer la téléportation. -teleport-error-generic = Impossible de démarrer la téléportation : { $message } diff --git a/js/i18n/locales/pt-BR/common.ftl b/js/i18n/locales/pt-BR/common.ftl index 0417e8f12..c35cf0f9c 100644 --- a/js/i18n/locales/pt-BR/common.ftl +++ b/js/i18n/locales/pt-BR/common.ftl @@ -106,4 +106,3 @@ teleport-error-countdown-number = A contagem deve ser um número de segundos. teleport-error-self = Você não pode se teletransportar para si mesmo. teleport-error-resolve-handle = Não foi possível encontrar @{ $handle }. teleport-error-create = Não foi possível iniciar o teletransporte. -teleport-error-generic = Não foi possível iniciar o teletransporte: { $message } diff --git a/js/i18n/locales/ro-RO/common.ftl b/js/i18n/locales/ro-RO/common.ftl index 843082df4..8a4c2e922 100644 --- a/js/i18n/locales/ro-RO/common.ftl +++ b/js/i18n/locales/ro-RO/common.ftl @@ -106,4 +106,3 @@ teleport-error-countdown-number = Numărătoarea trebuie să fie un număr de se teleport-error-self = Nu te poți teleporta la tine însuți. teleport-error-resolve-handle = Nu s-a putut găsi @{ $handle }. teleport-error-create = Nu s-a putut porni teleportarea. -teleport-error-generic = Nu s-a putut porni teleportarea: { $message } diff --git a/js/i18n/locales/zh-Hans/common.ftl b/js/i18n/locales/zh-Hans/common.ftl index f01b99c8c..24fcecfd7 100644 --- a/js/i18n/locales/zh-Hans/common.ftl +++ b/js/i18n/locales/zh-Hans/common.ftl @@ -98,4 +98,3 @@ teleport-error-countdown-number = 倒计时必须是秒数。 teleport-error-self = 无法传送到自己的直播间。 teleport-error-resolve-handle = 找不到 @{ $handle }。 teleport-error-create = 无法启动传送。 -teleport-error-generic = 无法启动传送:{ $message } diff --git a/js/i18n/locales/zh-Hant/common.ftl b/js/i18n/locales/zh-Hant/common.ftl index eef4794e5..d51d1dedb 100644 --- a/js/i18n/locales/zh-Hant/common.ftl +++ b/js/i18n/locales/zh-Hant/common.ftl @@ -103,4 +103,3 @@ teleport-error-countdown-number = 倒數時間必須是秒數。 teleport-error-self = 無法傳送到自己的直播間。 teleport-error-resolve-handle = 找不到 @{ $handle }。 teleport-error-create = 無法啟動傳送。 -teleport-error-generic = 無法啟動傳送:{ $message } diff --git a/js/web/src/components/stream/chat-input.tsx b/js/web/src/components/stream/chat-input.tsx index b9230e941..4e8f4cf29 100644 --- a/js/web/src/components/stream/chat-input.tsx +++ b/js/web/src/components/stream/chat-input.tsx @@ -1,7 +1,9 @@ import { handleSlashCommand, + isTeleportErrorCode, registerTeleportCommand, type LivestreamStore, + type TeleportErrorData, } from "@streamplace/core"; import { Extension } from "@tiptap/core"; import MentionBase from "@tiptap/extension-mention"; @@ -512,13 +514,29 @@ export function ChatInput({ }, [editor, sending, send, store]); const submitTeleport = useCallback( - async (handle: string, countdownSeconds: number) => { + async ( + handle: string, + countdownSeconds: number, + ): Promise => { const result = await handleSlashCommand( `/teleport @${handle} ${countdownSeconds}`, ); if (!result.handled || result.error) { - throw new Error(result.error || "Could not start teleport."); + const errorData = result.errorData; + if ( + errorData?.type === "teleport" && + isTeleportErrorCode(errorData.code) + ) { + return { + code: errorData.code, + ...(errorData.params?.handle + ? { params: { handle: errorData.params.handle } } + : {}), + }; + } + return { code: "create" }; } + return undefined; }, [], ); diff --git a/js/web/src/components/stream/teleport-dialog.test.tsx b/js/web/src/components/stream/teleport-dialog.test.tsx index c17744bcb..842bbdd82 100644 --- a/js/web/src/components/stream/teleport-dialog.test.tsx +++ b/js/web/src/components/stream/teleport-dialog.test.tsx @@ -1,4 +1,5 @@ import type { LivestreamView } from "@/hooks/use-live-users"; +import type { TeleportErrorData } from "@streamplace/core"; import { act, type ButtonHTMLAttributes, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -49,9 +50,12 @@ describe("TeleportDialog", () => { let container: HTMLDivElement; let root: Root; - function render() { + function render( + onSubmit: () => Promise = async () => + undefined, + ) { root.render( - {}} onSubmit={async () => {}} />, + {}} onSubmit={onSubmit} />, ); } @@ -94,4 +98,21 @@ describe("TeleportDialog", () => { ).find((button) => button.textContent === "teleport-start"); expect(currentTeleportButton?.disabled).toBe(true); }); + + it("translates typed teleport errors", async () => { + await act(async () => render(async () => ({ code: "self" }))); + const streamerButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("@streamer.example")); + await act(async () => streamerButton?.click()); + const teleportButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent === "teleport-start"); + + await act(async () => teleportButton?.click()); + + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + "teleport-error-self", + ); + }); }); diff --git a/js/web/src/components/stream/teleport-dialog.tsx b/js/web/src/components/stream/teleport-dialog.tsx index 318cec72f..5e2c21b9e 100644 --- a/js/web/src/components/stream/teleport-dialog.tsx +++ b/js/web/src/components/stream/teleport-dialog.tsx @@ -9,42 +9,32 @@ import { } from "@/components/ui/dialog"; import useAvatars from "@/hooks/use-avatars"; import { useLiveUsers } from "@/hooks/use-live-users"; +import type { TeleportErrorData } from "@streamplace/core"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; function translateTeleportError( - message: string, + error: TeleportErrorData, t: ReturnType["t"], ) { - if ( - message === - "Only the streamer of the current livestream can start a teleport" - ) { - return t("teleport-error-streamer-only"); + switch (error.code) { + case "streamer-only": + return t("teleport-error-streamer-only"); + case "handle-format": + return t("teleport-error-handle-format"); + case "countdown-number": + return t("teleport-error-countdown-number"); + case "countdown-range": + return t("teleport-countdown-error"); + case "self": + return t("teleport-error-self"); + case "resolve-handle": + return t("teleport-error-resolve-handle", { + handle: error.params?.handle, + }); + case "create": + return t("teleport-error-create"); } - if (message === "Invalid handle format. Expected: handle.bsky.social") { - return t("teleport-error-handle-format"); - } - if (message === "Countdown must be a number (seconds)") { - return t("teleport-error-countdown-number"); - } - if (message.startsWith("Countdown must be between")) { - return t("teleport-countdown-error"); - } - if (message === "You cannot teleport to yourself") { - return t("teleport-error-self"); - } - if ( - message === "Failed to create teleport" || - message === "Teleport failed." - ) { - return t("teleport-error-create"); - } - const resolveError = message.match(/^Could not resolve handle: (.+)$/); - if (resolveError) { - return t("teleport-error-resolve-handle", { handle: resolveError[1] }); - } - return t("teleport-error-generic", { message }); } export function TeleportDialog({ @@ -54,7 +44,10 @@ export function TeleportDialog({ }: { open: boolean; onOpenChange: (open: boolean) => void; - onSubmit: (handle: string, countdownSeconds: number) => Promise; + onSubmit: ( + handle: string, + countdownSeconds: number, + ) => Promise; }) { const { t } = useTranslation("common"); const [query, setQuery] = useState(""); @@ -113,15 +106,14 @@ export function TeleportDialog({ setSubmitting(true); setError(null); try { - await onSubmit(stream.author.handle, seconds); + const teleportError = await onSubmit(stream.author.handle, seconds); + if (teleportError) { + setError(translateTeleportError(teleportError, t)); + return; + } close(); - } catch (reason) { - setError( - translateTeleportError( - reason instanceof Error ? reason.message : "Teleport failed.", - t, - ), - ); + } catch { + setError(t("teleport-error-create")); } finally { setSubmitting(false); } -- 2.54.0