diff --git a/docs/plans/2026-07-29-clips-display-name-prompt.md b/docs/plans/2026-07-29-clips-display-name-prompt.md new file mode 100644 index 0000000000..323d4bb814 --- /dev/null +++ b/docs/plans/2026-07-29-clips-display-name-prompt.md @@ -0,0 +1,902 @@ +# Clips Display-Name Prompt Implementation Plan + +> **For the Fusion agent:** Execute this plan task-by-task. Each step is one +> action. Do not skip steps. Verify after each task. Commit after each task. + +**Goal:** Capture a real display name from signed-in Clips users who don't have +one, via a friendly modal at two natural moments, instead of requiring them to +find the field in Settings. + +**Architecture:** One shared shadcn `Dialog` component with two copy variants, +driven by one gate hook. The net-new variant mounts on the watch page while the +user's first recording is still processing; the returning variant mounts on the +library index. Both write through the existing core `update-user-profile` +action. A soft "skip" is remembered in `sessionStorage` only, so we ask again +next browser session. + +**Tech Stack:** React Router 7, TanStack Query, shadcn/ui, Tabler icons, +`@agent-native/core` actions + i18n, Vitest. + +--- + +## Background the implementer needs + +Read this before Task 1. Three non-obvious facts drive the whole design. + +### 1. No user ever has an empty name + +At signup, Better Auth is handed the email local part as the name: + +- `packages/core/src/server/auth.ts:3332` — `name: email.split("@")[0]` +- `packages/core/src/server/auth.ts:3568` — same + +And the read path substitutes the full email when nothing is stored: + +- `packages/core/src/user-profile/store.ts:19-29` — `getUserProfile()` returns + `normalizeUserProfileName(storedName ?? authName, email)` +- `packages/core/src/user-profile/shared.ts:8-14` — that helper returns `email` + when the name is blank + +So `get-user-profile` **always** returns a non-empty `name`. A check like +`if (!profile.name)` matches zero users and the modal would never appear. + +The only workable predicate is "the name still looks auto-derived": + +``` +name === email || name === email.split("@")[0] +``` + +This has a known false positive: a user genuinely named `tim` at `tim@…` looks +identical to one who never set a name. + +**That ambiguity only applies to returning users.** A brand-new signup's name is +the local part *by construction* — core auth wires no social/OAuth provider, so +`auth.ts:3332` and `:3568` are the only ways a user row is created. So the two +surfaces use different predicates: + +| Surface | Predicate | Why | +| --- | --- | --- | +| Net-new (watch page) | `isAutoDerivedName()` — local part, full email, or blank | They cannot have chosen a name yet. Ask openly. | +| Returning (library) | `matchesEmailLocalPart()` — exactly the local part | Narrower on purpose. Frame it as a confirmation, and never bother anyone whose name differs. | + +### 2. There are two display-name stores, and one is dead + +| Store | Written by | Read by | +| --- | --- | --- | +| Core `user-profile` (Better Auth `user.name`) | `AccountSettingsCard` on the **Account** tab, `_app.settings._index.tsx:840` | `server/jobs/transactional-emails.ts:410` — the sender name on notification emails share recipients receive | +| `clips-user-prefs.displayName` | Hand-rolled Profile card on the **General** tab, `_app.settings._index.tsx:1480-1503` | **Nothing.** Grep confirms zero consumers. | + +The core store is the real one. Task 6 deletes the dead card and its key. + +### 3. `application_state` is per-user forever, not per-session + +Despite the column name, `getSessionId()` returns the user's email: + +- `packages/core/src/application-state/handlers.ts:24-33` + +So `application_state` cannot express "ask again next session". Use +`sessionStorage` for the soft skip. This matches how Clips already stores +dismissals (`app/lib/capture-install-options.ts:12-64` uses `localStorage`); +we want the shorter lifetime. + +### Product decisions already made + +- **One store.** Modal writes to core `update-user-profile`; the dead Clips + Profile card is deleted. +- **Returning users are prompted on the library index**, not the watch page, + and only when their name is exactly the email local part. The dialog is a + confirmation pre-filled with that value, not an empty ask. +- **Skip is soft** — we ask again next browser session. + +--- + +## Task 1: Shared prompt dialog component + +**Files:** + +- Create: `templates/clips/app/components/profile/name-prompt-dialog.tsx` + +**Step 1: Create the directory** + +```bash +mkdir -p code/templates/clips/app/components/profile +``` + +**Step 2: Create the file with this exact content** + +```tsx +import { useActionMutation } from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const MAX_NAME_LENGTH = 120; + +export type NamePromptVariant = "welcome" | "welcome-back"; + +export interface NamePromptDialogProps { + open: boolean; + variant: NamePromptVariant; + /** + * Pre-filled field value. The `welcome-back` variant passes the email local + * part so the user can confirm it with one click; `welcome` passes nothing. + */ + initialName?: string; + /** Called with `true` after a successful save, `false` on skip/dismiss. */ + onResolved: (saved: boolean) => void; +} + +export function NamePromptDialog({ + open, + variant, + initialName = "", + onResolved, +}: NamePromptDialogProps) { + const t = useT(); + const queryClient = useQueryClient(); + const [name, setName] = useState(initialName); + const [seededFor, setSeededFor] = useState(initialName); + + // `initialName` arrives after the profile query resolves, which is usually + // after first render. Seed the field once it changes rather than stranding + // the user with an empty confirm box. + if (initialName !== seededFor) { + setSeededFor(initialName); + setName(initialName); + } + + const updateProfile = useActionMutation< + { email: string; name: string }, + { name: string } + >("update-user-profile", { + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["action", "get-user-profile"] }), + queryClient.invalidateQueries({ queryKey: ["session"] }), + ]); + toast.success(t("namePrompt.saved")); + onResolved(true); + }, + onError: (err: any) => + toast.error(err?.message ?? t("namePrompt.saveFailed")), + }); + + const trimmed = name.trim(); + const canSubmit = trimmed.length > 0 && !updateProfile.isPending; + + function handleSubmit() { + if (!canSubmit) return; + updateProfile.mutate({ name: trimmed.slice(0, MAX_NAME_LENGTH) }); + } + + return ( + { + if (!next && !updateProfile.isPending) onResolved(false); + }} + > + + + + {variant === "welcome" + ? t("namePrompt.welcomeTitle") + : t("namePrompt.welcomeBackTitle")} + + + {variant === "welcome" + ? t("namePrompt.welcomeBody") + : t("namePrompt.welcomeBackBody")} + + + +
+ + e.currentTarget.select()} + value={name} + maxLength={MAX_NAME_LENGTH} + placeholder={t("namePrompt.placeholder")} + disabled={updateProfile.isPending} + onChange={(e) => setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(); + } + }} + /> +

+ {t("namePrompt.visibilityDisclaimer")} +

+
+ + + + + +
+
+ ); +} +``` + +**Step 3: Verify it compiles** + +```bash +cd code/templates/clips && npx tsc --noEmit -p . 2>&1 | grep -v "actions-registry\|virtual:react-router" +``` + +Expected: no output (the two filtered lines are pre-existing generated-file +errors present on a clean tree). + +**Step 4: Commit** + +```bash +git add code/templates/clips/app/components/profile/name-prompt-dialog.tsx +git commit -m "Add shared display-name prompt dialog" +``` + +--- + +## Task 2: i18n strings + +**Files:** + +- Modify: `templates/clips/app/i18n/en-US.ts` and the 10 other locale catalogs + +**Step 1: Add the `namePrompt` namespace to `en-US.ts`** + +Insert as a new top-level key, alphabetically near `navigation`: + +```ts + namePrompt: { + welcomeTitle: "Your first clip is on its way", + welcomeBody: + "While it finishes processing: you can share it with a link, and anyone you send it to can watch it and leave comments. First though — what should we call you?", + welcomeBackTitle: "Welcome back to Clips", + welcomeBackBody: + "We've reconfigured how user profiles work since your last login. Is this the right username for you?", + label: "Your name", + placeholder: "e.g. Tim Milazzo", + visibilityDisclaimer: + "This name may be visible to anyone you share a clip with.", + save: "Save", + confirm: "Looks right", + saving: "Saving...", + skip: "Not now", + saved: "Thanks — we'll call you that from now on", + saveFailed: "Couldn't save your name", + }, +``` + +**Step 2: Add the same key set to the other 10 catalogs** + +Files: `zh-CN.ts`, `zh-TW.ts`, `es-ES.ts`, `fr-FR.ts`, `de-DE.ts`, `ja-JP.ts`, +`ko-KR.ts`, `pt-BR.ts`, `hi-IN.ts`, `ar-SA.ts`. Same keys, translated values, +no placeholders to preserve in this set. + +**Step 3: Verify** + +```bash +cd code && pnpm guard:i18n-catalogs +``` + +Expected: `[guard:i18n-catalogs] checked 18 catalog directories` and exit 0. + +**Step 4: Commit** + +```bash +git add code/templates/clips/app/i18n +git commit -m "Add display-name prompt strings to all locales" +``` + +--- + +## Task 3: Gate hook + +**Files:** + +- Create: `templates/clips/app/hooks/use-profile-name-prompt.ts` + +**Step 1: Create the file with this exact content** + +```ts +import { + useActionQuery, + useSession, +} from "@agent-native/core/client/hooks"; +import { useCallback, useState } from "react"; + +const SKIP_SESSION_KEY = "clips:profile-name-prompt-skipped"; + +export function emailLocalPart(email: string): string { + return email.split("@")[0] ?? ""; +} + +/** + * Signup writes `email.split("@")[0]` into the auth user's name + * (packages/core/src/server/auth.ts:3332), and the profile reader substitutes + * the full email when nothing is stored. So "no name" is never null — it is + * one of those two derived values. + * + * Used for the net-new surface, where the user cannot yet have chosen a name. + */ +export function isAutoDerivedName( + name: string | null | undefined, + email: string, +): boolean { + const trimmed = name?.trim(); + if (!trimmed) return true; + return trimmed === email || trimmed === emailLocalPart(email); +} + +/** + * Narrower predicate for returning users. Only the exact email local part + * qualifies, because that is the value signup wrote and the only one the + * confirmation dialog can sensibly pre-fill. Anyone whose name differs — + * including the rare full-email fallback — is left alone. + */ +export function matchesEmailLocalPart( + name: string | null | undefined, + email: string, +): boolean { + const trimmed = name?.trim(); + if (!trimmed) return false; + return trimmed === emailLocalPart(email); +} + +function readSkipped(): boolean { + if (typeof window === "undefined") return false; + try { + return window.sessionStorage.getItem(SKIP_SESSION_KEY) === "1"; + } catch { + return false; + } +} + +export interface ProfileNamePromptGate { + /** True when this user should be asked for a display name right now. */ + shouldPrompt: boolean; + /** Value to pre-fill the field with. Empty for the net-new surface. */ + initialName: string; + /** Call with the dialog's `saved` result to close and remember the outcome. */ + resolve: (saved: boolean) => void; +} + +/** + * @param enabled Caller-supplied condition for the surface (e.g. "this is + * their first recording"). The hook still runs its queries when false so + * hook order stays stable; it just never reports `shouldPrompt`. + * @param surface Picks the predicate and whether the field is pre-filled. + */ +export function useProfileNamePrompt( + enabled: boolean, + surface: "new" | "returning", +): ProfileNamePromptGate { + const { session } = useSession(); + const email = session?.email ?? ""; + const [skipped, setSkipped] = useState(readSkipped); + const [savedThisMount, setSavedThisMount] = useState(false); + + const profileQ = useActionQuery<{ email: string; name: string }>( + "get-user-profile", + undefined, + { enabled: Boolean(email), retry: false, throwOnError: false }, + ); + + const resolve = useCallback((saved: boolean) => { + if (saved) { + setSavedThisMount(true); + return; + } + setSkipped(true); + try { + window.sessionStorage.setItem(SKIP_SESSION_KEY, "1"); + } catch { + // Private-mode / storage-disabled: fall back to in-memory skip only. + } + }, []); + + const nameQualifies = + surface === "new" + ? isAutoDerivedName(profileQ.data?.name, email) + : matchesEmailLocalPart(profileQ.data?.name, email); + + const shouldPrompt = + enabled && + !skipped && + !savedThisMount && + Boolean(email) && + profileQ.isSuccess && + nameQualifies; + + return { + shouldPrompt, + initialName: surface === "returning" ? emailLocalPart(email) : "", + resolve, + }; +} +``` + +**Step 2: Verify it compiles** + +```bash +cd code/templates/clips && npx tsc --noEmit -p . 2>&1 | grep -v "actions-registry\|virtual:react-router" +``` + +Expected: no output. + +**Step 3: Commit** + +```bash +git add code/templates/clips/app/hooks/use-profile-name-prompt.ts +git commit -m "Add display-name prompt gate hook" +``` + +--- + +## Task 4: Net-new branch on the watch page + +The watch page renders a dedicated "still processing" shell when +`recording.status !== "ready" || !recording.videoUrl` +(`app/routes/r.$recordingId.tsx:952-1040`). Mount the prompt there so it +overlays while the first clip finishes, exactly as specified. + +"First recording" comes from `useRecordingsCount({ view: "library" })` +(`app/hooks/use-library.ts:88-100`), which calls `list-recordings` with +`countOnly` so it skips the row payload. + +**Files:** + +- Modify: `templates/clips/app/routes/r.$recordingId.tsx` + +**Step 1: Add the imports** + +Next to the existing `@/components/...` imports: + +```ts +import { NamePromptDialog } from "@/components/profile/name-prompt-dialog"; +import { useProfileNamePrompt } from "@/hooks/use-profile-name-prompt"; +import { useRecordingsCount } from "@/hooks/use-library"; +``` + +**Step 2: Add the gate near the other derived flags** + +Place immediately after `const canArchiveRecording = canEdit;` (currently +`r.$recordingId.tsx:531`). It must sit above the early `return` for the +processing shell so hook order is unconditional: + +```ts +const recordingsCountQ = useRecordingsCount({ view: "library" }); +const isFirstRecording = + recordingsCountQ.isSuccess && (recordingsCountQ.data ?? 0) <= 1; +const namePrompt = useProfileNamePrompt( + isFirstRecording && role === "owner" && recording?.status !== "ready", + "new", +); +``` + +**Step 3: Render it inside the processing shell** + +Inside the `if (recording.status !== "ready" || !recording.videoUrl)` block, +just before that branch's closing ``: + +```tsx + +``` + +**Step 4: Format** + +```bash +cd code/templates/clips && npx oxfmt 'app/routes/r.$recordingId.tsx' +``` + +**Step 5: Verify** + +```bash +cd code/templates/clips && npx tsc --noEmit -p . 2>&1 | grep -v "actions-registry\|virtual:react-router" +``` + +Expected: no output. + +**Step 6: Commit** + +```bash +git add code/templates/clips/app/routes/r.\$recordingId.tsx +git commit -m "Prompt first-time users for a display name while their clip processes" +``` + +--- + +## Task 5: Returning branch on the library index + +**Files:** + +- Modify: `templates/clips/app/routes/_app.library._index.tsx` + +**Step 1: Add the imports** + +```ts +import { NamePromptDialog } from "@/components/profile/name-prompt-dialog"; +import { useProfileNamePrompt } from "@/hooks/use-profile-name-prompt"; +``` + +**Step 2: Add the gate inside `LibraryIndexRoute`** + +```ts +const namePrompt = useProfileNamePrompt(true, "returning"); +``` + +No recording-count condition here. Someone with zero recordings who lands on +the library is still a returning user by every signal we have, and the net-new +path only fires on the watch page — so the two surfaces cannot double-prompt. + +The `"returning"` surface narrows the predicate to an exact email-local-part +match and supplies that value as the pre-filled field, turning the dialog into +a one-click confirmation. Anyone whose stored name differs never sees it. + +**Step 3: Wrap the returned JSX** + +Change the `return ` into a fragment: + +```tsx +return ( + <> + + + +); +``` + +**Step 4: Format and verify** + +```bash +cd code/templates/clips && npx oxfmt app/routes/_app.library._index.tsx && npx tsc --noEmit -p . 2>&1 | grep -v "actions-registry\|virtual:react-router" +``` + +Expected: no output from tsc. + +**Step 5: Commit** + +```bash +git add code/templates/clips/app/routes/_app.library._index.tsx +git commit -m "Prompt returning users without a display name on the library" +``` + +--- + +## Task 6: Delete the dead Clips profile field + +`clips-user-prefs.displayName` is written by the General-tab Profile card and +read by nothing. The Account tab already has a working display-name field +backed by the core profile store, so no capability is lost. + +**Files:** + +- Modify: `templates/clips/app/routes/_app.settings._index.tsx` +- Modify: `templates/clips/shared/clips-ai-prefs.ts` + +**Step 1: Delete the Profile card** + +Remove the whole `` block, +`_app.settings._index.tsx:1480-1503`. + +**Step 2: Delete its state and wiring** + +- `:433` — `const [displayName, setDisplayName] = useState("");` +- `:499` — `setDisplayName(v.displayName ?? "");` +- `:534` — `displayName: displayName.trim() || undefined,` in `handleSave` +- `:169` — `displayName?: string;` from the local `ClipsUserSettings` interface + +**Step 3: Delete the settings-search entry** + +Remove the `clips-profile` entry at `_app.settings._index.tsx:804-809`. + +**Step 4: Remove the field from the shared type** + +In `shared/clips-ai-prefs.ts:28-33`, delete `displayName?: string;` from +`ClipsUserPrefs`. + +Leave any already-persisted `displayName` values in the settings blob alone. +The PUT route merges partial updates +(`server/routes/_agent-native/clips/user-prefs.put.ts`), so stale keys are inert +and removing them is not worth a migration. + +**Step 5: Check for stragglers** + +```bash +cd code/templates/clips && grep -rn "displayName" app/ shared/ server/routes/_agent-native/clips/ | grep -v "calendar\|meetings\|feature-flags\|comments-panel\|db/index" +``` + +Expected: no output. + +**Step 6: Verify** + +```bash +cd code/templates/clips && npx oxfmt app/routes/_app.settings._index.tsx shared/clips-ai-prefs.ts && npx tsc --noEmit -p . 2>&1 | grep -v "actions-registry\|virtual:react-router" +``` + +Expected: no output from tsc. + +The `settings.profile`, `settings.displayName`, and +`settings.displayNamePlaceholder` i18n keys (`en-US.ts:648-651`) are still used +by other surfaces — leave them. + +**Step 7: Commit** + +```bash +git add code/templates/clips/app/routes/_app.settings._index.tsx code/templates/clips/shared/clips-ai-prefs.ts +git commit -m "Remove duplicate Clips profile field that wrote to an unread key" +``` + +--- + +## Task 7: Unit test the predicate + +The predicate is the one piece of logic that silently disables the whole +feature if it drifts. Test it directly. + +**Files:** + +- Create: `templates/clips/app/hooks/use-profile-name-prompt.test.ts` + +**Step 1: Create the file with this exact content** + +```ts +import { describe, expect, it } from "vitest"; + +import { + isAutoDerivedName, + matchesEmailLocalPart, +} from "./use-profile-name-prompt"; + +describe("isAutoDerivedName (net-new surface)", () => { + it("treats the signup-derived local part as no name", () => { + // packages/core/src/server/auth.ts:3332 seeds name with the local part. + expect(isAutoDerivedName("tim", "tim@builder.io")).toBe(true); + }); + + it("treats the full-email fallback as no name", () => { + // normalizeUserProfileName() substitutes the email when nothing is stored. + expect(isAutoDerivedName("tim@builder.io", "tim@builder.io")).toBe(true); + }); + + it("treats blank and missing as no name", () => { + expect(isAutoDerivedName("", "tim@builder.io")).toBe(true); + expect(isAutoDerivedName(null, "tim@builder.io")).toBe(true); + expect(isAutoDerivedName(" ", "tim@builder.io")).toBe(true); + }); + + it("accepts a real name", () => { + expect(isAutoDerivedName("Tim Milazzo", "tim@builder.io")).toBe(false); + }); +}); + +describe("matchesEmailLocalPart (returning surface)", () => { + it("matches the exact local part", () => { + expect(matchesEmailLocalPart("tim", "tim@builder.io")).toBe(true); + }); + + it("does not match the full-email fallback", () => { + // Deliberately out of scope: the confirm dialog would pre-fill a whole + // email address, which is not a username anyone would accept. + expect(matchesEmailLocalPart("tim@builder.io", "tim@builder.io")).toBe( + false, + ); + }); + + it("does not match blank or missing", () => { + expect(matchesEmailLocalPart("", "tim@builder.io")).toBe(false); + expect(matchesEmailLocalPart(null, "tim@builder.io")).toBe(false); + }); + + it("leaves a real name alone", () => { + expect(matchesEmailLocalPart("Tim Milazzo", "tim@builder.io")).toBe(false); + }); +}); +``` + +**Step 2: Run it** + +```bash +cd code/templates/clips && npx vitest run app/hooks/use-profile-name-prompt.test.ts +``` + +Expected: `Test Files 1 passed`, `Tests 8 passed`. + +**Step 3: Commit** + +```bash +git add code/templates/clips/app/hooks/use-profile-name-prompt.test.ts +git commit -m "Test display-name detection predicate" +``` + +--- + +## Task 8: Browser verification + +Type checks prove nothing about whether the modal appears. Drive it for real. + +Chromium for Playwright is already installed in this environment. The launcher +lives at +`node_modules/.pnpm/playwright@1.61.1/node_modules/playwright/index.mjs` — +import it by path, because `playwright` is not resolvable from the repo root. + +**Step 1: Force a test user into the no-name state** + +```bash +cd code/templates/clips && node --input-type=module -e " +import {neon} from '@neondatabase/serverless'; +const sql=neon(process.env.CLIPS_DATABASE_URL); +console.log(await sql\`select id, email, name from \\\"user\\\" where email='dev@local.test'\`); +" +``` + +If `name` is anything other than `dev` or `dev@local.test`, set it to `dev` so +the predicate matches, and record the original value to restore in Step 4. + +**Step 2: Verify the returning-user path** + +Script (run from `code/`, delete the file afterwards): + +```js +import { chromium } from "./node_modules/.pnpm/playwright@1.61.1/node_modules/playwright/index.mjs"; +const b = await chromium.launch(); +const p = await b.newPage({ viewport: { width: 1440, height: 900 } }); +await p.goto("http://127.0.0.1:8080/clips/library", { waitUntil: "networkidle" }); +await p.waitForTimeout(1500); +console.log("dialog:", await p.getByRole("dialog").innerText().catch(() => "NONE")); +await p.getByRole("button", { name: /not now/i }).click(); +await p.waitForTimeout(500); +console.log("after skip:", await p.getByRole("dialog").count()); +await p.reload({ waitUntil: "networkidle" }); +await p.waitForTimeout(1500); +console.log("after reload (same session, expect 0):", await p.getByRole("dialog").count()); +await b.close(); +``` + +Expected: the welcome-back copy on first load, **with the field pre-filled to +`dev`**; `0` dialogs after skip; `0` after reload within the same browser +session. + +**Step 2b: Verify a real name suppresses the returning prompt** + +```bash +cd code/templates/clips && node --input-type=module -e " +import {neon} from '@neondatabase/serverless'; +const sql=neon(process.env.CLIPS_DATABASE_URL); +await sql\`update \\"user\\" set name='Tim Milazzo' where email='dev@local.test'\`; +" +``` + +Reload `/clips/library` in a fresh context. Expected: `0` dialogs. Set the name +back to `dev` before continuing. + +**Step 3: Verify save persists** + +Re-run with a fresh browser context, type a name, click Save, then confirm: + +```bash +cd code/templates/clips && node --input-type=module -e " +import {neon} from '@neondatabase/serverless'; +const sql=neon(process.env.CLIPS_DATABASE_URL); +console.log(await sql\`select email, name from \\\"user\\\" where email='dev@local.test'\`); +" +``` + +Expected: `name` is the string that was typed. + +**Step 4: Restore the test user's original name** + +Do not leave modified fixture state behind. + +**Step 5: Commit nothing** + +This task produces no source changes. + +--- + +## Task 9: Changelog + +**Step 1: Record the user-facing change** + +```bash +cd code/templates/clips && npx agent-native changelog add "Clips now asks for your display name when it doesn't have one, instead of hiding it in Settings." --type added +``` + +**Step 2: Commit** + +```bash +git add code/templates/clips/changelog +git commit -m "Add changelog entry for display-name prompt" +``` + +--- + +## Final verification checklist + +```bash +cd code && pnpm guard:i18n-catalogs +cd code/templates/clips && npx tsc --noEmit -p . 2>&1 | grep -v "actions-registry\|virtual:react-router" +cd code/templates/clips && npx vitest run app/ actions/ +``` + +- [ ] `pnpm guard:i18n-catalogs` exits 0 +- [ ] `tsc` reports only the two pre-existing generated-file errors +- [ ] Clips test suite passes +- [ ] Welcome-back modal appears on `/clips/library` when the name is the email + local part, pre-filled with that value +- [ ] It does **not** appear once the name differs from the local part +- [ ] Skip suppresses it for the rest of the browser session +- [ ] Saving writes the name to the auth user row +- [ ] Settings General tab no longer has a Profile card; Account tab still does + +--- + +## Known limitations + +Call these out in the PR description; do not try to solve them here. + +1. **False positives on the returning surface only.** A user genuinely named + `tim` at `tim@…` is indistinguishable from one who never set a name — signup + writes exactly that value, so no marker exists to tell them apart. The + confirmation framing ("Is this the right username for you?", pre-filled with + `tim`) is written so that person can answer in one click rather than being + accused of having no name. The net-new surface has no such ambiguity: core + auth wires no social provider, so a fresh account's name is always the + derived local part. +2. **Returning users whose profile resolves to the full email are never + prompted.** `matchesEmailLocalPart` deliberately excludes them, because a + confirm dialog pre-filled with `tim@builder.io` is not a username anyone + would accept. This only occurs when both the stored setting and the auth + user's name are blank — not reachable through the normal signup path. They + can still set a name in Settings. +3. **The disclaimer is currently true only for email.** The name reaches share + recipients as the sender name on notification emails + (`server/jobs/transactional-emails.ts:410`). It does **not** yet appear on + comments — `app/components/player/comments-panel.tsx:153` sends + `authorName: null`, so comments fall back to the email local part. The copy + in Task 2 says "may be visible", which is accurate today. Wiring the profile + name into comment authorship is a separate change. +4. **Net-new detection is a proxy.** `list-recordings --countOnly` with + `view: "library"` scopes to the current user's personal recordings in the + active org (`actions/list-recordings.ts:159-171`). Someone who imports two + Looms before their first native recording will get the returning-user copy. + Acceptable; the alternative needs a new server field for account age. diff --git a/templates/clips/actions/get-recording-player-data.ts b/templates/clips/actions/get-recording-player-data.ts index 2c9d221c05..d2a1949cd3 100644 --- a/templates/clips/actions/get-recording-player-data.ts +++ b/templates/clips/actions/get-recording-player-data.ts @@ -345,6 +345,7 @@ export default defineAction({ spaceIds: parseSpaceIds(rec.spaceIds), createdAt: rec.createdAt, updatedAt: rec.updatedAt, + archivedAt: rec.archivedAt, }, transcript: transcript ? { diff --git a/templates/clips/app/components/player/delete-recording-menu.tsx b/templates/clips/app/components/player/delete-recording-menu.tsx index 1a4ebe63ff..c8320ea071 100644 --- a/templates/clips/app/components/player/delete-recording-menu.tsx +++ b/templates/clips/app/components/player/delete-recording-menu.tsx @@ -1,6 +1,13 @@ import { useActionMutation } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; -import { IconDots, IconDownload, IconTrash } from "@tabler/icons-react"; +import { + IconArchive, + IconArchiveOff, + IconDots, + IconDownload, + IconGif, + IconTrash, +} from "@tabler/icons-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; @@ -35,6 +42,12 @@ interface RecordingOptionsMenuProps extends DeleteRecordingMenuProps { downloadLabel?: string; downloadingLabel?: string; onDownload?: () => void; + canDownloadGif?: boolean; + gifPending?: boolean; + onDownloadGif?: () => void; + canArchive?: boolean; + isArchived?: boolean; + onArchiveChanged?: () => void; } export function RecordingOptionsMenu({ @@ -46,11 +59,19 @@ export function RecordingOptionsMenu({ downloadLabel, downloadingLabel, onDownload, + canDownloadGif = false, + gifPending = false, + onDownloadGif, + canArchive = false, + isArchived = false, + onArchiveChanged, }: RecordingOptionsMenuProps) { const t = useT(); const [open, setOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const showDownload = canDownload && Boolean(onDownload); + const showDownloadGif = canDownloadGif && Boolean(onDownloadGif); + const showArchive = canArchive; const showDelete = canDelete; const trashRecording = useActionMutation( "trash-recording", @@ -70,12 +91,57 @@ export function RecordingOptionsMenu({ trashRecording.mutate({ id: recordingId }); }, [recordingId, trashRecording]); + const archiveRecording = useActionMutation( + "archive-recording", + { + onSuccess: () => { + toast.success(t("deleteRecordingMenu.archived")); + onArchiveChanged?.(); + }, + onError: (err: any) => + toast.error(err?.message ?? t("deleteRecordingMenu.archiveFailed")), + }, + ); + const restoreRecording = useActionMutation( + "restore-recording", + { + onSuccess: () => { + toast.success(t("deleteRecordingMenu.restoredFromArchive")); + onArchiveChanged?.(); + }, + onError: (err: any) => + toast.error(err?.message ?? t("deleteRecordingMenu.unarchiveFailed")), + }, + ); + const archivePending = + archiveRecording.isPending || restoreRecording.isPending; + const handleDownload = useCallback(() => { setMenuOpen(false); onDownload?.(); }, [onDownload]); - if (!showDownload && !showDelete) return null; + const handleDownloadGif = useCallback(() => { + setMenuOpen(false); + onDownloadGif?.(); + }, [onDownloadGif]); + + const handleArchive = useCallback(() => { + if (archivePending) return; + setMenuOpen(false); + if (isArchived) restoreRecording.mutate({ id: recordingId }); + else archiveRecording.mutate({ id: recordingId }); + }, [ + archivePending, + archiveRecording, + isArchived, + recordingId, + restoreRecording, + ]); + + if (!showDownload && !showDownloadGif && !showArchive && !showDelete) { + return null; + } return ( ) : null} - {showDownload && showDelete ? : null} + {showDownloadGif ? ( + + + {gifPending + ? t("deleteRecordingMenu.buildingGif") + : t("deleteRecordingMenu.downloadAsGif")} + + ) : null} + {showArchive ? ( + + {isArchived ? ( + + ) : ( + + )} + {isArchived + ? t("deleteRecordingMenu.unarchive") + : t("deleteRecordingMenu.archive")} + + ) : null} + {(showDownload || showDownloadGif || showArchive) && showDelete ? ( + + ) : null} {showDelete ? ( { diff --git a/templates/clips/app/i18n/ar-SA.ts b/templates/clips/app/i18n/ar-SA.ts index 104474a2e6..b2a8002a95 100644 --- a/templates/clips/app/i18n/ar-SA.ts +++ b/templates/clips/app/i18n/ar-SA.ts @@ -139,6 +139,8 @@ const messages = { pageTitle: "اجتماع · Clips", }, recordingPage: { + buildingGif: "جارٍ إنشاء GIF… {{percent}}%", + gifExportFailed: "تعذر إنشاء GIF من هذا المقطع", untitledClip: "مقطع بدون عنوان", recordingNotFound: "لم يتم العثور على التسجيل", noAccess: "قد لا يكون لديك حق الوصول إلى هذا المقطع.", @@ -963,6 +965,14 @@ const messages = { updateTranscriptSharingFailed: "تعذر تحديث مشاركة النص", }, deleteRecordingMenu: { + downloadAsGif: "تنزيل كملف GIF", + buildingGif: "جارٍ إنشاء GIF…", + archive: "أرشفة", + unarchive: "إلغاء الأرشفة", + archived: "تمت أرشفة المقطع", + restoredFromArchive: "تمت استعادة المقطع من الأرشيف", + archiveFailed: "تعذرت أرشفة المقطع", + unarchiveFailed: "تعذرت استعادة المقطع", movedToTrash: "تم نقل المقطع إلى المهملات", deleteFailed: "فشل حذف المقطع", clipOptions: "خيارات المقطع", diff --git a/templates/clips/app/i18n/de-DE.ts b/templates/clips/app/i18n/de-DE.ts index 05d1faae37..74acd2221b 100644 --- a/templates/clips/app/i18n/de-DE.ts +++ b/templates/clips/app/i18n/de-DE.ts @@ -142,6 +142,8 @@ const messages = { pageTitle: "Treffen · Clips", }, recordingPage: { + buildingGif: "GIF wird erstellt… {{percent}} %", + gifExportFailed: "GIF konnte aus diesem Clip nicht erstellt werden", untitledClip: "Unbenannter Clip", recordingNotFound: "Aufnahme nicht gefunden", noAccess: "Möglicherweise haben Sie keinen Zugriff auf diesen Clip.", @@ -986,6 +988,14 @@ Alle sichtbaren Änderungen für Clips-Nutzer werden hier dokumentiert. Du kanns "Die Transkriptfreigabe konnte nicht aktualisiert werden", }, deleteRecordingMenu: { + downloadAsGif: "Als GIF herunterladen", + buildingGif: "GIF wird erstellt…", + archive: "Archivieren", + unarchive: "Archivierung aufheben", + archived: "Clip archiviert", + restoredFromArchive: "Clip aus dem Archiv wiederhergestellt", + archiveFailed: "Clip konnte nicht archiviert werden", + unarchiveFailed: "Clip konnte nicht wiederhergestellt werden", movedToTrash: "Clip in den Papierkorb verschoben", deleteFailed: "Clip konnte nicht gelöscht werden", clipOptions: "Clip-Optionen", diff --git a/templates/clips/app/i18n/en-US.ts b/templates/clips/app/i18n/en-US.ts index 2670f35217..500d0e3b57 100644 --- a/templates/clips/app/i18n/en-US.ts +++ b/templates/clips/app/i18n/en-US.ts @@ -140,6 +140,8 @@ const messages = { pageTitle: "Meeting · Clips", }, recordingPage: { + buildingGif: "Building GIF… {{percent}}%", + gifExportFailed: "Couldn't build a GIF from this clip", untitledClip: "Untitled Clip", recordingNotFound: "Recording not found", noAccess: "You may not have access to this clip.", @@ -954,6 +956,14 @@ All notable user-facing changes to Clips are documented here. Open it any time f updateTranscriptSharingFailed: "Couldn't update transcript sharing", }, deleteRecordingMenu: { + downloadAsGif: "Download as GIF", + buildingGif: "Building GIF...", + archive: "Archive", + unarchive: "Unarchive", + archived: "Clip archived", + restoredFromArchive: "Clip restored from archive", + archiveFailed: "Failed to archive clip", + unarchiveFailed: "Failed to restore clip", movedToTrash: "Clip moved to trash", deleteFailed: "Failed to delete clip", clipOptions: "Clip options", diff --git a/templates/clips/app/i18n/es-ES.ts b/templates/clips/app/i18n/es-ES.ts index 4e8dd459f1..fbfe236c09 100644 --- a/templates/clips/app/i18n/es-ES.ts +++ b/templates/clips/app/i18n/es-ES.ts @@ -141,6 +141,8 @@ const messages = { pageTitle: "Reunión · Clips", }, recordingPage: { + buildingGif: "Creando GIF… {{percent}}%", + gifExportFailed: "No se pudo crear un GIF de este clip", untitledClip: "Clip sin título", recordingNotFound: "Grabación no encontrada", noAccess: "Es posible que no tengas acceso a este clip.", @@ -979,6 +981,14 @@ Todos los cambios visibles para los usuarios de Clips se documentan aquí. Puede "No se pudo actualizar el uso compartido de la transcripción", }, deleteRecordingMenu: { + downloadAsGif: "Descargar como GIF", + buildingGif: "Creando GIF…", + archive: "Archivar", + unarchive: "Desarchivar", + archived: "Clip archivado", + restoredFromArchive: "Clip restaurado desde el archivo", + archiveFailed: "No se pudo archivar el clip", + unarchiveFailed: "No se pudo restaurar el clip", movedToTrash: "Clip movido a la papelera", deleteFailed: "No se pudo eliminar el clip", clipOptions: "Opciones del clip", diff --git a/templates/clips/app/i18n/fr-FR.ts b/templates/clips/app/i18n/fr-FR.ts index c1b8356b35..beb1dc11a9 100644 --- a/templates/clips/app/i18n/fr-FR.ts +++ b/templates/clips/app/i18n/fr-FR.ts @@ -141,6 +141,8 @@ const messages = { pageTitle: "Réunion · Clips", }, recordingPage: { + buildingGif: "Création du GIF… {{percent}} %", + gifExportFailed: "Impossible de créer un GIF à partir de ce clip", untitledClip: "Extrait sans titre", recordingNotFound: "Enregistrement introuvable", noAccess: "Vous n’aurez peut-être pas accès à ce clip.", @@ -981,6 +983,14 @@ Tous les changements visibles par les utilisateurs de Clips sont documentés ici "Impossible de modifier le partage de la transcription", }, deleteRecordingMenu: { + downloadAsGif: "Télécharger en GIF", + buildingGif: "Création du GIF…", + archive: "Archiver", + unarchive: "Désarchiver", + archived: "Clip archivé", + restoredFromArchive: "Clip restauré depuis l’archive", + archiveFailed: "Échec de l’archivage du clip", + unarchiveFailed: "Échec de la restauration du clip", movedToTrash: "Clip déplacé vers la corbeille", deleteFailed: "Échec de la suppression du clip", clipOptions: "Options du clip", diff --git a/templates/clips/app/i18n/hi-IN.ts b/templates/clips/app/i18n/hi-IN.ts index efc1b295e3..86e18165e8 100644 --- a/templates/clips/app/i18n/hi-IN.ts +++ b/templates/clips/app/i18n/hi-IN.ts @@ -140,6 +140,8 @@ const messages = { pageTitle: "मीटिंग · Clips", }, recordingPage: { + buildingGif: "GIF बन रहा है… {{percent}}%", + gifExportFailed: "इस क्लिप से GIF नहीं बनाया जा सका", untitledClip: "शीर्षक रहित क्लिप", recordingNotFound: "रिकॉर्डिंग नहीं मिली", noAccess: "हो सकता है कि आपके पास इस क्लिप तक पहुंच न हो.", @@ -947,6 +949,14 @@ Clips में उपयोगकर्ताओं को दिखने व updateTranscriptSharingFailed: "ट्रांसक्रिप्ट शेयरिंग अपडेट नहीं हो सकी", }, deleteRecordingMenu: { + downloadAsGif: "GIF के रूप में डाउनलोड करें", + buildingGif: "GIF बन रहा है…", + archive: "आर्काइव", + unarchive: "आर्काइव से हटाएं", + archived: "क्लिप आर्काइव किया गया", + restoredFromArchive: "क्लिप आर्काइव से पुनर्स्थापित", + archiveFailed: "क्लिप आर्काइव नहीं किया जा सका", + unarchiveFailed: "क्लिप पुनर्स्थापित नहीं किया जा सका", movedToTrash: "क्लिप ट्रैश में ले जाया गया", deleteFailed: "क्लिप हटाने में विफल", clipOptions: "क्लिप विकल्प", diff --git a/templates/clips/app/i18n/ja-JP.ts b/templates/clips/app/i18n/ja-JP.ts index 57e4b80106..5ea1aedb53 100644 --- a/templates/clips/app/i18n/ja-JP.ts +++ b/templates/clips/app/i18n/ja-JP.ts @@ -141,6 +141,8 @@ const messages = { pageTitle: "ミーティング · Clips", }, recordingPage: { + buildingGif: "GIF を作成中… {{percent}}%", + gifExportFailed: "このクリップから GIF を作成できませんでした", untitledClip: "無題のクリップ", recordingNotFound: "録画が見つかりません", noAccess: "このクリップにアクセスできない可能性があります。", @@ -966,6 +968,14 @@ Clips のユーザー向けの主な変更はここに記録されます。コ updateTranscriptSharingFailed: "文字起こしの共有を更新できませんでした", }, deleteRecordingMenu: { + downloadAsGif: "GIF としてダウンロード", + buildingGif: "GIF を作成中…", + archive: "アーカイブ", + unarchive: "アーカイブ解除", + archived: "クリップをアーカイブしました", + restoredFromArchive: "アーカイブからクリップを復元しました", + archiveFailed: "クリップをアーカイブできませんでした", + unarchiveFailed: "クリップを復元できませんでした", movedToTrash: "クリップをゴミ箱に移動しました", deleteFailed: "クリップの削除に失敗しました", clipOptions: "クリップオプション", diff --git a/templates/clips/app/i18n/ko-KR.ts b/templates/clips/app/i18n/ko-KR.ts index 5129421afe..f3ad17c380 100644 --- a/templates/clips/app/i18n/ko-KR.ts +++ b/templates/clips/app/i18n/ko-KR.ts @@ -140,6 +140,8 @@ const messages = { pageTitle: "회의 · Clips", }, recordingPage: { + buildingGif: "GIF 만드는 중… {{percent}}%", + gifExportFailed: "이 클립에서 GIF를 만들지 못했습니다", untitledClip: "제목 없는 클립", recordingNotFound: "녹화를 찾을 수 없습니다", noAccess: "이 클립에 액세스할 수 없을 수도 있습니다.", @@ -955,6 +957,14 @@ Clips의 모든 사용자 대상 변경 사항은 여기에 기록됩니다. 명 updateTranscriptSharingFailed: "스크립트 공유를 업데이트하지 못했습니다", }, deleteRecordingMenu: { + downloadAsGif: "GIF로 다운로드", + buildingGif: "GIF 만드는 중…", + archive: "보관", + unarchive: "보관 해제", + archived: "클립을 보관했습니다", + restoredFromArchive: "보관함에서 클립을 복원했습니다", + archiveFailed: "클립을 보관하지 못했습니다", + unarchiveFailed: "클립을 복원하지 못했습니다", movedToTrash: "클립이 휴지통으로 이동됨", deleteFailed: "클립 삭제 실패", clipOptions: "클립 옵션", diff --git a/templates/clips/app/i18n/pt-BR.ts b/templates/clips/app/i18n/pt-BR.ts index df60e50a50..b3cee9ada8 100644 --- a/templates/clips/app/i18n/pt-BR.ts +++ b/templates/clips/app/i18n/pt-BR.ts @@ -141,6 +141,8 @@ const messages = { pageTitle: "Reunião · Clips", }, recordingPage: { + buildingGif: "Gerando GIF… {{percent}}%", + gifExportFailed: "Não foi possível gerar um GIF deste clipe", untitledClip: "Clipe sem título", recordingNotFound: "Gravação não encontrada", noAccess: "Você pode não ter acesso a este clipe.", @@ -975,6 +977,14 @@ Todas as mudanças visíveis para usuários do Clips são documentadas aqui. Voc "Não foi possível atualizar o compartilhamento da transcrição", }, deleteRecordingMenu: { + downloadAsGif: "Baixar como GIF", + buildingGif: "Gerando GIF…", + archive: "Arquivar", + unarchive: "Desarquivar", + archived: "Clipe arquivado", + restoredFromArchive: "Clipe restaurado do arquivo", + archiveFailed: "Não foi possível arquivar o clipe", + unarchiveFailed: "Não foi possível restaurar o clipe", movedToTrash: "Clipe movido para a lixeira", deleteFailed: "Falha ao excluir clipe", clipOptions: "Opções do clipe", diff --git a/templates/clips/app/i18n/zh-CN.ts b/templates/clips/app/i18n/zh-CN.ts index 9e2eff067d..c44dda428a 100644 --- a/templates/clips/app/i18n/zh-CN.ts +++ b/templates/clips/app/i18n/zh-CN.ts @@ -136,6 +136,8 @@ const messages = { pageTitle: "会议 · Clips", }, recordingPage: { + buildingGif: "正在生成 GIF… {{percent}}%", + gifExportFailed: "无法从此剪辑生成 GIF", untitledClip: "无标题剪辑", recordingNotFound: "找不到录制", noAccess: "您可能无权访问此剪辑。", @@ -917,6 +919,14 @@ Clips 中所有面向用户的重要更改都会记录在这里。你可以随 updateTranscriptSharingFailed: "无法更新转写共享设置", }, deleteRecordingMenu: { + downloadAsGif: "下载为 GIF", + buildingGif: "正在生成 GIF…", + archive: "归档", + unarchive: "取消归档", + archived: "剪辑已归档", + restoredFromArchive: "已从归档恢复剪辑", + archiveFailed: "归档剪辑失败", + unarchiveFailed: "恢复剪辑失败", movedToTrash: "剪辑已移至废纸篓", deleteFailed: "删除剪辑失败", clipOptions: "剪辑选项", diff --git a/templates/clips/app/i18n/zh-TW.ts b/templates/clips/app/i18n/zh-TW.ts index 8dd3985ee5..3a313b529b 100644 --- a/templates/clips/app/i18n/zh-TW.ts +++ b/templates/clips/app/i18n/zh-TW.ts @@ -136,6 +136,8 @@ const messages = { pageTitle: "會議 · Clips", }, recordingPage: { + buildingGif: "正在產生 GIF… {{percent}}%", + gifExportFailed: "無法從此剪輯產生 GIF", untitledClip: "無標題剪輯", recordingNotFound: "找不到錄製", noAccess: "您可能無權存取此剪輯。", @@ -911,6 +913,14 @@ const messages = { updateTranscriptSharingFailed: "無法更新逐字稿分享設定", }, deleteRecordingMenu: { + downloadAsGif: "下載為 GIF", + buildingGif: "正在產生 GIF…", + archive: "封存", + unarchive: "取消封存", + archived: "剪輯已封存", + restoredFromArchive: "已從封存恢復剪輯", + archiveFailed: "封存剪輯失敗", + unarchiveFailed: "恢復剪輯失敗", movedToTrash: "剪輯已移至廢紙簍", deleteFailed: "刪除剪輯失敗", clipOptions: "剪輯選項", diff --git a/templates/clips/app/lib/ffmpeg-export.ts b/templates/clips/app/lib/ffmpeg-export.ts index cdb9ad0331..2dfe364197 100644 --- a/templates/clips/app/lib/ffmpeg-export.ts +++ b/templates/clips/app/lib/ffmpeg-export.ts @@ -292,11 +292,18 @@ export async function exportMp4( * picker's "Animated GIF" tab. Keeps GIF small by scaling to 320px wide and * 15fps — more than enough for a library thumbnail. */ +export interface GifOptions { + /** Output width in pixels; height follows the source aspect ratio. */ + width?: number; + fps?: number; +} + export async function exportGif( recording: ExportRecording, startMs: number, durationMs: number, onProgress?: (p: ExportProgress) => void, + options?: GifOptions, ): Promise { if (!recording.videoUrl) throw new Error("Recording has no videoUrl"); @@ -325,7 +332,7 @@ export async function exportGif( "-i", inputName, "-vf", - "fps=15,scale=320:-2:flags=lanczos", + `fps=${options?.fps ?? 15},scale=${options?.width ?? 320}:-2:flags=lanczos`, "-loop", "0", outputName, diff --git a/templates/clips/app/routes/r.$recordingId.tsx b/templates/clips/app/routes/r.$recordingId.tsx index cb7cae3420..bb0e1b4f32 100644 --- a/templates/clips/app/routes/r.$recordingId.tsx +++ b/templates/clips/app/routes/r.$recordingId.tsx @@ -92,6 +92,7 @@ import { isDefaultTitle, useAutoTitleBridge } from "@/hooks/use-auto-title"; import { usePlayerShortcuts } from "@/hooks/use-player-shortcuts"; import { useViewTracking } from "@/hooks/use-view-tracking"; import enMessages from "@/i18n/en-US"; +import { exportGif } from "@/lib/ffmpeg-export"; import { parsePlaybackSpeed } from "@/lib/playback-speed"; import { recordingShareUrl } from "@/lib/recording-link"; import { isStorageSetupFailureReason } from "@/lib/storage-failures"; @@ -103,6 +104,11 @@ const UPLOAD_STUCK_TIMEOUT_MS = 5 * 60 * 1000; const PROCESSING_STUCK_TIMEOUT_MS = 12 * 60 * 1000; const READY_MEDIA_SETTLE_POLL_MS = 20 * 1000; const READY_MEDIA_SETTLE_POLL_INTERVAL_MS = 1000; +// GIF has no interframe compression, so a full-length clip at source +// resolution is orders of magnitude larger than the MP4 it came from. 480px / +// 12fps keeps a shareable GIF within a size a browser can actually encode. +const GIF_DOWNLOAD_WIDTH = 480; +const GIF_DOWNLOAD_FPS = 12; export function meta() { return [{ title: enMessages.recordingRoute.pageTitle }]; @@ -277,6 +283,7 @@ export default function RecordingPage() { const [processingTimeout, setProcessingTimeout] = useState(false); const [retryingFinalize, setRetryingFinalize] = useState(false); const [downloading, setDownloading] = useState(false); + const [exportingGif, setExportingGif] = useState(false); const [pendingLinkCopied, setPendingLinkCopied] = useState(false); const browserTabId = useMemo(() => getBrowserTabId(), []); const recordingScope = useMemo( @@ -520,6 +527,8 @@ export default function RecordingPage() { const canDownloadRecording = Boolean( recording?.enableDownloads && recording.videoUrl && !isLoomEmbedBacked, ); + const canDownloadGif = Boolean(canDownloadRecording && recording?.durationMs); + const canArchiveRecording = canEdit; const downloadRecording = useCallback(async () => { if (!recording?.videoUrl) return; setDownloading(true); @@ -547,6 +556,55 @@ export default function RecordingPage() { toast.dismiss(downloadToastId); } }, [recording?.title, recording?.videoFormat, recording?.videoUrl, t]); + const downloadGif = useCallback(async () => { + if (!recording?.videoUrl || !recording.durationMs) return; + setExportingGif(true); + const gifToastId = toast.loading( + t("recordingPage.buildingGif", { percent: 0 }), + ); + try { + const blob = await exportGif( + { + id: recording.id, + videoUrl: recording.videoUrl, + videoFormat: recording.videoFormat, + durationMs: recording.durationMs, + title: recording.title, + }, + 0, + recording.durationMs, + (p) => + toast.loading( + t("recordingPage.buildingGif", { + percent: Math.round(p.progress * 100), + }), + { id: gifToastId }, + ), + { width: GIF_DOWNLOAD_WIDTH, fps: GIF_DOWNLOAD_FPS }, + ); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${sanitizeFilename(recording.title || "clip")}.gif`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (err) { + console.error(err); + toast.error(t("recordingPage.gifExportFailed")); + } finally { + setExportingGif(false); + toast.dismiss(gifToastId); + } + }, [ + recording?.durationMs, + recording?.id, + recording?.title, + recording?.videoFormat, + recording?.videoUrl, + t, + ]); const retryFinalizeAfterStorage = useCallback(async () => { if (!recordingId) return; setRetryingFinalize(true); @@ -1557,7 +1615,7 @@ export default function RecordingPage() { ) : null} - {canDelete || canDownloadRecording ? ( + {canDelete || canDownloadRecording || canArchiveRecording ? ( { void downloadRecording(); }} + canDownloadGif={canDownloadGif} + gifPending={exportingGif} + onDownloadGif={() => { + void downloadGif(); + }} + canArchive={canArchiveRecording} + isArchived={Boolean(recording.archivedAt)} + onArchiveChanged={() => navigate("/library", { replace: true })} onDeleted={() => navigate("/library", { replace: true })} /> ) : null} diff --git a/templates/clips/changelog/2026-07-29-clip-menus-on-the-watch-page-now-include-archive-and-downloa.md b/templates/clips/changelog/2026-07-29-clip-menus-on-the-watch-page-now-include-archive-and-downloa.md new file mode 100644 index 0000000000..c810887e9f --- /dev/null +++ b/templates/clips/changelog/2026-07-29-clip-menus-on-the-watch-page-now-include-archive-and-downloa.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-07-29 +--- + +Clip menus on the watch page now include Archive and Download as GIF.