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:
@@ -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
|
- Giphy-powered picker accessible from the composer toolbar
|
||||||
- The button is only shown when `gifApiKey` is set in `config.json`
|
- The button is only shown when `gifApiKey` is set in `config.json`
|
||||||
- Selected GIFs are sent as `m.image` events
|
- 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
|
- Picker UI is styled with TDS variables when the TDS theme is active
|
||||||
- Located at `src/app/components/GifPicker.tsx`
|
- Located at `src/app/components/GifPicker.tsx`
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||||
import { IGif } from '@giphy/js-types';
|
import { IGif } from '@giphy/js-types';
|
||||||
import { Box, color, config } from 'folds';
|
import { Box, color, config } from 'folds';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { addRecentGif, RecentGif, recentGifsAtom } from '../state/recentGifs';
|
||||||
|
|
||||||
const PICKER_WIDTH = 312;
|
const PICKER_WIDTH = 312;
|
||||||
const PICKER_WIDTH_CSS = `min(${PICKER_WIDTH}px, calc(100vw - 16px))`;
|
const PICKER_WIDTH_CSS = `min(${PICKER_WIDTH}px, calc(100vw - 16px))`;
|
||||||
@@ -15,22 +17,108 @@ type GifPickerInnerProps = {
|
|||||||
lotusTerminal: boolean;
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 2px',
|
||||||
|
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
color: 'var(--lt-accent-orange)',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`// ${text.toUpperCase()}`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '2px 2px 4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
opacity: 0.6,
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentGifs({
|
||||||
|
recents,
|
||||||
|
lotusTerminal,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
recents: RecentGif[];
|
||||||
|
lotusTerminal: boolean;
|
||||||
|
onPick: (url: string, width: number, height: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<SectionLabel text="Recent" lotusTerminal={lotusTerminal} />
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}>
|
||||||
|
{recents.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g.url}
|
||||||
|
type="button"
|
||||||
|
aria-label="Send recent GIF"
|
||||||
|
onClick={() => onPick(g.url, g.width, g.height)}
|
||||||
|
style={{
|
||||||
|
padding: 0,
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
height: 72,
|
||||||
|
borderRadius: lotusTerminal ? '4px' : '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={g.url}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInnerProps) {
|
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(
|
const handleClick = useCallback(
|
||||||
(gif: IGif, e: React.SyntheticEvent) => {
|
(gif: IGif, e: React.SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const r = gif.images.downsized ?? gif.images.original;
|
const r = gif.images.downsized ?? gif.images.original;
|
||||||
const { url } = r;
|
sendGif(r.url, Number(r.width) || 200, Number(r.height) || 200);
|
||||||
const width = Number(r.width) || 200;
|
|
||||||
const height = Number(r.height) || 200;
|
|
||||||
onSelect(url, width, height);
|
|
||||||
requestClose();
|
|
||||||
},
|
},
|
||||||
[onSelect, requestClose],
|
[sendGif],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showRecents = recents.length > 0 && !(term ?? '').trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
|
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
|
||||||
{lotusTerminal && (
|
{lotusTerminal && (
|
||||||
@@ -57,6 +145,9 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
<div
|
<div
|
||||||
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
|
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
|
||||||
>
|
>
|
||||||
|
{showRecents && (
|
||||||
|
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={sendGif} />
|
||||||
|
)}
|
||||||
<Grid
|
<Grid
|
||||||
key={searchKey}
|
key={searchKey}
|
||||||
fetchGifs={fetchGifs}
|
fetchGifs={fetchGifs}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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 { 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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