feat(composer): tap once to preview, again to send stickers/GIFs on touch (#147)
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
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
This commit is contained in:
@@ -4,6 +4,8 @@ import { useAtom } from 'jotai';
|
|||||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||||
import { IGif } from '@giphy/js-types';
|
import { IGif } from '@giphy/js-types';
|
||||||
import { Box, color, config } from 'folds';
|
import { Box, color, config } from 'folds';
|
||||||
|
import { TapToSendBar } from './tap-to-send/TapToSendBar';
|
||||||
|
import { useRecentTouch } from '../hooks/useRecentTouch';
|
||||||
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
|
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
@@ -120,13 +122,31 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
|
|
||||||
const sendGif = useCallback(
|
const sendGif = useCallback(
|
||||||
(gif: RecentGif) => {
|
(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);
|
onSelect(gif.url, gif.width, gif.height);
|
||||||
requestClose();
|
requestClose();
|
||||||
},
|
},
|
||||||
[onSelect, requestClose, setRecents],
|
[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(
|
const handleClick = useCallback(
|
||||||
(gif: IGif, e: React.SyntheticEvent) => {
|
(gif: IGif, e: React.SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -135,14 +155,15 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
gif.images.fixed_width_small_still?.url ??
|
gif.images.fixed_width_small_still?.url ??
|
||||||
gif.images.downsized_still?.url ??
|
gif.images.downsized_still?.url ??
|
||||||
gif.images.original_still?.url;
|
gif.images.original_still?.url;
|
||||||
sendGif({
|
pick({
|
||||||
url: r.url,
|
url: r.url,
|
||||||
width: Number(r.width) || 200,
|
width: Number(r.width) || 200,
|
||||||
height: Number(r.height) || 200,
|
height: Number(r.height) || 200,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
|
title: gif.title,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[sendGif],
|
[pick],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showRecents = recents.length > 0 && !(term ?? '').trim();
|
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
|
// 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
|
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
|
||||||
// narrower than 312px with a fixed 296px grid.
|
// narrower than 312px with a fixed 296px grid.
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
|
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
|
||||||
useElementSizeObserver(
|
useElementSizeObserver(
|
||||||
useCallback(() => containerRef.current, []),
|
useCallback(() => containerRef.current, []),
|
||||||
@@ -180,11 +200,22 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
<SearchBar />
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
{pending && (
|
||||||
|
<TapToSendBar
|
||||||
|
previewUrl={pending.previewUrl ?? pending.url}
|
||||||
|
label={pending.title || 'GIF'}
|
||||||
|
onSend={() => {
|
||||||
|
setPending(undefined);
|
||||||
|
sendGif(pending);
|
||||||
|
}}
|
||||||
|
onCancel={() => setPending(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
|
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
|
||||||
>
|
>
|
||||||
{showRecents && (
|
{showRecents && (
|
||||||
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={sendGif} />
|
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={pick} />
|
||||||
)}
|
)}
|
||||||
<Grid
|
<Grid
|
||||||
key={searchKey}
|
key={searchKey}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { useThrottle } from '../../hooks/useThrottle';
|
|||||||
import { addRecentEmoji } from '../../plugins/recent-emoji';
|
import { addRecentEmoji } from '../../plugins/recent-emoji';
|
||||||
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
|
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useRecentTouch } from '../../hooks/useRecentTouch';
|
||||||
|
import { TapToSendBar } from '../tap-to-send/TapToSendBar';
|
||||||
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
|
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
|
||||||
import { getEmoticonSearchStr } from '../../plugins/utils';
|
import { getEmoticonSearchStr } from '../../plugins/utils';
|
||||||
import {
|
import {
|
||||||
@@ -52,7 +54,7 @@ import {
|
|||||||
EmojiGroup,
|
EmojiGroup,
|
||||||
EmojiBoardLayout,
|
EmojiBoardLayout,
|
||||||
} from './components';
|
} from './components';
|
||||||
import { EmojiBoardTab, EmojiType } from './types';
|
import { EmojiBoardTab, EmojiItemInfo, EmojiType } from './types';
|
||||||
import { VirtualTile } from '../virtualizer';
|
import { VirtualTile } from '../virtualizer';
|
||||||
|
|
||||||
const RECENT_GROUP_ID = 'recent_group';
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
@@ -518,10 +520,27 @@ export function EmojiBoard({
|
|||||||
});
|
});
|
||||||
const vItems = virtualizer.getVirtualItems();
|
const vItems = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
// [Gitea #147] Touch: first tap on a sticker parks it in a preview bar,
|
||||||
|
// second tap (or the bar's Send) sends. Mouse/keyboard/screen reader: one step.
|
||||||
|
const { wasTouch } = useRecentTouch(contentScrollRef);
|
||||||
|
const [pendingSticker, setPendingSticker] = useState<EmojiItemInfo | undefined>();
|
||||||
|
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 handleGroupItemClick: MouseEventHandler = (evt) => {
|
||||||
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
|
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
|
||||||
const emojiInfo = targetEl && getEmojiItemInfo(targetEl);
|
const emojiInfo = targetEl && getEmojiItemInfo(targetEl);
|
||||||
if (!emojiInfo) return;
|
if (!emojiInfo) {
|
||||||
|
if (pendingSticker) setPendingSticker(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (emojiInfo.type === EmojiType.Emoji) {
|
if (emojiInfo.type === EmojiType.Emoji) {
|
||||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
@@ -533,14 +552,12 @@ export function EmojiBoard({
|
|||||||
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
}
|
}
|
||||||
if (emojiInfo.type === EmojiType.Sticker) {
|
if (emojiInfo.type === EmojiType.Sticker) {
|
||||||
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
|
if (wasTouch() && pendingSticker?.data !== emojiInfo.data) {
|
||||||
setRecentStickers((prev) =>
|
setPendingSticker(emojiInfo);
|
||||||
addRecentSticker(prev, {
|
return;
|
||||||
url: emojiInfo.data,
|
}
|
||||||
shortcode: emojiInfo.shortcode,
|
sendSticker(emojiInfo, !evt.altKey && !evt.shiftKey);
|
||||||
body: emojiInfo.label,
|
return;
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||||
};
|
};
|
||||||
@@ -670,7 +687,18 @@ export function EmojiBoard({
|
|||||||
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
|
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
|
||||||
</EmojiGroupHolder>
|
</EmojiGroupHolder>
|
||||||
</Box>
|
</Box>
|
||||||
<Preview previewAtom={previewAtom} />
|
{pendingSticker && tab === EmojiBoardTab.Sticker ? (
|
||||||
|
<TapToSendBar
|
||||||
|
previewUrl={
|
||||||
|
mxcUrlToHttp(mx, pendingSticker.data, stickerUseAuthentication) ?? undefined
|
||||||
|
}
|
||||||
|
label={pendingSticker.label}
|
||||||
|
onSend={() => sendSticker(pendingSticker, true)}
|
||||||
|
onCancel={() => setPendingSticker(undefined)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Preview previewAtom={previewAtom} />
|
||||||
|
)}
|
||||||
</EmojiBoardLayout>
|
</EmojiBoardLayout>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
liveRef.current?.focus?.();
|
||||||
|
}, [label]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={liveRef}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
tabIndex={-1}
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="300"
|
||||||
|
style={{
|
||||||
|
margin: `0 ${config.space.S300} ${config.space.S200}`,
|
||||||
|
padding: config.space.S200,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewUrl && (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
width: toRem(40),
|
||||||
|
height: toRem(40),
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Tap again to send
|
||||||
|
</Text>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Button size="300" variant="Primary" fill="Solid" radii="300" onClick={onSend}>
|
||||||
|
<Text size="B300">Send</Text>
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="300"
|
||||||
|
onClick={onCancel}
|
||||||
|
aria-label="Cancel"
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1339,12 +1339,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
{(gifOpen: boolean, setGifOpen) => (
|
{(gifOpen: boolean, setGifOpen) => (
|
||||||
<PopOut
|
<PopOut
|
||||||
offset={16}
|
offset={16}
|
||||||
alignOffset={-44}
|
// [Gitea #147] In the compact overflow row the GIF button sits
|
||||||
|
// near the left edge; end-aligning a 312px picker to it pushed
|
||||||
|
// most of it off-screen. Anchor to the row instead.
|
||||||
|
alignOffset={compact ? 0 : -44}
|
||||||
position="Top"
|
position="Top"
|
||||||
align="End"
|
align="End"
|
||||||
anchor={
|
anchor={
|
||||||
gifOpen
|
gifOpen
|
||||||
? (gifBtnRef.current?.getBoundingClientRect() ?? undefined)
|
? ((compact
|
||||||
|
? gifBtnRef.current?.closest('#composer-more-actions')
|
||||||
|
: gifBtnRef.current
|
||||||
|
)?.getBoundingClientRect() ??
|
||||||
|
gifBtnRef.current?.getBoundingClientRect() ??
|
||||||
|
undefined)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
content={
|
content={
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { RefObject, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useMediaQuery } from './useMediaQuery';
|
||||||
|
|
||||||
|
const RECENT_MS = 700;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #147] Tells whether a click was produced by a finger. Records the
|
||||||
|
* last touch inside `ref`; `wasTouch()` is true for a short window after it.
|
||||||
|
* Keyboard activation and screen-reader activation (TalkBack/VoiceOver send a
|
||||||
|
* bare click, no touch events) report false, so they keep one-step behaviour.
|
||||||
|
* Always false on devices without a coarse pointer.
|
||||||
|
*/
|
||||||
|
export function useRecentTouch(ref: RefObject<HTMLElement | null>) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user