From 3a1c626bc8ce819cae1c7d5578df6bf3e5935392 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 10 Jul 2026 22:43:17 -0400 Subject: [PATCH] feat(stickers): "recently used" row in the sticker picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emoji and GIF pickers both have a "Recent" row, but the sticker tab of the shared EmojiBoard did not — you had to hunt through packs to re-send a sticker. Add recent stickers, mirroring recentGifs: - New state/recentStickers.ts (localStorage cinny_recent_stickers_v1, deduped by url, capped 16) + pure addRecentSticker with 4 unit tests. - EmojiBoard: a "Recent" group in stickerGroupItems and a RecentClock sidebar icon in StickerSidebar, shown only when recents exist. Entries are rebuilt into minimal PackImageReaders (StickerItem needs only url/shortcode/body) so they render + re-send like pack stickers. - Recorded on select in the shared delegated click handler, covering both the grouped and search paths. Blast radius is the sticker tab only (reactions/status use the emoji tab). Co-Authored-By: Claude Opus 4.8 --- LOTUS_FEATURES.md | 11 ++++ src/app/components/emoji-board/EmojiBoard.tsx | 38 +++++++++++++- src/app/state/recentStickers.test.ts | 47 +++++++++++++++++ src/app/state/recentStickers.ts | 50 +++++++++++++++++++ 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 src/app/state/recentStickers.test.ts create mode 100644 src/app/state/recentStickers.ts diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 81e00d127..9ea56e6f5 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -679,6 +679,17 @@ The indicator is hidden once the server confirms the event (when the internal st - Picker UI is styled with TDS variables when the TDS theme is active - Located at `src/app/components/GifPicker.tsx` +### Sticker Picker — Recently used + +The sticker tab of the shared `EmojiBoard` now has a **"Recent" group** (a sidebar +`RecentClock` icon + top group), matching the emoji and GIF pickers — the stickers you last sent +surface for one-click re-sending instead of hunting through packs. Only shown once you've sent at +least one sticker (hidden otherwise). Persisted in localStorage (`cinny_recent_stickers_v1`), +deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentSticker` +(`src/app/state/recentStickers.ts`). Recent entries are rebuilt into minimal `PackImageReader`s +(`StickerItem` only needs `url`/`shortcode`/`body`) so they render and re-send exactly like pack +stickers. Recorded on select for both the grouped and search paths (shared delegated click). + ### Message Forwarding Context menu → **Forward** allows forwarding a message to any room the user is a member of. diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 2c2af3121..de38dca8b 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -14,7 +14,7 @@ import { Box, config, Icons, Scroll } from 'folds'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; import { Room } from 'matrix-js-sdk'; -import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai'; +import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useVirtualizer } from '@tanstack/react-virtual'; import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji'; import { useEmojiGroupLabels } from './useEmojiGroupLabels'; @@ -29,6 +29,7 @@ import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearc import { useDebounce } from '../../hooks/useDebounce'; import { useThrottle } from '../../hooks/useThrottle'; import { addRecentEmoji } from '../../plugins/recent-emoji'; +import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji'; import { getEmoticonSearchStr } from '../../plugins/utils'; @@ -102,6 +103,7 @@ const useGroups = ( const mx = useMatrixClient(); const recentEmojis = useRecentEmoji(mx, 21); + const recentStickers = useAtomValue(recentStickersAtom); const labels = useEmojiGroupLabels(); const { emojiGroups: loadedEmojiGroups } = useEmojiData(); @@ -143,6 +145,18 @@ const useGroups = ( const g: StickerGroupItem[] = []; if (tab !== EmojiBoardTab.Sticker) return g; + if (recentStickers.length > 0) { + g.push({ + id: RECENT_GROUP_ID, + name: 'Recent', + // StickerItem only reads url/shortcode/body, so a minimal PackImageReader + // reconstructed from the stored data renders and re-sends correctly. + items: recentStickers.map( + (s) => new PackImageReader(s.shortcode, s.url, { body: s.body }) + ), + }); + } + imagePacks.forEach((pack) => { let label = pack.meta.name; if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; @@ -157,7 +171,7 @@ const useGroups = ( }); return g; - }, [mx, imagePacks, tab]); + }, [mx, imagePacks, tab, recentStickers]); return [emojiGroupItems, stickerGroupItems]; }; @@ -289,6 +303,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide const useAuthentication = useMediaAuthentication(); const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom); + const recentStickers = useAtomValue(recentStickersAtom); const usage = ImageUsage.Sticker; const packLabels = useMemo(() => { @@ -308,6 +323,17 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide return ( + {recentStickers.length > 0 && ( + + + + )} {packs.map((pack) => { const label = packLabels.get(pack.id); @@ -435,6 +461,7 @@ export function EmojiBoard({ ); const activeGroupIdAtom = useMemo(() => atom(undefined), []); const setActiveGroupId = useSetAtom(activeGroupIdAtom); + const setRecentStickers = useSetAtom(recentStickersAtom); const imagePacks = useRelevantImagePacks(usage, imagePackRooms); const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks); const groups = emojiTab ? emojiGroupItems : stickerGroupItems; @@ -494,6 +521,13 @@ export function EmojiBoard({ } if (emojiInfo.type === EmojiType.Sticker) { onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label); + setRecentStickers((prev) => + addRecentSticker(prev, { + url: emojiInfo.data, + shortcode: emojiInfo.shortcode, + body: emojiInfo.label, + }) + ); } if (!evt.altKey && !evt.shiftKey) requestClose(); }; diff --git a/src/app/state/recentStickers.test.ts b/src/app/state/recentStickers.test.ts new file mode 100644 index 000000000..9259a168e --- /dev/null +++ b/src/app/state/recentStickers.test.ts @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// The module evaluates atomWithStorage(..., { getOnInit: true }), which reads +// localStorage at load time. node has none, so install a no-op mock, then import +// dynamically (a static import would hoist above the mock and evaluate too early). +(globalThis as { localStorage?: unknown }).localStorage = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, +}; + +const { addRecentSticker } = await import('./recentStickers'); + +const st = (url: string, shortcode = url, body = url) => ({ url, shortcode, body }); + +test('addRecentSticker prepends a new sticker', () => { + const out = addRecentSticker([st('a'), st('b')], st('c')); + assert.deepEqual( + out.map((s) => s.url), + ['c', 'a', 'b'] + ); +}); + +test('addRecentSticker de-dupes by url, moving the existing one to the front', () => { + const out = addRecentSticker([st('a'), st('b'), st('c')], st('c')); + assert.deepEqual( + out.map((s) => s.url), + ['c', 'a', 'b'] + ); + assert.equal(out.length, 3); +}); + +test('addRecentSticker caps the list at max (newest kept)', () => { + const start = [st('a'), st('b'), st('c')]; + const out = addRecentSticker(start, st('d'), 3); + assert.deepEqual( + out.map((s) => s.url), + ['d', 'a', 'b'] + ); +}); + +test('addRecentSticker ignores an empty url', () => { + const start = [st('a')]; + const out = addRecentSticker(start, st('')); + assert.equal(out, start); // returns the same array unchanged +}); diff --git a/src/app/state/recentStickers.ts b/src/app/state/recentStickers.ts new file mode 100644 index 000000000..ef45a934b --- /dev/null +++ b/src/app/state/recentStickers.ts @@ -0,0 +1,50 @@ +import { atom } from 'jotai'; +import { atomWithStorage, createJSONStorage } from 'jotai/utils'; + +export type RecentSticker = { + /** mxc:// url of the sticker image. */ + url: string; + shortcode: string; + body?: string; +}; + +const STORAGE_KEY = 'cinny_recent_stickers_v1'; +const MAX_RECENT_STICKERS = 16; + +// getOnInit reads localStorage synchronously so the Recent group is present on the +// first render of the sticker picker (no flash of the empty default). +const internalAtom = atomWithStorage( + STORAGE_KEY, + [], + createJSONStorage(() => localStorage), + { getOnInit: true }, +); + +/** + * Global atom: the most recently sent stickers, newest first, deduped by url, + * capped at MAX_RECENT_STICKERS. Backed by localStorage (device-local + * convenience), mirroring `recentGifsAtom`. + */ +export const recentStickersAtom = atom( + (get): RecentSticker[] => get(internalAtom), + (_get, set, updater: RecentSticker[] | ((prev: RecentSticker[]) => RecentSticker[])) => { + set(internalAtom, (prev) => { + const prevList = Array.isArray(prev) ? prev : []; + return typeof updater === 'function' ? updater(prevList) : updater; + }); + } +); + +/** + * Prepend a sticker: ignores an empty url, de-dupes by url (moving an existing + * entry to the front), and caps the list at `max`. Pure — returns a new array. + */ +export const addRecentSticker = ( + prev: RecentSticker[], + sticker: RecentSticker, + max = MAX_RECENT_STICKERS +): RecentSticker[] => { + if (!sticker.url) return prev; + const withoutDupe = prev.filter((s) => s.url !== sticker.url); + return [sticker, ...withoutDupe].slice(0, max); +};