fix(gif): still previews + a11y for recent GIFs
Address findings from 2 review agents on the recent-GIFs row:
- Motion/perf: the Recent row rendered up to 16 full animated GIFs at
once (autoplaying). Capture a small still image at pick time
(fixed_width_small_still / *_still) into RecentGif.previewUrl and render
that for the thumbnail, so recents no longer autoplay. Pre-existing
recents without a preview fall back to the animated url. Re-send still
uses the animated url, so the sent m.image is unchanged.
- a11y: the recent buttons all had the identical label "Send recent GIF".
Give them positional labels ("Send recent GIF N of M") and wrap the grid
in a role="group" labelled by the "Recent" section heading, so the row
is a distinguishable, announced group.
Correctness review found no bugs (write-before-unmount, term gating,
dedupe, re-send fidelity all verified).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,10 +19,21 @@ type GifPickerInnerProps = {
|
||||
|
||||
// Small monospace section header matching the picker's `// GIF_SEARCH` treatment
|
||||
// (lotusTerminal) / a muted label otherwise.
|
||||
function SectionLabel({ text, lotusTerminal }: { text: string; lotusTerminal: boolean }) {
|
||||
const RECENT_LABEL_ID = 'gif-picker-recent-label';
|
||||
|
||||
function SectionLabel({
|
||||
text,
|
||||
lotusTerminal,
|
||||
id,
|
||||
}: {
|
||||
text: string;
|
||||
lotusTerminal: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
if (lotusTerminal) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
style={{
|
||||
padding: '4px 2px',
|
||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
|
||||
@@ -39,6 +50,7 @@ function SectionLabel({ text, lotusTerminal }: { text: string; lotusTerminal: bo
|
||||
}
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
style={{
|
||||
padding: '2px 2px 4px',
|
||||
fontSize: '11px',
|
||||
@@ -60,18 +72,22 @@ function RecentGifs({
|
||||
}: {
|
||||
recents: RecentGif[];
|
||||
lotusTerminal: boolean;
|
||||
onPick: (url: string, width: number, height: number) => void;
|
||||
onPick: (gif: RecentGif) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<SectionLabel text="Recent" lotusTerminal={lotusTerminal} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}>
|
||||
{recents.map((g) => (
|
||||
<SectionLabel text="Recent" lotusTerminal={lotusTerminal} id={RECENT_LABEL_ID} />
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={RECENT_LABEL_ID}
|
||||
style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}
|
||||
>
|
||||
{recents.map((g, i) => (
|
||||
<button
|
||||
key={g.url}
|
||||
type="button"
|
||||
aria-label="Send recent GIF"
|
||||
onClick={() => onPick(g.url, g.width, g.height)}
|
||||
aria-label={`Send recent GIF ${i + 1} of ${recents.length}`}
|
||||
onClick={() => onPick(g)}
|
||||
style={{
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
@@ -82,8 +98,10 @@ function RecentGifs({
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* A still preview (falls back to the animated url for pre-existing
|
||||
recents) so the Recent row doesn't autoplay many GIFs at once. */}
|
||||
<img
|
||||
src={g.url}
|
||||
src={g.previewUrl ?? g.url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
@@ -100,9 +118,9 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
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);
|
||||
(gif: RecentGif) => {
|
||||
setRecents((prev) => addRecentGif(prev, gif));
|
||||
onSelect(gif.url, gif.width, gif.height);
|
||||
requestClose();
|
||||
},
|
||||
[onSelect, requestClose, setRecents],
|
||||
@@ -112,7 +130,16 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
(gif: IGif, e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
const r = gif.images.downsized ?? gif.images.original;
|
||||
sendGif(r.url, Number(r.width) || 200, Number(r.height) || 200);
|
||||
const previewUrl =
|
||||
gif.images.fixed_width_small_still?.url ??
|
||||
gif.images.downsized_still?.url ??
|
||||
gif.images.original_still?.url;
|
||||
sendGif({
|
||||
url: r.url,
|
||||
width: Number(r.width) || 200,
|
||||
height: Number(r.height) || 200,
|
||||
previewUrl,
|
||||
});
|
||||
},
|
||||
[sendGif],
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ export type RecentGif = {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
/** A small still image used for the picker thumbnail (so recents don't all autoplay). */
|
||||
previewUrl?: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'cinny_recent_gifs_v1';
|
||||
|
||||
Reference in New Issue
Block a user