diff --git a/src/app/components/GifPicker.tsx b/src/app/components/GifPicker.tsx index d9576e997..2b94de467 100644 --- a/src/app/components/GifPicker.tsx +++ b/src/app/components/GifPicker.tsx @@ -4,6 +4,8 @@ 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'; @@ -120,13 +122,31 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne const sendGif = useCallback( (gif: RecentGif) => { - setRecents((prev) => addRecentGif(prev, gif)); + 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(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(); @@ -135,14 +155,15 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne gif.images.fixed_width_small_still?.url ?? gif.images.downsized_still?.url ?? gif.images.original_still?.url; - sendGif({ + pick({ url: r.url, width: Number(r.width) || 200, height: Number(r.height) || 200, previewUrl, + title: gif.title, }); }, - [sendGif], + [pick], ); const showRecents = recents.length > 0 && !(term ?? '').trim(); @@ -150,7 +171,6 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne // 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 containerRef = useRef(null); const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16); useElementSizeObserver( useCallback(() => containerRef.current, []), @@ -180,11 +200,22 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne + {pending && ( + { + setPending(undefined); + sendGif(pending); + }} + onCancel={() => setPending(undefined)} + /> + )}
{showRecents && ( - + )} (); + const stickerUseAuthentication = useMediaAuthentication(); + const sendSticker = (info: EmojiItemInfo, close: boolean) => { + onStickerSelect?.(info.data, info.shortcode, info.label); + setRecentStickers((prev) => + addRecentSticker(prev, { url: info.data, shortcode: info.shortcode, body: info.label }), + ); + setPendingSticker(undefined); + if (close) requestClose(); + }; + const handleGroupItemClick: MouseEventHandler = (evt) => { const targetEl = targetFromEvent(evt.nativeEvent, 'button'); const emojiInfo = targetEl && getEmojiItemInfo(targetEl); - if (!emojiInfo) return; + if (!emojiInfo) { + if (pendingSticker) setPendingSticker(undefined); + return; + } if (emojiInfo.type === EmojiType.Emoji) { onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); @@ -533,14 +552,12 @@ export function EmojiBoard({ onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); } if (emojiInfo.type === EmojiType.Sticker) { - onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label); - setRecentStickers((prev) => - addRecentSticker(prev, { - url: emojiInfo.data, - shortcode: emojiInfo.shortcode, - body: emojiInfo.label, - }), - ); + if (wasTouch() && pendingSticker?.data !== emojiInfo.data) { + setPendingSticker(emojiInfo); + return; + } + sendSticker(emojiInfo, !evt.altKey && !evt.shiftKey); + return; } if (!evt.altKey && !evt.shiftKey) requestClose(); }; @@ -670,7 +687,18 @@ export function EmojiBoard({ {tab === EmojiBoardTab.Sticker && groups.length === 0 && } - + {pendingSticker && tab === EmojiBoardTab.Sticker ? ( + sendSticker(pendingSticker, true)} + onCancel={() => setPendingSticker(undefined)} + /> + ) : ( + + )} ); diff --git a/src/app/components/tap-to-send/TapToSendBar.tsx b/src/app/components/tap-to-send/TapToSendBar.tsx new file mode 100644 index 000000000..19b81df84 --- /dev/null +++ b/src/app/components/tap-to-send/TapToSendBar.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useRef } from 'react'; +import { Box, Button, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds'; + +type TapToSendBarProps = { + previewUrl?: string; + label: string; + onSend: () => void; + onCancel: () => void; +}; + +/** + * [Gitea #147] Fat-finger guard for touch screens: the first tap on a sticker + * or GIF parks it here with a preview; "Send" (or a second tap on the same + * item) sends it. + */ +export function TapToSendBar({ previewUrl, label, onSend, onCancel }: TapToSendBarProps) { + const liveRef = useRef(null); + useEffect(() => { + liveRef.current?.focus?.(); + }, [label]); + + return ( + + {previewUrl && ( + + )} + + + Tap again to send + + + {label} + + + + + + + + ); +} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 5c9a49d72..f59aee7f5 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1339,12 +1339,20 @@ export const RoomInput = forwardRef( {(gifOpen: boolean, setGifOpen) => ( ) { + const coarse = useMediaQuery('(pointer: coarse)'); + const last = useRef(0); + + // Listen on the document (the ref'd element may be remounted, e.g. on a tab + // switch) and only count touches that land inside it. + useEffect(() => { + if (!coarse) return undefined; + const mark = (evt: TouchEvent) => { + const el = ref.current; + if (el && evt.target instanceof Node && !el.contains(evt.target)) return; + last.current = Date.now(); + }; + document.addEventListener('touchstart', mark, { passive: true, capture: true }); + document.addEventListener('touchend', mark, { passive: true, capture: true }); + return () => { + document.removeEventListener('touchstart', mark, { capture: true }); + document.removeEventListener('touchend', mark, { capture: true }); + }; + }, [ref, coarse]); + + const wasTouch = useCallback(() => coarse && Date.now() - last.current < RECENT_MS, [coarse]); + return { coarse, wasTouch }; +}