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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<RecentGif[]>(
|
||||
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);
|
||||
};
|
||||
Reference in New Issue
Block a user