diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5067be3b..25d57019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,29 @@ 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: | + 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' + run: yarn test:desktop-video-decoder verify: runs-on: ubuntu-latest env: @@ -107,6 +130,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/fixtures/clip.mp4 b/e2e/fixtures/clip.mp4 new file mode 100644 index 00000000..57bfdf73 Binary files /dev/null and b/e2e/fixtures/clip.mp4 differ diff --git a/e2e/tests/card-editor.spec.ts b/e2e/tests/card-editor.spec.ts new file mode 100644 index 00000000..afda005b --- /dev/null +++ b/e2e/tests/card-editor.spec.ts @@ -0,0 +1,39 @@ +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 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.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" }); + 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.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..ecfe2744 --- /dev/null +++ b/e2e/tests/emoji-picker.spec.ts @@ -0,0 +1,40 @@ +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"); + await expect.poll(() => editor.locator(".gmc-frame").evaluate((element) => + (element as HTMLElement).style.getPropertyValue("--pat-img"))).toMatch(/%3Cimage|(); + 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-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) })); + 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().catch(() => undefined); + } + }; + 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/electron/main.ts b/electron/main.ts index 1d59b037..1a67475d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -167,6 +167,10 @@ import { const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +if (!app.isPackaged && process.env.GRYT_DEV_USER_DATA) { + app.setPath("userData", resolve(process.env.GRYT_DEV_USER_DATA)); +} + // ── Startup logging ────────────────────────────────────────────────────── const LOG_PATH = join(app.getPath("userData"), "gryt-startup.log"); diff --git a/package.json b/package.json index dd82ad83..2b086420 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,12 @@ "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: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: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", @@ -219,7 +225,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.2-beta.1", "@gryt/voice": "0.6.1", "@mediapipe/tasks-vision": "^1.0.1", "@noble/curves": "^2.3.0", 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/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/scripts/check-card-editor-flow.mjs b/scripts/check-card-editor-flow.mjs new file mode 100644 index 00000000..0b8f4574 --- /dev/null +++ b/scripts/check-card-editor-flow.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +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/); +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\);/); +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/scripts/check-card-emoji-groups.mjs b/scripts/check-card-emoji-groups.mjs new file mode 100644 index 00000000..4321a2a8 --- /dev/null +++ b/scripts/check-card-emoji-groups.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const emojiData = readFileSync("src/packages/socket/src/utils/emojiData.ts", "utf8"); +const cardGroups = readFileSync("src/packages/socket/src/components/memberCard/cardEmojiGroups.ts", "utf8"); +const cardSettings = readFileSync("src/packages/settings/src/components/cardSettings.tsx", "utf8"); +const cardView = readFileSync("src/packages/socket/src/components/memberCard/MemberCardView.tsx", "utf8"); + +assert.match(emojiData, /customEmojisByServer\.set\(serverHost, next\)/); +assert.match(emojiData, /customEmojisByServer\.get\(serverHost\)/); +assert.match(cardGroups, /getCustomEmojis\(serverHost\)/); +assert.match(cardSettings, /useCardEmojiGroups\(currentlyViewingServer\?\.host\)/); +assert.match(cardView, /useCardEmojiGroups\(currentlyViewingServer\?\.host\)/); + +console.log("card emoji groups: ok"); 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/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/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, / 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); }); diff --git a/src/lib/sharedLook.ts b/src/lib/sharedLook.ts index 4e8da550..d95a3e9c 100644 --- a/src/lib/sharedLook.ts +++ b/src/lib/sharedLook.ts @@ -5,6 +5,8 @@ import { useSyncExternalStore } from "react"; export interface SharedLook { /** A card style as a query string, as the card builder writes it. */ card?: string; + /** A copied server banner. It stays local until the card is saved. */ + cardBanner?: string | null; /** An owl as a worn string. */ owl?: string; } @@ -15,12 +17,12 @@ const listeners = new Set<() => 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(); } /** Takes it, so it is shown once. */ -export function takeSharedLook(kind: keyof SharedLook): string | undefined { +export function takeSharedLook(kind: K): SharedLook[K] { const value = pending[kind]; if (value === undefined) return undefined; pending = { ...pending, [kind]: undefined }; diff --git a/src/packages/settings/src/components/BannerCropDialog.tsx b/src/packages/settings/src/components/BannerCropDialog.tsx new file mode 100644 index 00000000..d3c78e78 --- /dev/null +++ b/src/packages/settings/src/components/BannerCropDialog.tsx @@ -0,0 +1,146 @@ +import { Button, Dialog, Slider } from "@gryt/ui"; +import { useCallback, 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 viewportObserver = 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]); + + 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); + }, []); + + 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..c17ff9d8 100644 --- a/src/packages/settings/src/components/cardSettings.tsx +++ b/src/packages/settings/src/components/cardSettings.tsx @@ -19,8 +19,10 @@ import { useServerManagement, useSockets } from "@/socket"; import type { RichActivity } from "../../../../lib/richActivity"; import { takeSharedLook, useSharedLook } from "../../../../lib/sharedLook"; +import { ConfirmDialog } from "../../../socket/src/components/ConfirmDialog"; 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, @@ -39,6 +41,9 @@ 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"; /** A game for the preview when you are not playing one, so the band can be seen. */ @@ -65,16 +70,20 @@ 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; + await waitForMedia(getUploadsFileUrl(host, result.bannerFileId)); } export function CardSettings() { const stored = useStoredCard(); const { sockets, serverProfiles, serverDetailsList, memberLists } = useSockets(); - const { servers } = useServerManagement(); - const { nickname, avatarDataUrl, gameCard } = useSettings(); + const { servers, currentlyViewingServer } = useServerManagement(); + const { nickname, avatarDataUrl, gameCard, settingsTab, setSettingsTab } = useSettings(); const { activeTheme } = useCustomThemes(); const { resolvedAppearance } = useTheme(); - const emojiGroups = useCardEmojiGroups(); + const emojiGroups = useCardEmojiGroups(currentlyViewingServer?.host); const hosts = Object.keys(servers); const connected = hosts.filter((h) => sockets[h]?.connected); @@ -107,9 +116,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)) { @@ -137,11 +157,6 @@ export function CardSettings() { ); }; const shared = useSharedLook().card; - useEffect(() => { - if (!shared) return; - const text = takeSharedLook("card"); - if (text && !showShared(text)) toast.error("That link had no card in it."); - }, [shared]); const text = (key: "bio" | "pronouns" | "statusLine", max: number) => ({ value: draft[key] ?? "", @@ -160,9 +175,14 @@ export function CardSettings() { }; const bannerHosts = connected.filter(mayUpload); const [bannerPreview, setBannerPreview] = useState(); + const [bannerMime, setBannerMime] = useState(); const [pendingBanner, setPendingBanner] = useState(); - const [editing, setEditing] = useState(false); + 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(() => { for (const host of connected) { @@ -175,11 +195,85 @@ 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); setPendingBanner(file); }; + useEffect(() => { + if (!shared) return; + const text = takeSharedLook("card"); + const remoteBanner = takeSharedLook("cardBanner"); + if (!text || !showShared(text)) { + toast.error("That link had no card in it."); + return; + } + 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(); + if (revision === bannerRevision.current) changeBanner(new File([blob], "copied-banner", { type: blob.type })); + }) + .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 + + useEffect(() => { + if (settingsTab === "profile/card/edit") setEditing(true); + }, [settingsTab]); + + const discardChanges = () => { + bannerRevision.current++; + setBannerDownloading(false); + setDraft(saved); + if (bannerPreview?.startsWith("blob:")) URL.revokeObjectURL(bannerPreview); + setBannerPreview(undefined); + setBannerMime(undefined); + setPendingBanner(undefined); + }; + + const closeEditor = () => { + discardChanges(); + setEditing(false); + setConfirmCancel(false); + if (settingsTab === "profile/card/edit") setSettingsTab("profile/card"); + }; + + const requestCancel = () => { + if (bannerBusy) return; + if (dirty || bannerDownloading) setConfirmCancel(true); + else closeEditor(); + }; + + const chooseBanner = async (file: 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); + }; + const save = async () => { setBannerBusy(true); const results = pendingBanner === undefined @@ -195,7 +289,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"); }; @@ -216,6 +310,7 @@ export function CardSettings() { profile={draft} owlHex={owlHex} bannerUrl={bannerUrl} + bannerMime={bannerMime} game={playing ? gameCard ?? SAMPLE_GAME : null} appearance={appearance} seedKey={seedKey} @@ -242,6 +337,14 @@ 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 (revision !== bannerRevision.current || !stored?.found) return; + changeBanner(stored.file); + }; + const about = ( <> @@ -261,8 +364,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.` } >
@@ -278,16 +381,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); + }} + /> ); @@ -314,7 +426,7 @@ 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. */} @@ -327,18 +439,15 @@ export function CardSettings() { - - - }>Close +
@@ -380,14 +489,17 @@ export function CardSettings() { - ); -} - -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(); - 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(), [customVersion]); - const standardCategories = useMemo(() => getStandardEmojisByCategory(), []); - - const searchResults = useMemo(() => { - if (!search.trim()) return null; - return searchEmojis(search.trim()); - }, [search]); - - 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/components/MemberIdentityCard.tsx b/src/packages/socket/src/components/MemberIdentityCard.tsx index 8ae0fb5f..b45af91f 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"; @@ -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) || "card=b4b", + 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")} > diff --git a/src/packages/socket/src/components/memberCard/MemberCardView.tsx b/src/packages/socket/src/components/memberCard/MemberCardView.tsx index 735321e2..aacdec41 100644 --- a/src/packages/socket/src/components/memberCard/MemberCardView.tsx +++ b/src/packages/socket/src/components/memberCard/MemberCardView.tsx @@ -1,5 +1,7 @@ import { MemberCard, type MemberCardProps, setCardIconLoader } from "@gryt/ui"; +import { useEffect, useState } from "react"; +import { useServerManagement } from "../../hooks/useServerManagement"; import { BotTag } from "../BotTag"; import { useCardEmojiGroups } from "./cardEmojiGroups"; @@ -8,10 +10,52 @@ 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 | null { + const explicit = mime ? typeFromMime(mime) : null; + 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(null); + const controller = new AbortController(); + 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); + return; + } + })().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) { - const emojiGroups = useCardEmojiGroups(); - return : undefined} />; +export function MemberCardView({ isBot, bannerMime, ...props }: MemberCardViewProps) { + const { currentlyViewingServer } = useServerManagement(); + const emojiGroups = useCardEmojiGroups(currentlyViewingServer?.host); + const bannerType = useBannerType(props.bannerUrl, bannerMime); + const memberCardProps = { ...props, bannerType: bannerType ?? "image", bannerUrl: bannerType ? props.bannerUrl : null }; + return : undefined} />; } diff --git a/src/packages/socket/src/components/memberCard/cardEmojiGroups.ts b/src/packages/socket/src/components/memberCard/cardEmojiGroups.ts index aa9fdb07..86364844 100644 --- a/src/packages/socket/src/components/memberCard/cardEmojiGroups.ts +++ b/src/packages/socket/src/components/memberCard/cardEmojiGroups.ts @@ -4,8 +4,11 @@ import { useMemo, useSyncExternalStore } from "react"; import { getCustomEmojis, onCustomEmojisChange } from "../../utils/emojiData"; /** Custom emoji from the server currently open in the client. */ -export function useCardEmojiGroups(): readonly EmojiPickerGroup[] { - const custom = useSyncExternalStore(onCustomEmojisChange, getCustomEmojis); +export function useCardEmojiGroups(serverHost?: string): readonly EmojiPickerGroup[] { + const custom = useSyncExternalStore( + onCustomEmojisChange, + () => getCustomEmojis(serverHost), + ); return useMemo(() => { if (custom.length === 0) return []; return [{ 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/lib/memberCard/cardBannerStore.ts b/src/packages/socket/src/lib/memberCard/cardBannerStore.ts new file mode 100644 index 00000000..e7d1b643 --- /dev/null +++ b/src/packages/socket/src/lib/memberCard/cardBannerStore.ts @@ -0,0 +1,86 @@ +import { type CardStyle, cardStyleForWire } from "./cardStyle"; + +const DB_NAME = "gryt"; +const DB_VERSION = 1; +const STORE_NAME = "kv"; +const PREFIX = "member-card-banner:v1:"; + +interface StoredBanner { + blob: Blob | null; + name: string | null; + type: string | null; +} + +export interface CardBannerResult { + found: boolean; + file: File | null; +} + +const keyFor = (style: CardStyle) => `${PREFIX}${JSON.stringify(cardStyleForWire(style))}`; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORE_NAME)) request.result.createObjectStore(STORE_NAME); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("Could not open banner storage")); + }); +} + +async function transact(mode: IDBTransactionMode, run: (store: IDBObjectStore, finish: (value: T) => void, fail: (error: unknown) => void) => void): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, mode); + const finish = (value: T) => resolve(value); + const fail = (error: unknown) => reject(error instanceof Error ? error : new Error("Banner storage failed")); + transaction.oncomplete = () => db.close(); + transaction.onerror = () => fail(transaction.error); + run(transaction.objectStore(STORE_NAME), finish, fail); + }); +} + +export async function getCardBanner(style: CardStyle): Promise { + return transact("readonly", (store, finish, fail) => { + const request = store.get(keyFor(style)); + request.onerror = () => fail(request.error); + request.onsuccess = () => { + const stored = request.result as StoredBanner | undefined; + if (!stored) return finish({ found: false, file: null }); + const file = stored.blob + ? new File([stored.blob], stored.name ?? "banner", { type: stored.type ?? stored.blob.type }) + : null; + finish({ found: true, file }); + }; + }); +} + +export async function setCardBanner(style: CardStyle, file: File | null): Promise { + return transact("readwrite", (store, finish, fail) => { + const stored: StoredBanner = { blob: file, name: file?.name ?? null, type: file?.type ?? null }; + const request = store.put(stored, keyFor(style)); + request.onerror = () => fail(request.error); + request.onsuccess = () => finish(); + }); +} + +export async function deleteCardBanner(style: CardStyle): Promise { + return transact("readwrite", (store, finish, fail) => { + const request = store.delete(keyFor(style)); + request.onerror = () => fail(request.error); + request.onsuccess = () => finish(); + }); +} + +export async function pruneCardBanners(styles: readonly CardStyle[]): Promise { + const keep = new Set(styles.map(keyFor)); + return transact("readwrite", (store, finish, fail) => { + const request = store.getAllKeys(IDBKeyRange.bound(PREFIX, `${PREFIX}\uffff`)); + request.onerror = () => fail(request.error); + request.onsuccess = () => { + for (const key of request.result) if (typeof key === "string" && !keep.has(key)) store.delete(key); + finish(); + }; + }); +} diff --git a/src/packages/socket/src/lib/memberCard/cardStore.ts b/src/packages/socket/src/lib/memberCard/cardStore.ts index 581a8ad1..710b5624 100644 --- a/src/packages/socket/src/lib/memberCard/cardStore.ts +++ b/src/packages/socket/src/lib/memberCard/cardStore.ts @@ -123,14 +123,18 @@ function writeHistory(next: CardStyle[]): void { const sameStyle = (a: CardStyle, b: CardStyle) => JSON.stringify(cardStyleForWire(a)) === JSON.stringify(cardStyleForWire(b)); /** Newest first, no repeats, and the plain card is not worth remembering. */ -export function rememberCardStyle(style: CardStyle): void { - if (cardStyleForWire(style) === null) return; +export function rememberCardStyle(style: CardStyle): CardStyle[] { + if (cardStyleForWire(style) === null) return history ?? readHistory(); const rest = (history ?? readHistory()).filter((s) => !sameStyle(s, style)); - writeHistory([style, ...rest].slice(0, HISTORY_MAX)); + const next = [style, ...rest].slice(0, HISTORY_MAX); + writeHistory(next); + return next; } -export function forgetCardStyle(style: CardStyle): void { - writeHistory((history ?? readHistory()).filter((s) => !sameStyle(s, style))); +export function forgetCardStyle(style: CardStyle): CardStyle[] { + const next = (history ?? readHistory()).filter((s) => !sameStyle(s, style)); + writeHistory(next); + return next; } export function useCardHistory(): CardStyle[] { diff --git a/src/packages/socket/src/utils/emojiData.ts b/src/packages/socket/src/utils/emojiData.ts index 7ede7871..9a2f789a 100644 --- a/src/packages/socket/src/utils/emojiData.ts +++ b/src/packages/socket/src/utils/emojiData.ts @@ -62,7 +62,9 @@ export function getStandardEmojisByCategory(): Map(); type EmojiChangeListener = () => void; const emojiChangeListeners = new Set(); @@ -77,7 +79,7 @@ export function getCustomEmojiUrl(serverHost: string, name: string): string { } export function setCustomEmojis(emojis: { name: string; file_id: string }[], serverHost: string): void { - customEmojisCache = emojis.map((e) => ({ + const next = emojis.map((e) => ({ name: e.name, emoji: null, isCustom: true, @@ -85,15 +87,17 @@ export function setCustomEmojis(emojis: { name: string; file_id: string }[], ser tags: [], aliases: [], })); + customEmojisByServer.set(serverHost, next); + customEmojisCache = next; emojiChangeListeners.forEach((fn) => fn()); } -export function getCustomEmojis(): EmojiEntry[] { - return customEmojisCache; +export function getCustomEmojis(serverHost?: string): EmojiEntry[] { + return serverHost ? (customEmojisByServer.get(serverHost) ?? EMPTY_CUSTOM_EMOJIS) : customEmojisCache; } -export function getAllEmojis(): EmojiEntry[] { - return [...getStandardEmojis(), ...customEmojisCache]; +export function getAllEmojis(serverHost?: string): EmojiEntry[] { + return [...getStandardEmojis(), ...getCustomEmojis(serverHost)]; } const enum MatchTier { @@ -108,10 +112,10 @@ interface ScoredEntry { tier: MatchTier; } -export function searchEmojis(query: string, limit = 0): EmojiEntry[] { +export function searchEmojis(query: string, limit = 0, serverHost?: string): EmojiEntry[] { if (!query) return []; const q = query.toLowerCase(); - const all = getAllEmojis(); + const all = getAllEmojis(serverHost); const scored: ScoredEntry[] = []; for (const entry of all) { 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..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.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.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"