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 ? (
-
+
) : src.service === 'discord' ? (
) : (
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. */