From 5b58e5fe439334fe2fe7ed99b86de67ff9e96c4b Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 10 Jul 2026 12:27:18 -0400 Subject: [PATCH] feat(gif): recently-used GIFs in the picker The GIF picker was a bare Giphy search grid with no memory of what you've sent, so re-sending a go-to reaction GIF meant re-typing the search every time. Add a "Recent" row at the top of the picker (default view; hidden while searching) for one-click re-sending. - New persisted state state/recentGifs.ts: recentGifsAtom (localStorage, cinny_recent_gifs_v1, getOnInit) + pure addRecentGif (dedupe-by-url move-to-front, cap 16, ignore empty url), with 5 unit tests. - GifPicker records every sent GIF (from search or the Recent row) to the front, and renders a 3-up thumbnail grid of recents above the search grid when there are recents and no active search term. Section label matches the picker's existing `// GIF_SEARCH` treatment (lotusTerminal) or a muted label otherwise. Co-Authored-By: Claude Opus 4.8 --- LOTUS_FEATURES.md | 1 + src/app/components/GifPicker.tsx | 105 ++++++++++++++++++++++++++++--- src/app/state/recentGifs.test.ts | 56 +++++++++++++++++ src/app/state/recentGifs.ts | 48 ++++++++++++++ 4 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 src/app/state/recentGifs.test.ts create mode 100644 src/app/state/recentGifs.ts diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 1f528058c..5a3a31ef9 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -674,6 +674,7 @@ The indicator is hidden once the server confirms the event (when the internal st - Giphy-powered picker accessible from the composer toolbar - The button is only shown when `gifApiKey` is set in `config.json` - Selected GIFs are sent as `m.image` events +- **Recently used**: a "Recent" row at the top of the picker (shown on the default view, hidden while searching) surfaces the GIFs you last sent for one-click re-sending — no re-searching. Persisted in localStorage (`cinny_recent_gifs_v1`), deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentGif` (`src/app/state/recentGifs.ts`). - Picker UI is styled with TDS variables when the TDS theme is active - Located at `src/app/components/GifPicker.tsx` diff --git a/src/app/components/GifPicker.tsx b/src/app/components/GifPicker.tsx index 91a7617f9..40b3f060d 100644 --- a/src/app/components/GifPicker.tsx +++ b/src/app/components/GifPicker.tsx @@ -1,10 +1,12 @@ import React, { useCallback } from 'react'; import FocusTrap from 'focus-trap-react'; +import { useAtom } from 'jotai'; import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components'; import { IGif } from '@giphy/js-types'; import { Box, color, config } from 'folds'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; +import { addRecentGif, RecentGif, recentGifsAtom } from '../state/recentGifs'; const PICKER_WIDTH = 312; const PICKER_WIDTH_CSS = `min(${PICKER_WIDTH}px, calc(100vw - 16px))`; @@ -15,22 +17,108 @@ type GifPickerInnerProps = { lotusTerminal: boolean; }; +// Small monospace section header matching the picker's `// GIF_SEARCH` treatment +// (lotusTerminal) / a muted label otherwise. +function SectionLabel({ text, lotusTerminal }: { text: string; lotusTerminal: boolean }) { + if (lotusTerminal) { + return ( +
+ {`// ${text.toUpperCase()}`} +
+ ); + } + return ( +
+ {text} +
+ ); +} + +function RecentGifs({ + recents, + lotusTerminal, + onPick, +}: { + recents: RecentGif[]; + lotusTerminal: boolean; + onPick: (url: string, width: number, height: number) => void; +}) { + return ( +
+ +
+ {recents.map((g) => ( + + ))} +
+
+ ); +} + function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInnerProps) { - const { fetchGifs, searchKey } = React.useContext(SearchContext); + const { fetchGifs, searchKey, term } = React.useContext(SearchContext); + const [recents, setRecents] = useAtom(recentGifsAtom); + + const sendGif = useCallback( + (url: string, width: number, height: number) => { + setRecents((prev) => addRecentGif(prev, { url, width, height })); + onSelect(url, width, height); + requestClose(); + }, + [onSelect, requestClose, setRecents], + ); const handleClick = useCallback( (gif: IGif, e: React.SyntheticEvent) => { e.preventDefault(); const r = gif.images.downsized ?? gif.images.original; - const { url } = r; - const width = Number(r.width) || 200; - const height = Number(r.height) || 200; - onSelect(url, width, height); - requestClose(); + sendGif(r.url, Number(r.width) || 200, Number(r.height) || 200); }, - [onSelect, requestClose], + [sendGif], ); + const showRecents = recents.length > 0 && !(term ?? '').trim(); + return ( {lotusTerminal && ( @@ -57,6 +145,9 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
+ {showRecents && ( + + )} null, + setItem: () => undefined, + removeItem: () => undefined, +}; + +const { addRecentGif } = await import('./recentGifs'); + +const gif = (url: string, width = 100, height = 100) => ({ url, width, height }); + +test('addRecentGif prepends a new gif', () => { + const out = addRecentGif([gif('a'), gif('b')], gif('c')); + assert.deepEqual( + out.map((g) => g.url), + ['c', 'a', 'b'], + ); +}); + +test('addRecentGif de-dupes by url, moving the entry to the front', () => { + const out = addRecentGif([gif('a'), gif('b'), gif('c')], gif('b')); + assert.deepEqual( + out.map((g) => g.url), + ['b', 'a', 'c'], + ); +}); + +test('addRecentGif ignores an empty url', () => { + assert.deepEqual(addRecentGif([gif('a')], gif('')), [gif('a')]); +}); + +test('addRecentGif caps the list at 16, dropping the oldest', () => { + const sixteen = Array.from({ length: 16 }, (_, i) => gif(`u${i}`)); + const out = addRecentGif(sixteen, gif('new')); + assert.equal(out.length, 16); + assert.equal(out[0].url, 'new'); + assert.equal( + out.some((g) => g.url === 'u15'), + false, + ); +}); + +test('addRecentGif does not mutate its input', () => { + const input = [gif('a'), gif('b')]; + const before = input.map((g) => g.url); + addRecentGif(input, gif('c')); + assert.deepEqual( + input.map((g) => g.url), + before, + ); +}); diff --git a/src/app/state/recentGifs.ts b/src/app/state/recentGifs.ts new file mode 100644 index 000000000..b5706e30f --- /dev/null +++ b/src/app/state/recentGifs.ts @@ -0,0 +1,48 @@ +import { atom } from 'jotai'; +import { atomWithStorage, createJSONStorage } from 'jotai/utils'; + +export type RecentGif = { + url: string; + width: number; + height: number; +}; + +const STORAGE_KEY = 'cinny_recent_gifs_v1'; +const MAX_RECENT_GIFS = 16; + +// getOnInit reads localStorage synchronously so the Recent row is present on the +// first render of the GIF picker (no flash of the empty default). +const internalAtom = atomWithStorage( + STORAGE_KEY, + [], + createJSONStorage(() => localStorage), + { getOnInit: true }, +); + +/** + * Global atom: the most recently sent GIFs, newest first, deduped by url, capped + * at MAX_RECENT_GIFS. Backed by localStorage (device-local convenience). + */ +export const recentGifsAtom = atom( + (get): RecentGif[] => get(internalAtom), + (_get, set, updater: RecentGif[] | ((prev: RecentGif[]) => RecentGif[])) => { + set(internalAtom, (prev) => { + const prevList = Array.isArray(prev) ? prev : []; + return typeof updater === 'function' ? updater(prevList) : updater; + }); + }, +); + +/** + * Prepend a GIF: 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 addRecentGif = ( + prev: RecentGif[], + gif: RecentGif, + max = MAX_RECENT_GIFS, +): RecentGif[] => { + if (!gif.url) return prev; + const withoutDupe = prev.filter((g) => g.url !== gif.url); + return [gif, ...withoutDupe].slice(0, max); +};