From 8d11a62e144b2788ad0922226a71ae682b90cf33 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 20 Sep 2026 00:15:09 -0400 Subject: [PATCH] feat(composer): tap once to preview, again to send stickers/GIFs on touch (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/GifPicker.tsx | 41 ++++++++-- src/app/components/emoji-board/EmojiBoard.tsx | 50 ++++++++++--- .../components/tap-to-send/TapToSendBar.tsx | 75 +++++++++++++++++++ src/app/features/room/RoomInput.tsx | 12 ++- src/app/hooks/useRecentTouch.ts | 36 +++++++++ 5 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 src/app/components/tap-to-send/TapToSendBar.tsx create mode 100644 src/app/hooks/useRecentTouch.ts 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 }; +}