diff --git a/nobilis b/nobilis index e264ea6..8cf3578 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit e264ea6612a17d8c56c22877093367f8499483b3 +Subproject commit 8cf3578985fb59eee864a09d9767efb390535b30 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8058a79..222a2ac 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -31,6 +31,7 @@ import { watchDelta, watchedKickBuffers } from './lib/kickwatch' import { useActiveBuffer, useChat, usePref, usePrefsReady, useStore } from './state/hooks' import { bufferDisplayName } from './lib/util' import type { BufferEntry } from './state/store' +import { loadLocalEmotes } from './lib/emotecache' export default function App(): JSX.Element { const store = useStore() @@ -67,6 +68,11 @@ export default function App(): JSX.Element { if (!prefsReady || booted) return setBooted(true) void store.init(savedBufferId, savedGroupId) + // What the daemon has already shrunk, asked for once. Without it every + // Kick emote would be drawn at its full 500x500 once per session before + // its small copy was noticed - the cache is on disk and outlives the + // window, so there is no reason to rediscover it a megabyte at a time. + void loadLocalEmotes() return () => store.dispose() }, [prefsReady, booted, savedBufferId, savedGroupId, store]) diff --git a/src/renderer/src/components/EmojiPicker.tsx b/src/renderer/src/components/EmojiPicker.tsx index bce8bd6..a677609 100644 --- a/src/renderer/src/components/EmojiPicker.tsx +++ b/src/renderer/src/components/EmojiPicker.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom' import { resolveMediaUrl } from '../lib/util' import { Icon } from './Icon' import { discordEmojiUrl, emojiPreview, type SmilieEntry } from '../lib/format' +import { drawableEmoteUrl, onLocalEmotes } from '../lib/emotecache' import type { CustomEmoji } from '../../../shared/wire' /** @@ -69,7 +70,7 @@ function emojiToken(e: CustomEmoji): string { /** Where its picture is: said outright where the service gives one. */ function emojiImage(e: CustomEmoji): string { - return e.url ?? discordEmojiUrl(e.id) + return e.url ? drawableEmoteUrl(e.url) : discordEmojiUrl(e.id) } const MAX_RECENT = 16 @@ -341,6 +342,11 @@ export function EmojiPicker({ return [...byPack.entries()] }, [q, stickers]) + // Redrawn when small copies arrive, so a cell that opened on Kick's own + // full-size URL swaps to the shrunk one rather than waiting for a scroll. + const [, setEmoteTick] = useState(0) + useEffect(() => onLocalEmotes(() => setEmoteTick((n) => n + 1)), []) + const pick = (text: string): void => { const next = [text, ...recent.filter((r) => r !== text)].slice(0, MAX_RECENT) setRecent(next) @@ -451,7 +457,7 @@ export function EmojiPicker({ onClick={() => pick(e.id)} > {e.url ? ( - {e.name} + {e.name} ) : src.service === 'discord' ? ( {e.name} ) : ( diff --git a/src/renderer/src/lib/emotecache.ts b/src/renderer/src/lib/emotecache.ts new file mode 100644 index 0000000..8952902 --- /dev/null +++ b/src/renderer/src/lib/emotecache.ts @@ -0,0 +1,110 @@ +/** + * Small local copies of Kick emotes. + * + * Kick serves one size of an emote and it is the big one - typically 500x500, + * animated, about a megabyte - while a picker cell draws it at 26 pixels and a + * message smaller still. Decoded, a 35-frame emote is 33MB of frame buffers, + * which is why opening the picker cost hundreds of megabytes (issue #211). + * + * The daemon keeps a shrunk copy of each emote it has been asked for, 64 + * pixels on the longest edge, animation intact: 33MB of frame buffers becomes + * 0.55MB. This module is the renderer's half - a map of emote id to local + * file, consulted whenever an emote URL is about to be used. + * + * It is deliberately synchronous. Emote URLs are built in two places that + * cannot await: the HTML formatter, which returns a string, and the picker, + * which renders a cell. So the map is filled in the background and read + * without blocking, and an emote not in it yet falls back to Kick's own URL - + * the right picture, just an expensive one, rather than a gap. + */ + +/** Kick's own emote URL, which is the only shape worth rewriting. */ +const KICK_EMOTE = /^https:\/\/files\.kick\.com\/emotes\/(\d+)\/fullsize$/ + +const local = new Map() +/** Ids asked for but not yet answered, so nothing is requested twice. */ +const pending = new Set() +/** Ids seen but not yet requested, flushed together a tick later. */ +const queued = new Set() +let flushing: ReturnType | null = null +const listeners = new Set<() => void>() + +/** The emote id in a Kick emote URL, or null for anything else. */ +export function kickEmoteId(url: string): string | null { + return url.match(KICK_EMOTE)?.[1] ?? null +} + +/** Told when new local copies land, so a view can draw them. */ +export function onLocalEmotes(fn: () => void): () => void { + listeners.add(fn) + return () => listeners.delete(fn) +} + +function merge(map: Record): void { + let added = 0 + for (const [id, url] of Object.entries(map)) { + if (!local.has(id)) added++ + local.set(id, url) + pending.delete(id) + } + if (added > 0) for (const fn of listeners) fn() +} + +/** + * Everything the daemon already has, asked for once at startup. + * + * Without this every emote would be drawn full-size once per session before + * its small copy was noticed - the cache is on disk and survives restarts, so + * there is no reason to rediscover it a megabyte at a time. + */ +export async function loadLocalEmotes(): Promise { + try { + merge(await window.moho.rpc>('kickEmotes', {})) + } catch { + /* an older daemon has no such method; emotes stay full-size */ + } +} + +/** Ask for these emotes to be shrunk, batched so a screenful is one call. */ +export function requestLocalEmotes(ids: string[]): void { + for (const id of ids) { + if (!local.has(id) && !pending.has(id)) queued.add(id) + } + if (queued.size === 0 || flushing) return + flushing = setTimeout(async () => { + flushing = null + const batch = [...queued] + queued.clear() + for (const id of batch) pending.add(id) + try { + merge(await window.moho.rpc>('kickEmotes', { ids: batch })) + } catch { + for (const id of batch) pending.delete(id) + } + }, 50) +} + +/** + * The URL to actually draw for an emote: the small local copy where there is + * one, and Kick's own otherwise - with the small one requested for next time. + * + * Takes and returns a URL rather than an id so that both callers can use it + * without agreeing on what an emote's id means: the formatter builds the URL + * from a token, the picker is handed one by the daemon, and the only thing + * they share is the URL's shape. + */ +export function drawableEmoteUrl(url: string): string { + const id = kickEmoteId(url) + if (!id) return url + const small = local.get(id) + if (small) return small + requestLocalEmotes([id]) + return url +} + +/** Test seam: forget everything, so one test cannot colour the next. */ +export function resetLocalEmotes(): void { + local.clear() + pending.clear() + queued.clear() +} diff --git a/src/renderer/src/lib/format.ts b/src/renderer/src/lib/format.ts index 7e40c72..8ce9573 100644 --- a/src/renderer/src/lib/format.ts +++ b/src/renderer/src/lib/format.ts @@ -1,4 +1,6 @@ import type { SneedchatSmilie } from '../../../shared/wire' +import { drawableEmoteUrl } from './emotecache' +import { resolveMediaUrl } from './util' // Shared with the main process, which is what actually opens a clicked link. export { clearnetLinks } from '../../../shared/clearnet' @@ -55,12 +57,16 @@ export function discordEmojiUrl(id: string, size = 44): string { /** * The picture for a Kick emote. * - * One size only. The `/default` and `/small` variants other emote hosts serve - * are a 403 here, so asking for anything but fullsize is a broken image in - * every message rather than a smaller one. + * One size only, from Kick: the `/default` and `/small` variants other emote + * hosts serve are a 403 here, so asking for anything but fullsize is a broken + * image in every message rather than a smaller one. + * + * Which is why the daemon keeps a shrunk copy instead, and why this asks for + * that first - fullsize is 500x500 and animated, for a picture drawn at about + * twenty pixels in a message. See lib/emotecache. */ export function kickEmoteUrl(id: string): string { - return `https://files.kick.com/emotes/${id}/fullsize` + return resolveMediaUrl(drawableEmoteUrl(`https://files.kick.com/emotes/${id}/fullsize`)) } /** What something picked out of the emoji list looks like, when it isn't text. */