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
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
296 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|