From 8810d2d2ef4af43a51a43d9e046d43d22433d8e4 Mon Sep 17 00:00:00 2001 From: Sivert Date: Fri, 2 Oct 2026 08:48:37 +0200 Subject: [PATCH 01/14] feat(client): crop and position card banners Co-Authored-By: OpenAI Codex --- package.json | 1 + scripts/check-banner-crop.mjs | 30 ++++ .../src/components/BannerCropDialog.tsx | 143 ++++++++++++++++++ .../settings/src/components/bannerCrop.ts | 46 ++++++ .../settings/src/components/cardSettings.tsx | 21 ++- 5 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 scripts/check-banner-crop.mjs create mode 100644 src/packages/settings/src/components/BannerCropDialog.tsx create mode 100644 src/packages/settings/src/components/bannerCrop.ts diff --git a/package.json b/package.json index dd82ad83..32011a84 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "test:chat-retry": "node --disable-warning=ExperimentalWarning scripts/check-chat-retry.mjs", "test:send-queue": "node --disable-warning=ExperimentalWarning scripts/check-send-queue.mjs", "test:member-card-state": "node scripts/check-member-card-state.mjs", + "test:banner-crop": "node scripts/check-banner-crop.mjs", "test:sidebar-tree": "node scripts/check-sidebar-tree.mjs", "test:hide-muted": "node --disable-warning=ExperimentalWarning scripts/check-hide-muted.mjs", "test:settings-index": "node scripts/check-settings-index.mjs", diff --git a/scripts/check-banner-crop.mjs b/scripts/check-banner-crop.mjs new file mode 100644 index 00000000..e8aaa123 --- /dev/null +++ b/scripts/check-banner-crop.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; + +import { bannerCropGeometry, imageBytesMayAnimate } from "../src/packages/settings/src/components/bannerCrop.ts"; + +const wide = bannerCropGeometry( + { width: 2000, height: 500 }, + { width: 1000, height: 400 }, + 1, + { x: 999, y: 999 }, +); +assert.deepEqual(wide.position, { x: 300, y: 0 }); +assert.deepEqual(wide.source, { x: 0, y: 0, width: 1250, height: 500 }); + +const zoomed = bannerCropGeometry( + { width: 1000, height: 400 }, + { width: 1000, height: 400 }, + 2, + { x: -5000, y: 5000 }, +); +assert.deepEqual(zoomed.position, { x: -500, y: 200 }); +assert.deepEqual(zoomed.source, { x: 500, y: 0, width: 500, height: 200 }); + +const bytes = (value) => new TextEncoder().encode(value); +assert.equal(imageBytesMayAnimate("image/gif", bytes("GIF89a")), true); +assert.equal(imageBytesMayAnimate("image/webp", bytes("RIFFxxxxWEBPANIM")), true); +assert.equal(imageBytesMayAnimate("image/webp", bytes("RIFFxxxxWEBPVP8 ")), false); +assert.equal(imageBytesMayAnimate("image/png", bytes("PNG acTL")), true); +assert.equal(imageBytesMayAnimate("image/jpeg", bytes("jpeg")), false); + +console.log("banner crop: ok"); diff --git a/src/packages/settings/src/components/BannerCropDialog.tsx b/src/packages/settings/src/components/BannerCropDialog.tsx new file mode 100644 index 00000000..13908593 --- /dev/null +++ b/src/packages/settings/src/components/BannerCropDialog.tsx @@ -0,0 +1,143 @@ +import { Button, Dialog, Slider } from "@gryt/ui"; +import { useEffect, useRef, useState } from "react"; + +import { bannerCropGeometry, type CropPoint, type CropSize } from "./bannerCrop"; + +const OUTPUT_WIDTH = 960; +const OUTPUT_HEIGHT = 384; + +export function BannerCropDialog({ + file, + onCancel, + onUse, +}: { + file: File | null; + onCancel: () => void; + onUse: (file: File) => void; +}) { + const viewportRef = useRef(null); + const imageRef = useRef(null); + const dragRef = useRef<{ pointer: number; at: CropPoint; start: CropPoint } | null>(null); + const [url, setUrl] = useState(""); + const [image, setImage] = useState({ width: 0, height: 0 }); + const [viewport, setViewport] = useState({ width: 0, height: 0 }); + const [zoom, setZoom] = useState(1); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!file) return; + const next = URL.createObjectURL(file); + setUrl(next); + setImage({ width: 0, height: 0 }); + setZoom(1); + setOffset({ x: 0, y: 0 }); + return () => URL.revokeObjectURL(next); + }, [file]); + + useEffect(() => { + const node = viewportRef.current; + if (!node) return; + const observer = new ResizeObserver(([entry]) => { + setViewport({ width: entry.contentRect.width, height: entry.contentRect.height }); + }); + observer.observe(node); + return () => observer.disconnect(); + }, [file]); + + const crop = bannerCropGeometry(image, viewport, zoom, offset); + + const applyCrop = async () => { + const element = imageRef.current; + if (!file || !element || !image.width || !viewport.width) return; + setBusy(true); + const canvas = document.createElement("canvas"); + canvas.width = OUTPUT_WIDTH; + canvas.height = OUTPUT_HEIGHT; + const context = canvas.getContext("2d"); + if (!context) { + setBusy(false); + return; + } + context.drawImage( + element, + crop.source.x, + crop.source.y, + crop.source.width, + crop.source.height, + 0, + 0, + OUTPUT_WIDTH, + OUTPUT_HEIGHT, + ); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/webp", 0.9)); + setBusy(false); + if (!blob) return; + onUse(new File([blob], file.name.replace(/\.[^.]+$/, "") + ".webp", { type: blob.type })); + }; + + return ( + { if (!open) onCancel(); }}> + + + +
+
+ Position banner + Drag to move. Scroll or use the slider to zoom. +
+
{ + event.preventDefault(); + setZoom((value) => Math.max(1, Math.min(3, value - event.deltaY * 0.0015))); + }} + onPointerDown={(event) => { + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { pointer: event.pointerId, at: { x: event.clientX, y: event.clientY }, start: crop.position }; + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointer !== event.pointerId) return; + setOffset({ + x: Math.max(-crop.limits.x, Math.min(crop.limits.x, drag.start.x + event.clientX - drag.at.x)), + y: Math.max(-crop.limits.y, Math.min(crop.limits.y, drag.start.y + event.clientY - drag.at.y)), + }); + }} + onPointerUp={(event) => { + if (dragRef.current?.pointer === event.pointerId) dragRef.current = null; + }} + > + {url && ( + Banner crop preview setImage({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })} + /> + )} +
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/src/packages/settings/src/components/bannerCrop.ts b/src/packages/settings/src/components/bannerCrop.ts new file mode 100644 index 00000000..077cf995 --- /dev/null +++ b/src/packages/settings/src/components/bannerCrop.ts @@ -0,0 +1,46 @@ +export interface CropSize { width: number; height: number } +export interface CropPoint { x: number; y: number } + +const clamp = (value: number, limit: number) => Math.max(-limit, Math.min(limit, value)); + +export function bannerCropGeometry(image: CropSize, viewport: CropSize, zoom: number, offset: CropPoint) { + const cover = image.width && viewport.width + ? Math.max(viewport.width / image.width, viewport.height / image.height) + : 1; + const scale = cover * zoom; + const shown = { width: image.width * scale, height: image.height * scale }; + const limits = { + x: Math.max(0, (shown.width - viewport.width) / 2), + y: Math.max(0, (shown.height - viewport.height) / 2), + }; + const position = { x: clamp(offset.x, limits.x), y: clamp(offset.y, limits.y) }; + return { + scale, + shown, + limits, + position, + source: { + x: (shown.width / 2 - viewport.width / 2 - position.x) / scale, + y: (shown.height / 2 - viewport.height / 2 - position.y) / scale, + width: viewport.width / scale, + height: viewport.height / scale, + }, + }; +} + +const marker = (bytes: Uint8Array, value: string) => { + const wanted = [...value].map((character) => character.charCodeAt(0)); + return bytes.some((_, index) => wanted.every((byte, offset) => bytes[index + offset] === byte)); +}; + +export function imageBytesMayAnimate(type: string, bytes: Uint8Array): boolean { + if (type === "image/gif") return true; + if (type === "image/webp") return marker(bytes, "ANIM") || marker(bytes, "ANMF"); + if (type === "image/png") return marker(bytes, "acTL"); + return false; +} + +export async function imageMayAnimate(file: File): Promise { + const bytes = new Uint8Array(await file.slice(0, 1024 * 1024).arrayBuffer()); + return imageBytesMayAnimate(file.type, bytes); +} diff --git a/src/packages/settings/src/components/cardSettings.tsx b/src/packages/settings/src/components/cardSettings.tsx index 1a40ddff..950ee3ce 100644 --- a/src/packages/settings/src/components/cardSettings.tsx +++ b/src/packages/settings/src/components/cardSettings.tsx @@ -39,6 +39,8 @@ import { PRONOUNS_MAX, STATUS_LINE_MAX, } from "../../../socket/src/lib/memberCard/cardStyle"; +import { imageMayAnimate } from "./bannerCrop"; +import { BannerCropDialog } from "./BannerCropDialog"; import { SettingGroup, SettingsContainer } from "./settingsComponents"; /** A game for the preview when you are not playing one, so the band can be seen. */ @@ -163,6 +165,7 @@ export function CardSettings() { const [pendingBanner, setPendingBanner] = useState(); const [editing, setEditing] = useState(false); const [bannerBusy, setBannerBusy] = useState(false); + const [bannerToCrop, setBannerToCrop] = useState(null); const bannerInput = useRef(null); const serverBanner = useMemo(() => { for (const host of connected) { @@ -180,6 +183,11 @@ export function CardSettings() { setPendingBanner(file); }; + const chooseBanner = async (file: File) => { + if (await imageMayAnimate(file)) changeBanner(file); + else setBannerToCrop(file); + }; + const save = async () => { setBannerBusy(true); const results = pendingBanner === undefined @@ -278,16 +286,25 @@ export function CardSettings() { { const file = e.target.files?.[0]; e.target.value = ""; - if (file) changeBanner(file); + if (!file) return; + void chooseBanner(file); }} /> )} + setBannerToCrop(null)} + onUse={(file) => { + setBannerToCrop(null); + changeBanner(file); + }} + /> ); From 5809b63eee913ff911b4ba662ee48b46c8829ce6 Mon Sep 17 00:00:00 2001 From: Sivert Date: Fri, 2 Oct 2026 08:53:39 +0200 Subject: [PATCH 02/14] fix(client): wrap banner crop actions Co-Authored-By: OpenAI Codex --- src/packages/settings/src/components/BannerCropDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packages/settings/src/components/BannerCropDialog.tsx b/src/packages/settings/src/components/BannerCropDialog.tsx index 13908593..1cc07b09 100644 --- a/src/packages/settings/src/components/BannerCropDialog.tsx +++ b/src/packages/settings/src/components/BannerCropDialog.tsx @@ -131,7 +131,7 @@ export function BannerCropDialog({ setZoom(Number(value) / 100)} /> {Math.round(zoom * 100)}% -
+
From bc20d15bac9873a4a3568520f756c2f30a8ff092 Mon Sep 17 00:00:00 2001 From: Sivert Date: Fri, 2 Oct 2026 08:59:12 +0200 Subject: [PATCH 03/14] feat(client): keep banners with card presets Co-Authored-By: OpenAI Codex --- package.json | 1 + scripts/check-card-banner-store.mjs | 43 ++++++++++ .../settings/src/components/cardSettings.tsx | 32 +++++-- .../src/lib/memberCard/cardBannerStore.ts | 86 +++++++++++++++++++ .../socket/src/lib/memberCard/cardStore.ts | 14 +-- 5 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 scripts/check-card-banner-store.mjs create mode 100644 src/packages/socket/src/lib/memberCard/cardBannerStore.ts diff --git a/package.json b/package.json index 32011a84..69f962ac 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "test:send-queue": "node --disable-warning=ExperimentalWarning scripts/check-send-queue.mjs", "test:member-card-state": "node scripts/check-member-card-state.mjs", "test:banner-crop": "node scripts/check-banner-crop.mjs", + "test:card-banner-store": "node scripts/check-card-banner-store.mjs", "test:sidebar-tree": "node scripts/check-sidebar-tree.mjs", "test:hide-muted": "node --disable-warning=ExperimentalWarning scripts/check-hide-muted.mjs", "test:settings-index": "node scripts/check-settings-index.mjs", diff --git a/scripts/check-card-banner-store.mjs b/scripts/check-card-banner-store.mjs new file mode 100644 index 00000000..db22cd3b --- /dev/null +++ b/scripts/check-card-banner-store.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { File } from "node:buffer"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { build } from "esbuild"; +import { indexedDB, IDBKeyRange } from "fake-indexeddb"; + +globalThis.File = File; +globalThis.indexedDB = indexedDB; +globalThis.IDBKeyRange = IDBKeyRange; + +const here = dirname(fileURLToPath(import.meta.url)); +const built = await build({ + entryPoints: [join(here, "../src/packages/socket/src/lib/memberCard/cardBannerStore.ts")], + bundle: true, + format: "esm", + platform: "browser", + write: false, +}); +const code = Buffer.from(built.outputFiles[0].contents).toString("base64"); +const store = await import(`data:text/javascript;base64,${code}`); + +const red = { fill: "solid", c1: "#ff0000", pattern: "dots" }; +const blue = { fill: "solid", c1: "#0000ff", pattern: "weave" }; +const file = new File([new Uint8Array([1, 2, 3])], "banner.webp", { type: "image/webp" }); + +assert.deepEqual(await store.getCardBanner(red), { found: false, file: null }); +await store.setCardBanner(red, file); +const saved = await store.getCardBanner(red); +assert.equal(saved.found, true); +assert.equal(saved.file.name, "banner.webp"); +assert.equal(saved.file.type, "image/webp"); +assert.deepEqual([...new Uint8Array(await saved.file.arrayBuffer())], [1, 2, 3]); + +await store.setCardBanner(blue, null); +assert.deepEqual(await store.getCardBanner(blue), { found: true, file: null }); +await store.pruneCardBanners([blue]); +assert.deepEqual(await store.getCardBanner(red), { found: false, file: null }); +await store.deleteCardBanner(blue); +assert.deepEqual(await store.getCardBanner(blue), { found: false, file: null }); + +console.log("card banner store: ok"); diff --git a/src/packages/settings/src/components/cardSettings.tsx b/src/packages/settings/src/components/cardSettings.tsx index 950ee3ce..a7c40baf 100644 --- a/src/packages/settings/src/components/cardSettings.tsx +++ b/src/packages/settings/src/components/cardSettings.tsx @@ -21,6 +21,7 @@ import type { RichActivity } from "../../../../lib/richActivity"; import { takeSharedLook, useSharedLook } from "../../../../lib/sharedLook"; import { useCardEmojiGroups } from "../../../socket/src/components/memberCard/cardEmojiGroups"; import { MemberCardView } from "../../../socket/src/components/memberCard/MemberCardView"; +import { deleteCardBanner, getCardBanner, pruneCardBanners, setCardBanner } from "../../../socket/src/lib/memberCard/cardBannerStore"; import { cardUpdatePayload, EMPTY_CARD, @@ -109,9 +110,20 @@ export function CardSettings() { const commit = (next: CardProfile) => setDraft(next); const cardDirty = JSON.stringify(cardUpdatePayload(draft)) !== JSON.stringify(cardUpdatePayload(saved)); - const saveCard = () => { + const saveCard = (bannerChange: File | null | undefined) => { setStoredCard(draft); - rememberCardStyle(draft.cardStyle); + const nextHistory = rememberCardStyle(draft.cardStyle); + void (async () => { + if (bannerChange !== undefined) await setCardBanner(draft.cardStyle, bannerChange); + else if (bannerUrl) { + const response = await fetch(bannerUrl); + if (response.ok) { + const blob = await response.blob(); + await setCardBanner(draft.cardStyle, new File([blob], "banner", { type: blob.type })); + } + } else await setCardBanner(draft.cardStyle, null); + await pruneCardBanners(nextHistory); + })().catch(() => {}); setRefusals({}); const payload = cardUpdatePayload(draft); for (const host of Object.keys(sockets)) { @@ -203,7 +215,7 @@ export function CardSettings() { setPendingBanner(undefined); for (const h of bannerHosts) sockets[h]?.emit("avatar:updated"); } - saveCard(); + saveCard(pendingBanner); toast.success(failed.length ? `Card saved, but ${failed.length} server${failed.length > 1 ? "s" : ""} refused the banner` : "Card saved"); }; @@ -250,6 +262,13 @@ export function CardSettings() { const refused = Object.entries(refusals); + const showPastCard = async (past: CardProfile["cardStyle"]) => { + setDraft({ ...draft, cardStyle: past }); + const stored = await getCardBanner(past).catch(() => null); + if (!stored?.found) return; + changeBanner(stored.file); + }; + const about = ( <> @@ -397,14 +416,17 @@ export function CardSettings() {
- setEditing(open)}> + { if (!open) requestCancel(); }}> {/* Laid out like the owl designer: panes on the left, the card on the right, nothing long to scroll. */} @@ -364,17 +404,14 @@ export function CardSettings() { Paste link - }>Close +
@@ -451,6 +488,16 @@ export function CardSettings() { + ); } diff --git a/src/packages/socket/src/components/MemberIdentityCard.tsx b/src/packages/socket/src/components/MemberIdentityCard.tsx index 8ae0fb5f..b6438697 100644 --- a/src/packages/socket/src/components/MemberIdentityCard.tsx +++ b/src/packages/socket/src/components/MemberIdentityCard.tsx @@ -16,6 +16,7 @@ import { import { useSettings } from "@/settings"; import { openLightbox } from "../../../../lib/lightboxStore"; +import { offerSharedLook } from "../../../../lib/sharedLook"; import { confirmServerFriend, friendAction, useAllFriends, useFriendState } from "../hooks/friendsStore"; import { useServerPermissions } from "../hooks/usePermissions"; import { useSockets } from "../hooks/useSockets"; @@ -147,7 +148,7 @@ export function MemberIdentityCard({ member.serverUserId === (actions.currentServerUserId ?? getOwnServerUserId(serverHost)); // Only a designed owl can be copied; copying somebody's photograph would be impersonation. - const worn = member.avatarWorn; + const worn = member.avatarFileId ? null : member.avatarWorn; const owlHex = generatedAvatarColor(member.nickname, member.avatarWorn) ?? member.avatarColor ?? FALLBACK_OWL; const channelName = voiceChannelName ?? @@ -207,9 +208,15 @@ export function MemberIdentityCard({ : []), { key: "style", - label: "Copy card style", + label: "Copy card", divider: true, - onSelect: () => copy(encodeCardStyle(profile.cardStyle), "Card style copied. Paste it in Edit my card to use it."), + onSelect: () => { + offerSharedLook({ + card: encodeCardStyle(profile.cardStyle), + cardBanner: member.bannerFileId && serverHost ? getUploadsFileUrl(serverHost, member.bannerFileId) : null, + }); + openSettings("profile/card/edit"); + }, }, ]; @@ -290,14 +297,20 @@ export function MemberIdentityCard({ )} {worn && ( - + )} {isSelf && ( } - onClick={() => openSettings("profile/card")} + onClick={() => openSettings("profile/card/edit")} > From 90ce0fdd0cf8fecda2b659fbd8f84d8b360bce33 Mon Sep 17 00:00:00 2001 From: Sivert Date: Fri, 2 Oct 2026 14:33:54 +0200 Subject: [PATCH 06/14] feat(client): preview MP4 card banners Co-Authored-By: OpenAI Codex --- scripts/check-card-editor-flow.mjs | 5 +++ .../settings/src/components/cardSettings.tsx | 25 +++++++++-- .../components/memberCard/MemberCardView.tsx | 42 +++++++++++++++++-- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/scripts/check-card-editor-flow.mjs b/scripts/check-card-editor-flow.mjs index 5c2f1711..628c08fc 100644 --- a/scripts/check-card-editor-flow.mjs +++ b/scripts/check-card-editor-flow.mjs @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; const settings = readFileSync("src/packages/settings/src/components/cardSettings.tsx", "utf8"); const card = readFileSync("src/packages/socket/src/components/MemberIdentityCard.tsx", "utf8"); const shared = readFileSync("src/lib/sharedLook.ts", "utf8"); +const view = readFileSync("src/packages/socket/src/components/memberCard/MemberCardView.tsx", "utf8"); assert.match(card, /openSettings\("profile\/card\/edit"\)/); assert.match(card, /cardBanner: member\.bannerFileId/); @@ -13,5 +14,9 @@ assert.match(settings, /confirmLabel="Discard changes"/); assert.match(settings, /const discardChanges = \(\) => \{[\s\S]*setDraft\(saved\);[\s\S]*setBannerPreview\(undefined\);[\s\S]*setPendingBanner\(undefined\);/); assert.match(settings, /takeSharedLook\("cardBanner"\)/); assert.match(shared, /cardBanner\?: string \| null/); +assert.match(settings, /accept="[^"]*video\/mp4/); +assert.match(settings, /server_info\?\.upload_max_bytes/); +assert.match(view, /fetch\(url, \{ method: "HEAD"/); +assert.match(view, /bannerType/); console.log("card editor flow: ok"); diff --git a/src/packages/settings/src/components/cardSettings.tsx b/src/packages/settings/src/components/cardSettings.tsx index 5a6f1294..5f2c6e80 100644 --- a/src/packages/settings/src/components/cardSettings.tsx +++ b/src/packages/settings/src/components/cardSettings.tsx @@ -170,6 +170,7 @@ export function CardSettings() { }; const bannerHosts = connected.filter(mayUpload); const [bannerPreview, setBannerPreview] = useState(); + const [bannerMime, setBannerMime] = useState(); const [pendingBanner, setPendingBanner] = useState(); const [editing, setEditing] = useState(settingsTab === "profile/card/edit"); const [confirmCancel, setConfirmCancel] = useState(false); @@ -189,6 +190,7 @@ export function CardSettings() { const changeBanner = (file: File | null) => { if (bannerPreview?.startsWith("blob:")) URL.revokeObjectURL(bannerPreview); setBannerPreview(file ? URL.createObjectURL(file) : null); + setBannerMime(file?.type || undefined); setPendingBanner(file); }; @@ -220,6 +222,7 @@ export function CardSettings() { setDraft(saved); if (bannerPreview?.startsWith("blob:")) URL.revokeObjectURL(bannerPreview); setBannerPreview(undefined); + setBannerMime(undefined); setPendingBanner(undefined); }; @@ -236,7 +239,20 @@ export function CardSettings() { }; const chooseBanner = async (file: File) => { - if (await imageMayAnimate(file)) changeBanner(file); + const type = file.type.toLowerCase(); + if (!type.startsWith("image/") && type !== "video/mp4") { + toast.error("Choose an image or MP4 video."); + return; + } + const limits = bannerHosts + .map((host) => serverDetailsList[host]?.server_info?.upload_max_bytes) + .filter((value): value is number => typeof value === "number" && value > 0); + const maxBytes = limits.length ? Math.min(...limits) : null; + if (maxBytes && file.size > maxBytes) { + toast.error(`Banner is too large. These servers allow up to ${(maxBytes / (1024 * 1024)).toFixed(1)}MB.`); + return; + } + if (type === "video/mp4" || await imageMayAnimate(file)) changeBanner(file); else setBannerToCrop(file); }; @@ -276,6 +292,7 @@ export function CardSettings() { profile={draft} owlHex={owlHex} bannerUrl={bannerUrl} + bannerMime={bannerMime} game={playing ? gameCard ?? SAMPLE_GAME : null} appearance={appearance} seedKey={seedKey} @@ -328,8 +345,8 @@ export function CardSettings() { title="Banner" description={ bannerHosts.length === connected.length - ? "A picture across the top of your card. Everywhere else your pattern shows instead." - : `A picture across the top of your card, on the ${bannerHosts.length} of your servers that let you upload. The others show your pattern.` + ? "An image or MP4 video across the top of your card. Everywhere else your pattern shows instead." + : `An image or MP4 video across the top of your card, on the ${bannerHosts.length} of your servers that let you upload. The others show your pattern.` } >
@@ -345,7 +362,7 @@ export function CardSettings() { { const file = e.target.files?.[0]; diff --git a/src/packages/socket/src/components/memberCard/MemberCardView.tsx b/src/packages/socket/src/components/memberCard/MemberCardView.tsx index cda0d473..af62b749 100644 --- a/src/packages/socket/src/components/memberCard/MemberCardView.tsx +++ b/src/packages/socket/src/components/memberCard/MemberCardView.tsx @@ -1,4 +1,5 @@ import { MemberCard, type MemberCardProps, setCardIconLoader } from "@gryt/ui"; +import { useEffect, useState } from "react"; import { useServerManagement } from "../../hooks/useServerManagement"; import { BotTag } from "../BotTag"; @@ -9,11 +10,46 @@ setCardIconLoader(() => import("./phosphorIcons")); export type { CardChip } from "@gryt/ui"; -export type MemberCardViewProps = Omit & { isBot?: boolean }; +export type MemberCardViewProps = Omit & { + isBot?: boolean; + /** Known for a local file; server files are detected from their response headers. */ + bannerMime?: string | null; +}; + +type BannerType = "image" | "video"; +const bannerTypes = new Map(); +const typeFromMime = (mime?: string | null): BannerType => mime?.toLowerCase().startsWith("video/") ? "video" : "image"; + +function useBannerType(url?: string | null, mime?: string | null): BannerType { + const explicit = mime ? typeFromMime(mime) : null; + const [type, setType] = useState(() => explicit ?? (url ? bannerTypes.get(url) : undefined) ?? "image"); + + useEffect(() => { + if (!url) { setType("image"); return; } + if (explicit) { setType(explicit); return; } + const cached = bannerTypes.get(url); + if (cached) { setType(cached); return; } + setType("image"); + const controller = new AbortController(); + void fetch(url, { method: "HEAD", signal: controller.signal }) + .then((response) => { + if (!response.ok) return; + const found = typeFromMime(response.headers.get("content-type")); + bannerTypes.set(url, found); + setType(found); + }) + .catch(() => {}); + return () => controller.abort(); + }, [url, explicit]); + + return type; +} /** The card from @gryt/ui (GRYT-1640), with the app's bot tag beside a bot's name. */ -export function MemberCardView({ isBot, ...props }: MemberCardViewProps) { +export function MemberCardView({ isBot, bannerMime, ...props }: MemberCardViewProps) { const { currentlyViewingServer } = useServerManagement(); const emojiGroups = useCardEmojiGroups(currentlyViewingServer?.host); - return : undefined} />; + const bannerType = useBannerType(props.bannerUrl, bannerMime); + const memberCardProps = { ...props, bannerType }; + return : undefined} />; } From 11d8a2af3911b0cef03b54e0658e0eefea4c21b3 Mon Sep 17 00:00:00 2001 From: Sivert Date: Fri, 2 Oct 2026 20:01:12 +0200 Subject: [PATCH 07/14] fix(client): wait for banner validation and restore cancelled edits Co-Authored-By: OpenAI Codex --- scripts/check-card-editor-flow.mjs | 2 +- scripts/check-role-pills.mjs | 3 +- src/lib/sharedLook.ts | 2 +- .../settings/src/components/cardSettings.tsx | 37 ++++++++++++++++--- .../src/components/MemberIdentityCard.tsx | 4 +- .../components/memberCard/MemberCardView.tsx | 24 +++++++----- 6 files changed, 52 insertions(+), 20 deletions(-) diff --git a/scripts/check-card-editor-flow.mjs b/scripts/check-card-editor-flow.mjs index 628c08fc..0b8f4574 100644 --- a/scripts/check-card-editor-flow.mjs +++ b/scripts/check-card-editor-flow.mjs @@ -8,7 +8,7 @@ const view = readFileSync("src/packages/socket/src/components/memberCard/MemberC assert.match(card, /openSettings\("profile\/card\/edit"\)/); assert.match(card, /cardBanner: member\.bannerFileId/); -assert.match(card, /member\.avatarFileId \? null : member\.avatarWorn/); +assert.match(card, /const worn = member\.avatarWorn/); assert.match(settings, /title="Discard card changes\?"/); assert.match(settings, /confirmLabel="Discard changes"/); assert.match(settings, /const discardChanges = \(\) => \{[\s\S]*setDraft\(saved\);[\s\S]*setBannerPreview\(undefined\);[\s\S]*setPendingBanner\(undefined\);/); diff --git a/scripts/check-role-pills.mjs b/scripts/check-role-pills.mjs index 06dc9f02..25d1650a 100644 --- a/scripts/check-role-pills.mjs +++ b/scripts/check-role-pills.mjs @@ -98,6 +98,7 @@ const VIEW = "src/packages/socket/src/components/memberCard/MemberCardView.tsx"; const view = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", VIEW), "utf8"); assert.match(card, /const chips = \[\s*\.\.\.rolePills,/, `${SOURCE} no longer hands rolePills to the card as chips`); assert.match(card, /chips=\{chips\}/, `${SOURCE} no longer passes the chips to MemberCardView`); -assert.match(view, / void>(); const emit = () => listeners.forEach((fn) => fn()); export function offerSharedLook(look: SharedLook): void { - pending = { ...pending, ...look }; + pending = { ...pending, ...(look.card !== undefined ? { cardBanner: undefined } : {}), ...look }; emit(); } diff --git a/src/packages/settings/src/components/cardSettings.tsx b/src/packages/settings/src/components/cardSettings.tsx index 5f2c6e80..0a0ce5b7 100644 --- a/src/packages/settings/src/components/cardSettings.tsx +++ b/src/packages/settings/src/components/cardSettings.tsx @@ -69,6 +69,18 @@ async function sendBanner(host: string, file: File | null): Promise { }); if (r.status === 404) throw new Error("this server can't take a banner yet"); if (!r.ok) throw new Error((await r.text().catch(() => "")) || `HTTP ${r.status}`); + if (!file) return; + const result = await r.json() as { bannerFileId?: string; processing?: boolean }; + if (!result.processing || !result.bannerFileId) return; + const url = getUploadsFileUrl(host, result.bannerFileId); + if (!url) throw new Error("Cannot check banner processing on this server"); + for (let attempt = 0; attempt < 90; attempt++) { + const ready = await fetch(url, { method: "HEAD", cache: "no-store" }); + if (ready.ok) return; + if (ready.status !== 503) throw new Error("Server rejected the banner during media checks"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error("Banner is still waiting for this server's image worker. Try again later."); } export function CardSettings() { @@ -175,6 +187,8 @@ export function CardSettings() { const [editing, setEditing] = useState(settingsTab === "profile/card/edit"); const [confirmCancel, setConfirmCancel] = useState(false); const [bannerBusy, setBannerBusy] = useState(false); + const [bannerDownloading, setBannerDownloading] = useState(false); + const bannerRevision = useRef(0); const [bannerToCrop, setBannerToCrop] = useState(null); const bannerInput = useRef(null); const serverBanner = useMemo(() => { @@ -188,6 +202,8 @@ export function CardSettings() { const dirty = cardDirty || pendingBanner !== undefined; const changeBanner = (file: File | null) => { + bannerRevision.current++; + setBannerDownloading(false); if (bannerPreview?.startsWith("blob:")) URL.revokeObjectURL(bannerPreview); setBannerPreview(file ? URL.createObjectURL(file) : null); setBannerMime(file?.type || undefined); @@ -204,13 +220,19 @@ export function CardSettings() { } if (remoteBanner === null) changeBanner(null); else if (remoteBanner) { + const revision = ++bannerRevision.current; + setBannerDownloading(true); void fetch(remoteBanner) .then(async (response) => { if (!response.ok) throw new Error(String(response.status)); const blob = await response.blob(); - changeBanner(new File([blob], "copied-banner", { type: blob.type })); + if (revision === bannerRevision.current) changeBanner(new File([blob], "copied-banner", { type: blob.type })); }) - .catch(() => toast.error("The card was copied, but its banner could not be downloaded.")); + .catch(() => { + if (revision !== bannerRevision.current) return; + setBannerDownloading(false); + toast.error("The card was copied, but its banner could not be downloaded."); + }); } }, [shared]); // eslint-disable-line react-hooks/exhaustive-deps @@ -219,6 +241,8 @@ export function CardSettings() { }, [settingsTab]); const discardChanges = () => { + bannerRevision.current++; + setBannerDownloading(false); setDraft(saved); if (bannerPreview?.startsWith("blob:")) URL.revokeObjectURL(bannerPreview); setBannerPreview(undefined); @@ -234,7 +258,8 @@ export function CardSettings() { }; const requestCancel = () => { - if (dirty) setConfirmCancel(true); + if (bannerBusy) return; + if (dirty || bannerDownloading) setConfirmCancel(true); else closeEditor(); }; @@ -420,15 +445,15 @@ export function CardSettings() { - - - +
diff --git a/src/packages/socket/src/components/MemberIdentityCard.tsx b/src/packages/socket/src/components/MemberIdentityCard.tsx index b6438697..b45af91f 100644 --- a/src/packages/socket/src/components/MemberIdentityCard.tsx +++ b/src/packages/socket/src/components/MemberIdentityCard.tsx @@ -148,7 +148,7 @@ export function MemberIdentityCard({ member.serverUserId === (actions.currentServerUserId ?? getOwnServerUserId(serverHost)); // Only a designed owl can be copied; copying somebody's photograph would be impersonation. - const worn = member.avatarFileId ? null : member.avatarWorn; + const worn = member.avatarWorn; const owlHex = generatedAvatarColor(member.nickname, member.avatarWorn) ?? member.avatarColor ?? FALLBACK_OWL; const channelName = voiceChannelName ?? @@ -212,7 +212,7 @@ export function MemberIdentityCard({ divider: true, onSelect: () => { offerSharedLook({ - card: encodeCardStyle(profile.cardStyle), + card: encodeCardStyle(profile.cardStyle) || "card=b4b", cardBanner: member.bannerFileId && serverHost ? getUploadsFileUrl(serverHost, member.bannerFileId) : null, }); openSettings("profile/card/edit"); diff --git a/src/packages/socket/src/components/memberCard/MemberCardView.tsx b/src/packages/socket/src/components/memberCard/MemberCardView.tsx index af62b749..aacdec41 100644 --- a/src/packages/socket/src/components/memberCard/MemberCardView.tsx +++ b/src/packages/socket/src/components/memberCard/MemberCardView.tsx @@ -20,25 +20,31 @@ type BannerType = "image" | "video"; const bannerTypes = new Map(); const typeFromMime = (mime?: string | null): BannerType => mime?.toLowerCase().startsWith("video/") ? "video" : "image"; -function useBannerType(url?: string | null, mime?: string | null): BannerType { +function useBannerType(url?: string | null, mime?: string | null): BannerType | null { const explicit = mime ? typeFromMime(mime) : null; - const [type, setType] = useState(() => explicit ?? (url ? bannerTypes.get(url) : undefined) ?? "image"); + const [type, setType] = useState(() => explicit ?? (url ? bannerTypes.get(url) : undefined) ?? null); useEffect(() => { if (!url) { setType("image"); return; } if (explicit) { setType(explicit); return; } const cached = bannerTypes.get(url); if (cached) { setType(cached); return; } - setType("image"); + setType(null); const controller = new AbortController(); - void fetch(url, { method: "HEAD", signal: controller.signal }) - .then((response) => { - if (!response.ok) return; + void (async () => { + for (let attempt = 0; attempt < 90 && !controller.signal.aborted; attempt++) { + const response = await fetch(url, { method: "HEAD", signal: controller.signal }); + if (response.status === 503) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue; + } + if (!response.ok || controller.signal.aborted) return; const found = typeFromMime(response.headers.get("content-type")); bannerTypes.set(url, found); setType(found); - }) - .catch(() => {}); + return; + } + })().catch(() => {}); return () => controller.abort(); }, [url, explicit]); @@ -50,6 +56,6 @@ export function MemberCardView({ isBot, bannerMime, ...props }: MemberCardViewPr const { currentlyViewingServer } = useServerManagement(); const emojiGroups = useCardEmojiGroups(currentlyViewingServer?.host); const bannerType = useBannerType(props.bannerUrl, bannerMime); - const memberCardProps = { ...props, bannerType }; + const memberCardProps = { ...props, bannerType: bannerType ?? "image", bannerUrl: bannerType ? props.bannerUrl : null }; return : undefined} />; } From b6cdb0b89b1148d525f284aa0965b05d31e01e99 Mon Sep 17 00:00:00 2001 From: Sivert Date: Fri, 2 Oct 2026 20:19:23 +0200 Subject: [PATCH 08/14] fix(client): share media approval waits and repair banner crop mounting Co-Authored-By: OpenAI Codex --- .github/workflows/ci.yml | 5 +++ e2e/tests/card-editor.spec.ts | 37 +++++++++++++++++++ package.json | 3 +- scripts/check-media-processing.mjs | 26 +++++++++++++ .../src/components/BannerCropDialog.tsx | 17 +++++---- .../settings/src/components/cardSettings.tsx | 11 +----- .../socket/src/hooks/uploadChatFile.ts | 4 +- src/packages/socket/src/utils/waitForMedia.ts | 11 ++++++ yarn.lock | 8 ++-- 9 files changed, 100 insertions(+), 22 deletions(-) create mode 100644 e2e/tests/card-editor.spec.ts create mode 100644 scripts/check-media-processing.mjs create mode 100644 src/packages/socket/src/utils/waitForMedia.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5067be3b..1aeeb418 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,11 @@ jobs: # A member changing group moves their row to another section, which remounts # it. The open hover card has to survive that. - run: yarn test:member-card-state + - run: yarn test:banner-crop + - run: yarn test:card-banner-store + - run: yarn test:card-emoji-groups + - run: yarn test:card-editor-flow + - run: yarn test:media-processing - run: yarn test:sidebar-tree - run: yarn test:hide-muted - run: yarn test:settings-index diff --git a/e2e/tests/card-editor.spec.ts b/e2e/tests/card-editor.spec.ts new file mode 100644 index 00000000..a2469876 --- /dev/null +++ b/e2e/tests/card-editor.spec.ts @@ -0,0 +1,37 @@ +import { fixture, membersPanel } from "../support/app"; +import { expect, test } from "../support/fixtures"; + +test("member card opens its editor directly and Cancel restores the saved preview without uploading", async ({ owner }) => { + const page = owner.page; + const uploads: string[] = []; + page.on("request", (request) => { + if (request.method() === "POST" && request.url().includes("/api/uploads/banner")) uploads.push(request.url()); + }); + await membersPanel(page).locator(".member-row").filter({ hasText: owner.name }).click(); + await page.getByRole("button", { name: "Edit my card", exact: true }).click(); + const editor = page.getByRole("dialog", { name: "Edit my card", exact: true }); + await expect(editor).toBeVisible(); + const settings = page.getByRole("dialog", { name: "Settings", exact: true }); + const savedStyle = await editor.locator(".gmc-frame").getAttribute("style"); + await editor.getByRole("button", { name: "Surprise me" }).click(); + await expect(editor.locator(".gmc-frame")).not.toHaveAttribute("style", savedStyle ?? ""); + await editor.getByRole("tab", { name: "About you" }).click(); + await editor.locator('input[type="file"]').setInputFiles(fixture("gradient.png")); + const crop = page.getByRole("dialog", { name: "Position banner" }); + await expect(crop).toBeVisible(); + await crop.getByRole("button", { name: "Use crop" }).click(); + await expect(crop).toBeHidden(); + await expect(editor.locator(".gmc-frame")).toHaveAttribute("style", /--img:\s*url\(["']?blob:/); + expect(uploads).toEqual([]); + await editor.getByRole("button", { name: "Cancel", exact: true }).click(); + const confirm = page.getByRole("alertdialog", { name: "Discard card changes?" }); + await expect(confirm).toBeVisible(); + await confirm.getByRole("button", { name: "Keep editing" }).click(); + await expect(editor).toBeVisible(); + await editor.getByRole("button", { name: "Cancel", exact: true }).click(); + await confirm.getByRole("button", { name: "Discard changes" }).click(); + await expect(editor).toBeHidden(); + await expect(settings.locator(".gmc-frame")).toHaveAttribute("style", savedStyle ?? ""); + await expect(settings.locator(".gmc-banner.img")).toHaveCount(0); + expect(uploads).toEqual([]); +}); diff --git a/package.json b/package.json index f3c55351..57fe9f1d 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "test:card-banner-store": "node scripts/check-card-banner-store.mjs", "test:card-emoji-groups": "node scripts/check-card-emoji-groups.mjs", "test:card-editor-flow": "node scripts/check-card-editor-flow.mjs", + "test:media-processing": "node scripts/check-media-processing.mjs", "test:sidebar-tree": "node scripts/check-sidebar-tree.mjs", "test:hide-muted": "node --disable-warning=ExperimentalWarning scripts/check-hide-muted.mjs", "test:settings-index": "node scripts/check-settings-index.mjs", @@ -223,7 +224,7 @@ "@gryt/core": "0.16.0", "@gryt/crypto": "0.9.0", "@gryt/owl": "^0.6.0", - "@gryt/ui": "^0.39.0", + "@gryt/ui": "^0.39.1", "@gryt/voice": "0.6.1", "@mediapipe/tasks-vision": "^1.0.1", "@noble/curves": "^2.3.0", diff --git a/scripts/check-media-processing.mjs b/scripts/check-media-processing.mjs new file mode 100644 index 00000000..0f898e0c --- /dev/null +++ b/scripts/check-media-processing.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { waitForMedia } from "../src/packages/socket/src/utils/waitForMedia.ts"; + +const savedFetch = globalThis.fetch; +const savedTimeout = globalThis.setTimeout; +try { + globalThis.setTimeout = (callback) => { queueMicrotask(callback); return 0; }; + let calls = 0; + globalThis.fetch = async (_url, options) => { + assert.equal(options.method, "HEAD"); + assert.equal(options.cache, "no-store"); + calls++; + return new Response(null, { status: calls === 1 ? 503 : 200 }); + }; + await waitForMedia("https://local.test/file"); + assert.equal(calls, 2); + globalThis.fetch = async () => new Response(null, { status: 422 }); + await assert.rejects(waitForMedia("https://local.test/file"), /rejected/); + globalThis.fetch = async () => new Response(null, { status: 503 }); + await assert.rejects(waitForMedia("https://local.test/file", 0), /still waiting/); + await assert.rejects(waitForMedia(""), /Cannot check/); +} finally { + globalThis.fetch = savedFetch; + globalThis.setTimeout = savedTimeout; +} +console.log("media processing: pending retries; failures and timeout reject"); diff --git a/src/packages/settings/src/components/BannerCropDialog.tsx b/src/packages/settings/src/components/BannerCropDialog.tsx index 1cc07b09..d3c78e78 100644 --- a/src/packages/settings/src/components/BannerCropDialog.tsx +++ b/src/packages/settings/src/components/BannerCropDialog.tsx @@ -1,5 +1,5 @@ import { Button, Dialog, Slider } from "@gryt/ui"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { bannerCropGeometry, type CropPoint, type CropSize } from "./bannerCrop"; @@ -16,6 +16,7 @@ export function BannerCropDialog({ onUse: (file: File) => void; }) { const viewportRef = useRef(null); + const viewportObserver = useRef(null); const imageRef = useRef(null); const dragRef = useRef<{ pointer: number; at: CropPoint; start: CropPoint } | null>(null); const [url, setUrl] = useState(""); @@ -35,15 +36,17 @@ export function BannerCropDialog({ return () => URL.revokeObjectURL(next); }, [file]); - useEffect(() => { - const node = viewportRef.current; + const attachViewport = useCallback((node: HTMLDivElement | null) => { + viewportRef.current = node; + viewportObserver.current?.disconnect(); + viewportObserver.current = null; if (!node) return; const observer = new ResizeObserver(([entry]) => { setViewport({ width: entry.contentRect.width, height: entry.contentRect.height }); }); + viewportObserver.current = observer; observer.observe(node); - return () => observer.disconnect(); - }, [file]); + }, []); const crop = bannerCropGeometry(image, viewport, zoom, offset); @@ -87,7 +90,7 @@ export function BannerCropDialog({ Drag to move. Scroll or use the slider to zoom.
{ event.preventDefault(); @@ -133,7 +136,7 @@ export function BannerCropDialog({
- +
diff --git a/src/packages/settings/src/components/cardSettings.tsx b/src/packages/settings/src/components/cardSettings.tsx index 0a0ce5b7..f548818a 100644 --- a/src/packages/settings/src/components/cardSettings.tsx +++ b/src/packages/settings/src/components/cardSettings.tsx @@ -41,6 +41,7 @@ import { PRONOUNS_MAX, STATUS_LINE_MAX, } from "../../../socket/src/lib/memberCard/cardStyle"; +import { waitForMedia } from "../../../socket/src/utils/waitForMedia"; import { imageMayAnimate } from "./bannerCrop"; import { BannerCropDialog } from "./BannerCropDialog"; import { SettingGroup, SettingsContainer } from "./settingsComponents"; @@ -72,15 +73,7 @@ async function sendBanner(host: string, file: File | null): Promise { if (!file) return; const result = await r.json() as { bannerFileId?: string; processing?: boolean }; if (!result.processing || !result.bannerFileId) return; - const url = getUploadsFileUrl(host, result.bannerFileId); - if (!url) throw new Error("Cannot check banner processing on this server"); - for (let attempt = 0; attempt < 90; attempt++) { - const ready = await fetch(url, { method: "HEAD", cache: "no-store" }); - if (ready.ok) return; - if (ready.status !== 503) throw new Error("Server rejected the banner during media checks"); - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - throw new Error("Banner is still waiting for this server's image worker. Try again later."); + await waitForMedia(getUploadsFileUrl(host, result.bannerFileId)); } export function CardSettings() { diff --git a/src/packages/socket/src/hooks/uploadChatFile.ts b/src/packages/socket/src/hooks/uploadChatFile.ts index 42f9c833..ff54497d 100644 --- a/src/packages/socket/src/hooks/uploadChatFile.ts +++ b/src/packages/socket/src/hooks/uploadChatFile.ts @@ -1,6 +1,7 @@ -import { getServerAccessToken, getServerHttpBase, type SealedAttachmentKey } from "@/common"; +import { getServerAccessToken, getServerHttpBase, getUploadsFileUrl, type SealedAttachmentKey } from "@/common"; import type { ImageDimensions } from "../utils/imageUtils"; +import { waitForMedia } from "../utils/waitForMedia"; /** What an upload came back as, and how to read it again if it was sealed. */ export interface UploadedFile { @@ -70,5 +71,6 @@ export async function uploadChatFile( throw new Error(msg); } const data = await resp.json(); + if (!sealed && data.processing) await waitForMedia(getUploadsFileUrl(serverHost, data.fileId)); return { fileId: data.fileId as string, ...(sealed ? { meta: sealed.meta } : null) }; } diff --git a/src/packages/socket/src/utils/waitForMedia.ts b/src/packages/socket/src/utils/waitForMedia.ts new file mode 100644 index 00000000..93f93355 --- /dev/null +++ b/src/packages/socket/src/utils/waitForMedia.ts @@ -0,0 +1,11 @@ +export async function waitForMedia(url: string, timeoutMs = 90_000): Promise { + if (!url) throw new Error("Cannot check media processing on this server"); + const deadline = Date.now() + timeoutMs; + do { + const response = await fetch(url, { method: "HEAD", cache: "no-store" }); + if (response.ok) return; + if (response.status !== 503) throw new Error("Server rejected the upload during media checks"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } while (Date.now() < deadline); + throw new Error("Upload is still waiting for this server's image worker. Try again later."); +} diff --git a/yarn.lock b/yarn.lock index 7cf63ff4..e879bf84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -572,10 +572,10 @@ resolved "https://registry.yarnpkg.com/@gryt/theme/-/theme-0.12.1.tgz#9a21b6a4d0ec0f5139828b99bd86a9d9893472c4" integrity sha512-oAMaBq/xF1YdR1AcBgqIRhN3CvkPW+Ifx7FgN6hC0xQ37SMp+g3WG34VNHInp5xVA5ahITUHkdRzDUq4Hb67uQ== -"@gryt/ui@^0.39.0": - version "0.39.0" - resolved "https://registry.yarnpkg.com/@gryt/ui/-/ui-0.39.0.tgz#163a87f8f0ba7a4d268e23d66d29da8e0a91ade4" - integrity sha512-iaYG8YeQjb1VXb6aIsQEyRK8m3Kt5OGxdy9VNZlQWVJaroWgUpnKqu4i+O8It8XWlcQpz/n8iJ3oBP902C0VMw== +"@gryt/ui@^0.39.1": + version "0.39.1" + resolved "https://registry.yarnpkg.com/@gryt/ui/-/ui-0.39.1.tgz#b048276f21df9b00e0aa6566a83b9aecd15ca7e7" + integrity sha512-Pz0dzgMCV721r0r7RyTt1MkGZwL5GJWDSvHhDA59FmQZrEys+XV/1E2pT9vawGU21Rm2ujNz6bcwT0RJHyMVCA== dependencies: "@base-ui/react" "^1.7.0" "@gryt/owl" "^0.6.0" From 82aee675bbb71373a80b61fd4773593293f97112 Mon Sep 17 00:00:00 2001 From: Sivert Date: Fri, 2 Oct 2026 20:57:34 +0200 Subject: [PATCH 09/14] fix(client): use shared virtualized emoji picker in chat Co-Authored-By: OpenAI Codex --- e2e/tests/card-editor.spec.ts | 8 +- e2e/tests/emoji-picker.spec.ts | 38 ++ .../socket/src/components/EmojiPicker.tsx | 427 +++--------------- src/packages/socket/src/utils/emojiData.ts | 5 +- 4 files changed, 112 insertions(+), 366 deletions(-) create mode 100644 e2e/tests/emoji-picker.spec.ts diff --git a/e2e/tests/card-editor.spec.ts b/e2e/tests/card-editor.spec.ts index a2469876..afda005b 100644 --- a/e2e/tests/card-editor.spec.ts +++ b/e2e/tests/card-editor.spec.ts @@ -12,9 +12,11 @@ test("member card opens its editor directly and Cancel restores the saved previe const editor = page.getByRole("dialog", { name: "Edit my card", exact: true }); await expect(editor).toBeVisible(); const settings = page.getByRole("dialog", { name: "Settings", exact: true }); - const savedStyle = await editor.locator(".gmc-frame").getAttribute("style"); + const readStyle = (element: Element) => Object.fromEntries(Array.from((element as HTMLElement).style).sort() + .map((property) => [property, (element as HTMLElement).style.getPropertyValue(property)])); + const savedStyle = await editor.locator(".gmc-frame").evaluate(readStyle); await editor.getByRole("button", { name: "Surprise me" }).click(); - await expect(editor.locator(".gmc-frame")).not.toHaveAttribute("style", savedStyle ?? ""); + await expect.poll(() => editor.locator(".gmc-frame").evaluate(readStyle)).not.toEqual(savedStyle); await editor.getByRole("tab", { name: "About you" }).click(); await editor.locator('input[type="file"]').setInputFiles(fixture("gradient.png")); const crop = page.getByRole("dialog", { name: "Position banner" }); @@ -31,7 +33,7 @@ test("member card opens its editor directly and Cancel restores the saved previe await editor.getByRole("button", { name: "Cancel", exact: true }).click(); await confirm.getByRole("button", { name: "Discard changes" }).click(); await expect(editor).toBeHidden(); - await expect(settings.locator(".gmc-frame")).toHaveAttribute("style", savedStyle ?? ""); + await expect.poll(() => settings.locator(".gmc-frame").evaluate(readStyle)).toEqual(savedStyle); await expect(settings.locator(".gmc-banner.img")).toHaveCount(0); expect(uploads).toEqual([]); }); diff --git a/e2e/tests/emoji-picker.spec.ts b/e2e/tests/emoji-picker.spec.ts new file mode 100644 index 00000000..09fc9d7c --- /dev/null +++ b/e2e/tests/emoji-picker.spec.ts @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; + +import { accessTokenOf } from "../support/admin"; +import { channelComposer, fixture, membersPanel } from "../support/app"; +import { expect, test } from "../support/fixtures"; + +test("chat and card pickers put this server's custom emoji first and keep Unicode searchable", async ({ owner, gryt, request }) => { + const page = owner.page; + const token = await accessTokenOf(page, gryt.server.host); + const name = "card_test_custom"; + const upload = await request.post(`${gryt.server.httpBase}/api/emojis`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: { name, file: { name: "custom.png", mimeType: "image/png", buffer: readFileSync(fixture("gradient.png")) } }, + }); + expect(upload.status(), await upload.text()).toBe(201); + await page.getByRole("button", { name: "Insert emoji", exact: true }).click(); + const chatPicker = page.locator(".gryt-emoji-picker"); + await expect(chatPicker.getByRole("button", { name: "This server", exact: true })).toBeVisible(); + await expect(chatPicker.locator("[data-emoji-category]").first()).toHaveAttribute("aria-label", "This server"); + await chatPicker.getByRole("button", { name: `:${name}:`, exact: true }).click(); + await expect(channelComposer(page).getByRole("img", { name: `:${name}:`, exact: true })).toBeVisible(); + await expect(chatPicker).toBeHidden(); + await page.getByRole("button", { name: "Insert emoji", exact: true }).click(); + await chatPicker.getByPlaceholder("Search emojis...").fill("thumbsup"); + await chatPicker.getByTitle(":thumbsup:", { exact: true }).click(); + await expect(channelComposer(page)).toContainText("👍"); + + await membersPanel(page).locator(".member-row").filter({ hasText: owner.name }).click(); + await page.getByRole("button", { name: "Edit my card", exact: true }).click(); + const editor = page.getByRole("dialog", { name: "Edit my card", exact: true }); + await editor.getByRole("tab", { name: "Pattern", exact: true }).click(); + await editor.getByRole("button", { name: "An emoji", exact: true }).click(); + await editor.getByRole("button", { name: /^Choose emoji/ }).click(); + const cardPicker = page.locator(".gryt-emoji-picker"); + await expect(cardPicker.locator("[data-emoji-category]").first()).toHaveAttribute("aria-label", "This server"); + await cardPicker.getByRole("button", { name: `:${name}:`, exact: true }).click(); + await expect(editor.getByRole("button", { name: /^Choose emoji/ })).toContainText("server emoji"); +}); diff --git a/src/packages/socket/src/components/EmojiPicker.tsx b/src/packages/socket/src/components/EmojiPicker.tsx index 8231f39b..24d90881 100644 --- a/src/packages/socket/src/components/EmojiPicker.tsx +++ b/src/packages/socket/src/components/EmojiPicker.tsx @@ -1,12 +1,9 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createGrytTheme, EmojiPicker as GrytEmojiPicker, type EmojiPickerGroup, type EmojiPickerItem, GrytProvider, grytTheme, grytThemeToOptions } from "@gryt/ui"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { - type EmojiEntry, - getCustomEmojis, - getStandardEmojisByCategory, - onCustomEmojisChange, - searchEmojis, -} from "../utils/emojiData"; +import { useCustomThemes, useTheme } from "@/common"; + +import { type EmojiEntry, getCustomEmojis, getStandardEmojisByCategory, onCustomEmojisChange } from "../utils/emojiData"; import { getRecentReactions } from "../utils/recentReactions"; interface EmojiPickerProps { @@ -23,344 +20,84 @@ export interface EmojiPickerContentProps { autoFocusSearch?: boolean; } -const CATEGORY_ICONS: Record = { - "Recently Used": "🕒", - "Custom": "⭐", - "Smileys & Emotion": "😀", - "People & Body": "👋", - "Animals & Nature": "🐾", - "Food & Drink": "🍕", - "Travel & Places": "✈️", - "Activities": "⚽", - "Objects": "💡", - "Symbols": "💜", - "Flags": "🏁", -}; - -const COLS = 8; const PICKER_WIDTH = 340; const PICKER_MAX_HEIGHT = 400; const VIEWPORT_PAD = 8; const GAP = 6; -function EmojiCell({ entry, onSelect }: { entry: EmojiEntry; onSelect: (src: string) => void }) { - const src = entry.isCustom ? `:${entry.name}:` : entry.emoji!; - return ( - - ); -} - -function CategorySection({ - label, - entries, - onSelect, -}: { - label: string; - entries: EmojiEntry[]; - onSelect: (src: string) => void; -}) { - if (entries.length === 0) return null; - return ( -
-
- {CATEGORY_ICONS[label] ?? ""} - {label} -
-
- {entries.map((entry, idx) => ( - - ))} -
-
- ); -} - -function NavButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) { - return ( - - ); +function pickerItem(entry: EmojiEntry): EmojiPickerItem { + const name = /^[+-]\d+$/.test(entry.name) ? entry.aliases[0] ?? entry.name : entry.name; + return { id: entry.isCustom ? `server:${entry.name}` : `unicode:${entry.emoji}`, name, + emoji: entry.emoji ?? undefined, imageUrl: entry.isCustom ? entry.url : undefined, + keywords: [entry.name, ...entry.aliases, ...entry.tags] }; } export function EmojiPickerContent({ onSelect, serverHost, autoFocusSearch = true }: EmojiPickerContentProps) { - const scrollRef = useRef(null); - const [search, setSearch] = useState(""); - const inputRef = useRef(null); - - useEffect(() => { - if (autoFocusSearch) inputRef.current?.focus({ preventScroll: true }); - }, [autoFocusSearch]); - - const recentEntries = useMemo((): EmojiEntry[] => { - const recent = getRecentReactions(16, serverHost); - const customEmojis = getCustomEmojis(serverHost); - const byCategory = getStandardEmojisByCategory(); - const allStandard: EmojiEntry[] = []; - for (const entries of byCategory.values()) allStandard.push(...entries); - - return recent - .map((src): EmojiEntry | null => { - if (src.startsWith(":") && src.endsWith(":")) { - const name = src.slice(1, -1); - return customEmojis.find((e) => e.name === name) ?? null; - } - return allStandard.find((e) => e.emoji === src) ?? null; - }) - .filter((e): e is EmojiEntry => e !== null); - }, [serverHost]); - - const [customVersion, setCustomVersion] = useState(0); - useEffect(() => onCustomEmojisChange(() => setCustomVersion((v) => v + 1)), []); - // eslint-disable-next-line react-hooks/exhaustive-deps - const customEntries = useMemo(() => getCustomEmojis(serverHost), [customVersion, serverHost]); - const standardCategories = useMemo(() => getStandardEmojisByCategory(), []); - - const searchResults = useMemo(() => { - if (!search.trim()) return null; - return searchEmojis(search.trim(), 0, serverHost); - }, [search, serverHost]); - - const categoryNames = useMemo(() => Array.from(standardCategories.keys()), [standardCategories]); - - const scrollToCategory = useCallback((category: string) => { - const container = scrollRef.current; - if (!container) return; - const el = container.querySelector(`[data-category="${CSS.escape(category)}"]`) as HTMLElement | null; - if (!el) return; - const containerRect = container.getBoundingClientRect(); - const elRect = el.getBoundingClientRect(); - container.scrollTo({ - top: container.scrollTop + elRect.top - containerRect.top, - behavior: "smooth", + const custom = useSyncExternalStore(onCustomEmojisChange, () => getCustomEmojis(serverHost)); + const { activeTheme } = useCustomThemes(); + const { resolvedAppearance } = useTheme(); + const theme = useMemo(() => createGrytTheme(grytThemeToOptions(activeTheme ?? grytTheme, resolvedAppearance)), [activeTheme, resolvedAppearance]); + const groups = useMemo((): EmojiPickerGroup[] => { + const standard = getStandardEmojisByCategory(); + const all = [...custom, ...Array.from(standard.values()).flat()]; + const recent = getRecentReactions(16, serverHost).flatMap((src) => { + const entry = all.find((item) => item.isCustom ? src === `:${item.name}:` : src === item.emoji); + return entry ? [pickerItem(entry)] : []; }); - }, []); - + return [ + { id: "server", label: "This server", items: custom.map(pickerItem) }, + { id: "recent", label: "Recently Used", items: recent }, + ...Array.from(standard, ([label, entries]) => ({ id: label, label, items: entries.map(pickerItem) })), + ]; + }, [custom, serverHost]); return ( - <> -
- setSearch(e.target.value)} - /* - * Base UI's MenuRoot puts useTypeahead on the popup, which - * preventDefaults every single-character key. Bubble phase only. - */ - onKeyDown={(e) => e.stopPropagation()} - style={{ - width: "100%", - padding: "6px 10px", - border: "1px solid var(--gryt-neutral-6)", - borderRadius: 8, - background: "var(--gryt-neutral-2)", - color: "var(--gryt-neutral-12)", - fontSize: 13, - outline: "none", - boxSizing: "border-box", - }} - onFocus={(e) => { e.currentTarget.style.borderColor = "var(--gryt-accent-8)"; }} - onBlur={(e) => { e.currentTarget.style.borderColor = "var(--gryt-neutral-6)"; }} - /> -
- - {!searchResults && ( -
- {recentEntries.length > 0 && ( - scrollToCategory("Recently Used")} /> - )} - {customEntries.length > 0 && ( - scrollToCategory("Custom")} /> - )} - {categoryNames.map((cat) => ( - scrollToCategory(cat)} /> - ))} -
- )} - -
- {searchResults ? ( -
- {searchResults.map((entry, idx) => ( - - ))} - {searchResults.length === 0 && ( -
- No emojis found -
- )} -
- ) : ( - <> - {recentEntries.length > 0 && ( -
- -
- )} - {customEntries.length > 0 && ( -
- -
- )} - {categoryNames.map((cat) => ( -
- -
- ))} - - )} -
- + + { if (event.target instanceof HTMLInputElement) event.stopPropagation(); }} + onSelect={(item) => onSelect(item.id.startsWith("server:") ? `:${item.id.slice(7)}:` : item.emoji ?? "")} + /> + ); } export const EmojiPicker = ({ onSelect, onClose, anchorEl, placement = "above", serverHost }: EmojiPickerProps) => { const containerRef = useRef(null); const [fixedPos, setFixedPos] = useState<{ top?: number; bottom?: number; left: number }>({ left: 0 }); - useLayoutEffect(() => { const anchor = anchorEl ?? containerRef.current?.parentElement; if (!(anchor instanceof HTMLElement)) return; - const compute = () => { const rect = anchor.getBoundingClientRect(); - if (placement === "beside") { - let top: number = rect.top; - if (top + PICKER_MAX_HEIGHT > window.innerHeight - VIEWPORT_PAD) { - top = window.innerHeight - PICKER_MAX_HEIGHT - VIEWPORT_PAD; - } + let top = rect.top; + if (top + PICKER_MAX_HEIGHT > window.innerHeight - VIEWPORT_PAD) top = window.innerHeight - PICKER_MAX_HEIGHT - VIEWPORT_PAD; if (top < VIEWPORT_PAD) top = VIEWPORT_PAD; - - const spaceRight = window.innerWidth - rect.right - VIEWPORT_PAD; - const spaceLeft = rect.left - VIEWPORT_PAD; + const right = window.innerWidth - rect.right - VIEWPORT_PAD; + const leftSpace = rect.left - VIEWPORT_PAD; let left: number; - - if (spaceRight >= PICKER_WIDTH + GAP) { - left = rect.right + GAP; - } else if (spaceLeft >= PICKER_WIDTH + GAP) { - left = rect.left - PICKER_WIDTH - GAP; - } else { + if (right >= PICKER_WIDTH + GAP) left = rect.right + GAP; + else if (leftSpace >= PICKER_WIDTH + GAP) left = rect.left - PICKER_WIDTH - GAP; + else { left = rect.left + rect.width / 2 - PICKER_WIDTH / 2; if (left < VIEWPORT_PAD) left = VIEWPORT_PAD; - if (left + PICKER_WIDTH > window.innerWidth - VIEWPORT_PAD) { - left = window.innerWidth - PICKER_WIDTH - VIEWPORT_PAD; - } + if (left + PICKER_WIDTH > window.innerWidth - VIEWPORT_PAD) left = window.innerWidth - PICKER_WIDTH - VIEWPORT_PAD; } - setFixedPos({ top, bottom: undefined, left }); } else { - const spaceAbove = rect.top; - const spaceBelow = window.innerHeight - rect.bottom; - - let top: number | undefined; - let bottom: number | undefined; - if (spaceAbove >= PICKER_MAX_HEIGHT || spaceAbove >= spaceBelow) { - bottom = window.innerHeight - rect.top + GAP; - } else { - top = rect.bottom + GAP; - } - + const above = rect.top; + const below = window.innerHeight - rect.bottom; + const useAbove = above >= PICKER_MAX_HEIGHT || above >= below; let left = rect.right - PICKER_WIDTH; if (left < VIEWPORT_PAD) left = VIEWPORT_PAD; - if (left + PICKER_WIDTH > window.innerWidth - VIEWPORT_PAD) { - left = window.innerWidth - PICKER_WIDTH - VIEWPORT_PAD; - } - - setFixedPos({ top, bottom, left }); + if (left + PICKER_WIDTH > window.innerWidth - VIEWPORT_PAD) left = window.innerWidth - PICKER_WIDTH - VIEWPORT_PAD; + setFixedPos({ top: useAbove ? undefined : rect.bottom + GAP, + bottom: useAbove ? window.innerHeight - rect.top + GAP : undefined, left }); } }; - compute(); window.addEventListener("resize", compute); window.addEventListener("scroll", compute, true); @@ -369,64 +106,32 @@ export const EmojiPicker = ({ onSelect, onClose, anchorEl, placement = "above", window.removeEventListener("scroll", compute, true); }; }, [anchorEl, placement]); - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - onClose(); - } + const escape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + onClose(); }; - document.addEventListener("keydown", handleEscape, true); - return () => document.removeEventListener("keydown", handleEscape, true); + document.addEventListener("keydown", escape, true); + return () => document.removeEventListener("keydown", escape, true); }, [onClose]); - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - onClose(); - } + const outside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) onClose(); }; - const timer = setTimeout(() => { - document.addEventListener("mousedown", handleClickOutside); - }, 0); + const timer = setTimeout(() => document.addEventListener("mousedown", outside), 0); return () => { clearTimeout(timer); - document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("mousedown", outside); }; }, [onClose]); - - const handleSelect = useCallback( - (src: string) => { - onSelect(src); - onClose(); - }, - [onSelect, onClose], - ); - + const select = useCallback((src: string) => { onSelect(src); onClose(); }, [onSelect, onClose]); return ( -
e.stopPropagation()} - > - +
event.stopPropagation()}> +
); }; diff --git a/src/packages/socket/src/utils/emojiData.ts b/src/packages/socket/src/utils/emojiData.ts index 77b4d497..9a2f789a 100644 --- a/src/packages/socket/src/utils/emojiData.ts +++ b/src/packages/socket/src/utils/emojiData.ts @@ -62,7 +62,8 @@ export function getStandardEmojisByCategory(): Map(); type EmojiChangeListener = () => void; @@ -92,7 +93,7 @@ export function setCustomEmojis(emojis: { name: string; file_id: string }[], ser } export function getCustomEmojis(serverHost?: string): EmojiEntry[] { - return serverHost ? (customEmojisByServer.get(serverHost) ?? []) : customEmojisCache; + return serverHost ? (customEmojisByServer.get(serverHost) ?? EMPTY_CUSTOM_EMOJIS) : customEmojisCache; } export function getAllEmojis(serverHost?: string): EmojiEntry[] { From 0e3d0a2a854c0b08bbc214f6b0c59540e9319a67 Mon Sep 17 00:00:00 2001 From: Sivert Date: Fri, 2 Oct 2026 21:05:56 +0200 Subject: [PATCH 10/14] feat(client): isolate hosted video decoding in Electron sandbox Co-Authored-By: OpenAI Codex --- .github/workflows/ci.yml | 19 ++++++ e2e/fixtures/clip.mp4 | Bin 0 -> 1970 bytes electron/desktopVideoDecoder.ts | 83 ++++++++++++++++++++++++ electron/embeddedServerManager.ts | 3 + package.json | 1 + scripts/check-desktop-video-decoder.mjs | 29 +++++++++ scripts/check-embedded-sfu-ports.mjs | 4 +- scripts/test-desktop-video-decoder.cjs | 55 ++++++++++++++++ 8 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 e2e/fixtures/clip.mp4 create mode 100644 electron/desktopVideoDecoder.ts create mode 100644 scripts/check-desktop-video-decoder.mjs create mode 100644 scripts/test-desktop-video-decoder.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1aeeb418..23ed3998 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,25 @@ on: branches: [main] jobs: + desktop-media: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: yarn + - run: COREPACK_ENABLE_STRICT=0 npx -y yarn@1.22.22 install --frozen-lockfile + shell: bash + - if: runner.os == 'Linux' + run: xvfb-run -a yarn test:desktop-video-decoder + - if: runner.os != 'Linux' + run: yarn test:desktop-video-decoder verify: runs-on: ubuntu-latest env: diff --git a/e2e/fixtures/clip.mp4 b/e2e/fixtures/clip.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..57bfdf7331c5097c25d697c67f1a0c1347c62fe8 GIT binary patch literal 1970 zcmbtU-D@0G6u+C*+G?b+rcz3TtEQlkY-VPYZHx?N8=?^+7C$P8IK4A-H#25tXL9Ez z*)50)ZSfzll02kOQmaL=6nqjZsGuVHn1U}-6iY!-6!M^1e`j{ntfBbgVP?+f@0@ez zoEgR#ujytQW=YIg4vJ84Iqo7F8$0(2<{4oY8VkOpLw>D-ccN_lVT z^xShI_Co<5kNurzw_rZVp8G6E4hMduIyu-<=*umc_%Z0ojHvsOq?n#VaagZG=wFKS zq}^=~`fcBUuO!6~k-v9QMZ7H+e$2ERLH;z;+15ZP=0h49I8k@bwqX*-o`Fa=QDOfq z;wx6k8ZViK#iP(|Se4PXGk+dvwZIP}YO3I~-qsK7;2ijcE7j`RvxPp4iP59Te><{r zyufw^*ROtk|K-0oHVEC9iYx`EM(FN$wd%a(@t2?z9msS(0g@@^PTD|FKMqEdA=<(> zyzN-TksVXY`RyIPrISkjJQw)?@(-XFFVDE$OQ$}Yf$v1eM~wRa6C7`a7SmvC3_jPX>HdBc-rA4?w!(n@s51rQuCA`-cIT%S9!?}kd)0LTz&J3;dD-ZngEA0dy=3b&CH!EXY zl`YHSZh5R?j0reqN(dNv^62#A!=t=)dMQzJBUGgn=r}@5lIk$2XA|5LuJCM=<4ds1+?Q^Yc=NV7VN4iY)J4?H zq-`|H6Gp={%RH8LAR9bebRiw)Y1RZ3Pe=K-Suy~^gU4Y*`VM6@fpC>?a4%3v zEF3^hu9b|UFoQJS823F5!CQ!J11$*uL{gWwWe%ApuZc`MX*M6G6wywwkUB}NmPu_7 zTU-YUh|I3=C`sl;0L9s|nLLX^54~&|3|?1x4KIv^rar>DmMRhmg58Z4m1sI%5~o5# z_wb7FlML%ffj}iFrlv$JGrTOtV?fE+oJ}xsRX@JC9?o<E_O8HsM|JJ=^j$+E+wiw@I3bYhTn?vPfL{PS0{r>b X=J+JDc^m%K^!{%6weITI+wgw?Y=N(9 literal 0 HcmV?d00001 diff --git a/electron/desktopVideoDecoder.ts b/electron/desktopVideoDecoder.ts new file mode 100644 index 00000000..53d4f29d --- /dev/null +++ b/electron/desktopVideoDecoder.ts @@ -0,0 +1,83 @@ +import type { ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; + +import { BrowserWindow, session } from "electron"; + +const MAX_INPUT_LENGTH = Math.ceil(64 * 1024 * 1024 / 3) * 4; + +export function attachDesktopVideoDecoder(worker: ChildProcess): void { + const windows = new Set(); + const decode = async (message: unknown) => { + if (!message || typeof message !== "object") return; + const request = message as { type?: unknown; id?: unknown; input?: unknown }; + if (request.type !== "gryt:video-frame" || typeof request.id !== "string" || !/^[a-f0-9-]{36}$/.test(request.id)) return; + const reply = (result: { frame?: string; error?: string }) => { + if (worker.connected) worker.send({ type: "gryt:video-frame-result", id: request.id, ...result }, () => undefined); + }; + if (windows.size >= 2 || typeof request.input !== "string" || request.input.length > MAX_INPUT_LENGTH + || !/^[A-Za-z0-9+/]+={0,2}$/.test(request.input)) { + reply({ error: "Video exceeds desktop decoder limits" }); + return; + } + const isolated = session.fromPartition(`gryt-video-${randomUUID()}`, { cache: false }); + isolated.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + isolated.setPermissionCheckHandler(() => false); + isolated.webRequest.onBeforeRequest((details, callback) => callback({ cancel: !/^(data:|blob:)/.test(details.url) })); + isolated.on("will-download", (event) => event.preventDefault()); + const window = new BrowserWindow({ show: false, webPreferences: { + session: isolated, sandbox: true, nodeIntegration: false, contextIsolation: true, + webSecurity: true, backgroundThrottling: false, + } }); + windows.add(window); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event) => event.preventDefault()); + const timeout = setTimeout(() => { if (!window.isDestroyed()) window.destroy(); }, 12000); + try { + await window.loadURL("data:text/html,"); + const frame: unknown = await window.webContents.executeJavaScript(`(async () => { + const bytes = Uint8Array.from(atob(${JSON.stringify(request.input)}), c => c.charCodeAt(0)); + const url = URL.createObjectURL(new Blob([bytes], { type: 'video/mp4' })); + const video = document.createElement('video'); + video.muted = true; + video.preload = 'auto'; + try { + await new Promise((resolve, reject) => { + video.onloadeddata = resolve; + video.onerror = () => reject(new Error('Video could not be decoded')); + video.src = url; + }); + if (!video.videoWidth || !video.videoHeight || video.videoWidth * video.videoHeight > 100000000) throw new Error('Video exceeds pixel limit'); + const seek = Math.min(1, Number.isFinite(video.duration) ? video.duration / 2 : 0); + if (seek > 0) await new Promise((resolve, reject) => { + video.onseeked = resolve; + video.onerror = () => reject(new Error('Video frame could not be decoded')); + video.currentTime = seek; + }); + const canvas = document.createElement('canvas'); + canvas.width = Math.min(320, video.videoWidth); + canvas.height = Math.max(1, Math.round(video.videoHeight * canvas.width / video.videoWidth)); + canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL('image/jpeg', 0.85).split(',')[1]; + } finally { + video.removeAttribute('src'); + video.load(); + URL.revokeObjectURL(url); + } + })()`); + if (typeof frame !== "string" || frame.length > 4 * 1024 * 1024) throw new Error("Invalid video frame"); + reply({ frame }); + } catch { + reply({ error: "Video could not be decoded in the desktop sandbox" }); + } finally { + clearTimeout(timeout); + windows.delete(window); + if (!window.isDestroyed()) window.destroy(); + await isolated.clearStorageData(); + } + }; + worker.on("message", (message) => { void decode(message); }); + worker.once("exit", () => { + for (const window of windows) if (!window.isDestroyed()) window.destroy(); + windows.clear(); + }); +} diff --git a/electron/embeddedServerManager.ts b/electron/embeddedServerManager.ts index f1a04482..6557e5d8 100644 --- a/electron/embeddedServerManager.ts +++ b/electron/embeddedServerManager.ts @@ -6,6 +6,7 @@ import { createServer } from "net"; import { join } from "path"; import { extract } from "tar"; +import { attachDesktopVideoDecoder } from "./desktopVideoDecoder"; import { pruneEmbeddedRuntimes } from "./embeddedRuntimeCleanup"; import { checkPortsAvailable, @@ -714,12 +715,14 @@ function spawnWorker( // Only the server beside it reads this endpoint, and it dials 127.0.0.1. // Last, so neither config.env nor the Electron process can widen it. HEALTH_HOST: "127.0.0.1", + GRYT_VIDEO_DECODER: "electron-sandbox", }, stdio: ["ignore", "pipe", "pipe", "ipc"], cwd: getServerDir(id), silent: true, }); + attachDesktopVideoDecoder(proc); const onOutput = (data: Buffer) => { const msg = data.toString().trim(); if (msg) { diff --git a/package.json b/package.json index 57fe9f1d..a595f04c 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "test:card-emoji-groups": "node scripts/check-card-emoji-groups.mjs", "test:card-editor-flow": "node scripts/check-card-editor-flow.mjs", "test:media-processing": "node scripts/check-media-processing.mjs", + "test:desktop-video-decoder": "node scripts/check-desktop-video-decoder.mjs", "test:sidebar-tree": "node scripts/check-sidebar-tree.mjs", "test:hide-muted": "node --disable-warning=ExperimentalWarning scripts/check-hide-muted.mjs", "test:settings-index": "node scripts/check-settings-index.mjs", diff --git a/scripts/check-desktop-video-decoder.mjs b/scripts/check-desktop-video-decoder.mjs new file mode 100644 index 00000000..dadc1299 --- /dev/null +++ b/scripts/check-desktop-video-decoder.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { build } from "esbuild"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const scratch = await mkdtemp(join(tmpdir(), "gryt-decoder-test-")); +try { + const module = join(scratch, "decoder.cjs"); + await build({ entryPoints: [join(root, "electron/desktopVideoDecoder.ts")], outfile: module, + bundle: true, platform: "node", format: "cjs", external: ["electron"] }); + const env = { ...process.env, GRYT_DECODER_TEST_MODULE: module, GRYT_DECODER_TEST_PROFILE: scratch, + GRYT_DECODER_TEST_FIXTURES: join(root, "e2e/fixtures") }; + delete env.ELECTRON_RUN_AS_NODE; + const executable = process.env.GRYT_TEST_ELECTRON ?? createRequire(import.meta.url)("electron"); + const code = await new Promise((resolve, reject) => { + const child = spawn(executable, [join(root, "scripts/test-desktop-video-decoder.cjs")], { env, stdio: "inherit" }); + child.once("error", reject); + child.once("exit", resolve); + }); + assert.equal(code, 0, "desktop decoder test failed"); +} finally { + await rm(scratch, { recursive: true, force: true }); +} diff --git a/scripts/check-embedded-sfu-ports.mjs b/scripts/check-embedded-sfu-ports.mjs index f08362e0..750a86b4 100644 --- a/scripts/check-embedded-sfu-ports.mjs +++ b/scripts/check-embedded-sfu-ports.mjs @@ -159,7 +159,7 @@ function loadManager(app, { onSpawn = () => {} } = {}) { const manager = new Function( "fork", "spawn", "app", "existsSync", "readFileSync", "chmod", "cp", "mkdir", "rename", "rm", "writeFile", "createServer", "join", "extract", "loadGlobalStore", "setGlobalValue", - "setTimeout", "clearTimeout", "process", "console", ...CONFIG_EXPORTS, + "setTimeout", "clearTimeout", "process", "console", "attachDesktopVideoDecoder", ...CONFIG_EXPORTS, moduleBody(MANAGER, ["createAndStartServer", "startExistingServer", "stopAllServers", "getEmbeddedServerState"]), )( fork, spawn, app, fs.existsSync, fs.readFileSync, null, null, null, null, @@ -172,6 +172,7 @@ function loadManager(app, { onSpawn = () => {} } = {}) { resourcesPath: join(scratch, "none"), }, quiet, + (proc) => { proc.decoderAttached = true; }, ...CONFIG_EXPORTS.map((name) => config[name]), ); @@ -201,6 +202,7 @@ async function serverStarted(spawned, count) { async function workerStarted(spawned, count) { for (let i = 0; i < 40 && spawned.worker.length < count; i++) await settle(50); assert.equal(spawned.worker.length, count, "the image worker was never started"); + assert.ok(spawned.worker.every((proc) => proc.decoderAttached), "a worker has no sandboxed video decoder"); return spawned.worker[count - 1]; } diff --git a/scripts/test-desktop-video-decoder.cjs b/scripts/test-desktop-video-decoder.cjs new file mode 100644 index 00000000..e6cccf7b --- /dev/null +++ b/scripts/test-desktop-video-decoder.cjs @@ -0,0 +1,55 @@ +const assert = require("node:assert/strict"); +const { EventEmitter } = require("node:events"); +const { readFileSync } = require("node:fs"); +const { join } = require("node:path"); +const { app, BrowserWindow } = require("electron"); +const { attachDesktopVideoDecoder } = require(process.env.GRYT_DECODER_TEST_MODULE); + +app.setPath("userData", process.env.GRYT_DECODER_TEST_PROFILE); +app.on("window-all-closed", () => undefined); +const worker = new EventEmitter(); +worker.connected = true; +const pending = new Map(); +worker.send = (message, callback) => { + pending.get(message.id)?.(message); + pending.delete(message.id); + callback(); +}; +const probes = []; +app.on("browser-window-created", (_event, window) => { + const preferences = window.webContents.getLastWebPreferences(); + assert.equal(preferences.sandbox, true); + assert.equal(preferences.nodeIntegration, false); + assert.equal(preferences.contextIsolation, true); + assert.equal(preferences.preload, undefined); + window.webContents.once("did-finish-load", () => { + probes.push(window.webContents.executeJavaScript(`(async () => ({ + node: typeof require, process: typeof process, + file: await fetch('file:///etc/passwd').then(() => true, () => false), + network: await fetch('https://example.com').then(() => true, () => false) + }))()`)); + }); +}); +const request = (input) => new Promise((resolve) => { + const id = require("node:crypto").randomUUID(); + pending.set(id, resolve); + worker.emit("message", { type: "gryt:video-frame", id, input }); +}); +const deadline = setTimeout(() => { console.error("Decoder test timed out"); app.exit(1); }, 30000); +app.whenReady().then(async () => { + attachDesktopVideoDecoder(worker); + for (const name of ["clip.mp4", "clip.webm"]) { + const video = readFileSync(join(process.env.GRYT_DECODER_TEST_FIXTURES, name)); + const result = await request(video.toString("base64")); + assert.equal(result.error, undefined, `${name}: ${result.error}`); + assert.ok(Buffer.from(result.frame, "base64").subarray(0, 3).equals(Buffer.from([255, 216, 255]))); + } + const bad = await request(Buffer.from("not a video").toString("base64")); + assert.match(bad.error, /could not be decoded/); + for (const probe of await Promise.all(probes)) assert.deepEqual(probe, { node: "undefined", process: "undefined", file: false, network: false }); + worker.emit("exit", 0); + assert.equal(BrowserWindow.getAllWindows().length, 0); + console.log("desktop video decoder: real MP4, malformed input, sandbox, denied filesystem/network, cleanup passed"); + clearTimeout(deadline); + app.exit(0); +}).catch((error) => { console.error(error); app.exit(1); }); From 77ad82b5d11d09bde54b4843c8e9a3f6a680baa7 Mon Sep 17 00:00:00 2001 From: Sivert Date: Fri, 2 Oct 2026 21:07:04 +0200 Subject: [PATCH 11/14] fix(client): reuse isolated decoder session and guard cleanup Co-Authored-By: OpenAI Codex --- electron/desktopVideoDecoder.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/electron/desktopVideoDecoder.ts b/electron/desktopVideoDecoder.ts index 53d4f29d..9adf8e37 100644 --- a/electron/desktopVideoDecoder.ts +++ b/electron/desktopVideoDecoder.ts @@ -1,5 +1,4 @@ import type { ChildProcess } from "node:child_process"; -import { randomUUID } from "node:crypto"; import { BrowserWindow, session } from "electron"; @@ -19,7 +18,7 @@ export function attachDesktopVideoDecoder(worker: ChildProcess): void { reply({ error: "Video exceeds desktop decoder limits" }); return; } - const isolated = session.fromPartition(`gryt-video-${randomUUID()}`, { cache: false }); + const isolated = session.fromPartition("gryt-video-decoder", { cache: false }); isolated.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); isolated.setPermissionCheckHandler(() => false); isolated.webRequest.onBeforeRequest((details, callback) => callback({ cancel: !/^(data:|blob:)/.test(details.url) })); @@ -72,7 +71,7 @@ export function attachDesktopVideoDecoder(worker: ChildProcess): void { clearTimeout(timeout); windows.delete(window); if (!window.isDestroyed()) window.destroy(); - await isolated.clearStorageData(); + await isolated.clearStorageData().catch(() => undefined); } }; worker.on("message", (message) => { void decode(message); }); From e2747f7a264fbc80fec9dffcf9db0b103d00df9b Mon Sep 17 00:00:00 2001 From: Sivert Date: Fri, 2 Oct 2026 21:11:22 +0200 Subject: [PATCH 12/14] ci: configure Electron sandbox helper on Linux runner Co-Authored-By: OpenAI Codex --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23ed3998..25d57019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,10 @@ jobs: cache: yarn - run: COREPACK_ENABLE_STRICT=0 npx -y yarn@1.22.22 install --frozen-lockfile shell: bash + - if: runner.os == 'Linux' + run: | + sudo chown root:root node_modules/electron/dist/chrome-sandbox + sudo chmod 4755 node_modules/electron/dist/chrome-sandbox - if: runner.os == 'Linux' run: xvfb-run -a yarn test:desktop-video-decoder - if: runner.os != 'Linux' From 35aa8a0cdca1bdc35337d9a3fd559233e243d5fb Mon Sep 17 00:00:00 2001 From: Sivert Date: Fri, 2 Oct 2026 21:15:15 +0200 Subject: [PATCH 13/14] test(client): verify custom emoji pattern renders in card Co-Authored-By: OpenAI Codex --- e2e/tests/emoji-picker.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/tests/emoji-picker.spec.ts b/e2e/tests/emoji-picker.spec.ts index 09fc9d7c..ecfe2744 100644 --- a/e2e/tests/emoji-picker.spec.ts +++ b/e2e/tests/emoji-picker.spec.ts @@ -35,4 +35,6 @@ test("chat and card pickers put this server's custom emoji first and keep Unicod await expect(cardPicker.locator("[data-emoji-category]").first()).toHaveAttribute("aria-label", "This server"); await cardPicker.getByRole("button", { name: `:${name}:`, exact: true }).click(); await expect(editor.getByRole("button", { name: /^Choose emoji/ })).toContainText("server emoji"); + await expect.poll(() => editor.locator(".gmc-frame").evaluate((element) => + (element as HTMLElement).style.getPropertyValue("--pat-img"))).toMatch(/%3Cimage| Date: Fri, 2 Oct 2026 21:21:41 +0200 Subject: [PATCH 14/14] fix(client): use UI beta card fix and guard preset restores Co-Authored-By: OpenAI Codex --- package.json | 2 +- src/packages/settings/src/components/cardSettings.tsx | 3 ++- yarn.lock | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a595f04c..2b086420 100644 --- a/package.json +++ b/package.json @@ -225,7 +225,7 @@ "@gryt/core": "0.16.0", "@gryt/crypto": "0.9.0", "@gryt/owl": "^0.6.0", - "@gryt/ui": "^0.39.1", + "@gryt/ui": "^0.39.2-beta.1", "@gryt/voice": "0.6.1", "@mediapipe/tasks-vision": "^1.0.1", "@noble/curves": "^2.3.0", diff --git a/src/packages/settings/src/components/cardSettings.tsx b/src/packages/settings/src/components/cardSettings.tsx index f548818a..c17ff9d8 100644 --- a/src/packages/settings/src/components/cardSettings.tsx +++ b/src/packages/settings/src/components/cardSettings.tsx @@ -338,9 +338,10 @@ export function CardSettings() { const refused = Object.entries(refusals); const showPastCard = async (past: CardProfile["cardStyle"]) => { + const revision = ++bannerRevision.current; setDraft({ ...draft, cardStyle: past }); const stored = await getCardBanner(past).catch(() => null); - if (!stored?.found) return; + if (revision !== bannerRevision.current || !stored?.found) return; changeBanner(stored.file); }; diff --git a/yarn.lock b/yarn.lock index e879bf84..c509bf40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -572,10 +572,10 @@ resolved "https://registry.yarnpkg.com/@gryt/theme/-/theme-0.12.1.tgz#9a21b6a4d0ec0f5139828b99bd86a9d9893472c4" integrity sha512-oAMaBq/xF1YdR1AcBgqIRhN3CvkPW+Ifx7FgN6hC0xQ37SMp+g3WG34VNHInp5xVA5ahITUHkdRzDUq4Hb67uQ== -"@gryt/ui@^0.39.1": - version "0.39.1" - resolved "https://registry.yarnpkg.com/@gryt/ui/-/ui-0.39.1.tgz#b048276f21df9b00e0aa6566a83b9aecd15ca7e7" - integrity sha512-Pz0dzgMCV721r0r7RyTt1MkGZwL5GJWDSvHhDA59FmQZrEys+XV/1E2pT9vawGU21Rm2ujNz6bcwT0RJHyMVCA== +"@gryt/ui@^0.39.2-beta.1": + version "0.39.2-beta.1" + resolved "https://registry.yarnpkg.com/@gryt/ui/-/ui-0.39.2-beta.1.tgz#8b303ed5422c4be3e78eb881839a6a980e5b1403" + integrity sha512-LklmVc/ISKv/LyKF8hp+crZecxF8tb1hcZXKwsdkuZwCqMggM9dfcXhxf4X99WiDUgqZ3iuOJny982eg7TCs/w== dependencies: "@base-ui/react" "^1.7.0" "@gryt/owl" "^0.6.0"