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 (
+
{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);
+};