Files
cinny/src/app/components/GifPicker.tsx
T
jaredandClaude Opus 5 8d11a62e14
CI / Build & Quality Checks (push) Successful in 1m59s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Canceled after 4m38s
feat(composer): tap once to preview, again to send stickers/GIFs on touch (#147)
Fat-finger guard for phones. On coarse-pointer devices the first tap on a
sticker (emoji board Sticker tab) or a GIF parks it in a small bar with a
thumbnail, name, Send and Cancel; a second tap on the same item or Send
sends it; tapping a different item switches the preview; tapping empty
picker space or Cancel clears it. Mouse clicks, keyboard activation and
screen-reader activation (bare click without touch events) still send in
one step, and the emoji tab is untouched.

useRecentTouch records touches inside the picker at the document level so
it survives the Sticker tab remount, and reports false without a coarse
pointer.

Also fixes the compact composer's GIF picker opening mostly off-screen:
end-aligning the 312px popout to a button near the left edge of the
overflow row pushed it to x≈-95; it now anchors to the row itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:15:09 -04:00

296 lines
8.9 KiB
TypeScript

import React, { useCallback, useRef, useState } 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 { TapToSendBar } from './tap-to-send/TapToSendBar';
import { useRecentTouch } from '../hooks/useRecentTouch';
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
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))`;
type GifPickerInnerProps = {
onSelect: (url: string, width: number, height: number) => void;
requestClose: () => void;
lotusTerminal: boolean;
};
// Small monospace section header matching the picker's `// GIF_SEARCH` treatment
// (lotusTerminal) / a muted label otherwise.
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",
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.1em',
color: 'var(--lt-accent-orange)',
userSelect: 'none',
}}
>
{`// ${text.toUpperCase()}`}
</div>
);
}
return (
<div
id={id}
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: (gif: RecentGif) => void;
}) {
return (
<div style={{ marginBottom: 8 }}>
<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 ${i + 1} of ${recents.length}`}
onClick={() => onPick(g)}
style={{
padding: 0,
border: 'none',
background: 'transparent',
cursor: 'pointer',
height: 72,
borderRadius: lotusTerminal ? '4px' : '8px',
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.previewUrl ?? g.url}
alt=""
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</button>
))}
</div>
</div>
);
}
function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInnerProps) {
const { fetchGifs, searchKey, term } = React.useContext(SearchContext);
const [recents, setRecents] = useAtom(recentGifsAtom);
const sendGif = useCallback(
(gif: RecentGif) => {
const { url, width, height, previewUrl } = gif;
setRecents((prev) => addRecentGif(prev, { url, width, height, previewUrl }));
onSelect(gif.url, gif.width, gif.height);
requestClose();
},
[onSelect, requestClose, setRecents],
);
// [Gitea #147] Touch: first tap parks the GIF in a preview bar, second tap
// (or Send) sends. Mouse/keyboard/screen reader: one step, as before.
const containerRef = useRef<HTMLDivElement>(null);
const { wasTouch } = useRecentTouch(containerRef);
const [pending, setPending] = useState<(RecentGif & { title?: string }) | undefined>();
const pick = useCallback(
(gif: RecentGif & { title?: string }) => {
if (wasTouch() && pending?.url !== gif.url) {
setPending(gif);
return;
}
setPending(undefined);
sendGif(gif);
},
[wasTouch, pending, sendGif],
);
const handleClick = useCallback(
(gif: IGif, e: React.SyntheticEvent) => {
e.preventDefault();
const r = gif.images.downsized ?? gif.images.original;
const previewUrl =
gif.images.fixed_width_small_still?.url ??
gif.images.downsized_still?.url ??
gif.images.original_still?.url;
pick({
url: r.url,
width: Number(r.width) || 200,
height: Number(r.height) || 200,
previewUrl,
title: gif.title,
});
},
[pick],
);
const showRecents = recents.length > 0 && !(term ?? '').trim();
// The container is min(312px, 100vw-16); feed the Grid the live pixel width
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
// narrower than 312px with a fixed 296px grid.
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
useElementSizeObserver(
useCallback(() => containerRef.current, []),
useCallback((w) => setGridWidth(Math.max(1, Math.floor(w) - 16)), []),
);
return (
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }} ref={containerRef}>
{lotusTerminal && (
<div
style={{
padding: '5px 10px 4px',
borderBottom: '1px solid color-mix(in srgb, var(--lt-accent-orange) 20%, transparent)',
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.1em',
color: 'var(--lt-accent-orange)',
userSelect: 'none',
}}
>
{'// GIF_SEARCH'}
</div>
)}
<Box style={{ padding: '8px 8px 4px' }}>
<div style={{ width: '100%', borderRadius: lotusTerminal ? '4px' : '8px' }}>
<SearchBar />
</div>
</Box>
{pending && (
<TapToSendBar
previewUrl={pending.previewUrl ?? pending.url}
label={pending.title || 'GIF'}
onSend={() => {
setPending(undefined);
sendGif(pending);
}}
onCancel={() => setPending(undefined)}
/>
)}
<div
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
>
{showRecents && (
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={pick} />
)}
<Grid
key={searchKey}
fetchGifs={fetchGifs}
width={gridWidth}
columns={2}
gutter={4}
onGifClick={handleClick}
hideAttribution={false}
noLink
/>
</div>
</Box>
);
}
type GifPickerProps = {
apiKey: string;
onSelect: (url: string, width: number, height: number) => void;
requestClose: () => void;
};
export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
// Searches (and every keystroke) go straight to Giphy's API, so the picker
// is opt-in (Settings → Messages) and must not render or fetch until then.
if (!gifPickerEnabled) {
return null;
}
const containerStyle = lotusTerminal
? {
background: 'var(--lt-bg-secondary)',
border: '1px solid color-mix(in srgb, var(--lt-accent-orange) 35%, transparent)',
borderRadius: '4px',
overflow: 'hidden',
boxShadow:
'0 4px 24px color-mix(in srgb, var(--lt-accent-orange) 10%, transparent), 0 0 0 1px color-mix(in srgb, var(--lt-accent-orange) 8%, transparent)',
width: PICKER_WIDTH_CSS,
}
: {
background: color.Surface.Container,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R400,
overflow: 'hidden',
boxShadow: color.Other.Shadow,
width: PICKER_WIDTH_CSS,
};
return (
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: true,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
allowOutsideClick: true,
}}
>
<Box
direction="Column"
data-gif-terminal={lotusTerminal ? '' : undefined}
style={containerStyle}
>
<SearchContextManager apiKey={apiKey} initialTerm="">
<GifPickerInner
onSelect={onSelect}
requestClose={requestClose}
lotusTerminal={!!lotusTerminal}
/>
</SearchContextManager>
</Box>
</FocusTrap>
);
}