diff --git a/src/app/features/room/message/styles.css.ts b/src/app/features/room/message/styles.css.ts index 7dacf14d0..41fe555b3 100644 --- a/src/app/features/room/message/styles.css.ts +++ b/src/app/features/room/message/styles.css.ts @@ -1,4 +1,4 @@ -import { style } from '@vanilla-extract/css'; +import { globalStyle, style } from '@vanilla-extract/css'; import { DefaultReset, FocusOutline, color, config, toRem } from 'folds'; export const MessageBase = style({ @@ -77,3 +77,14 @@ export const ReactionsMore = style([ export const ReactionsTooltipText = style({ wordBreak: 'break-word', }); + +// [Gitea #199] Long-press opens the message action sheet on touch screens. On +// iOS a long-press otherwise belongs to the system: a link/image callout, or +// the text-selection loupe, both of which race (and win against) the 450 ms +// press. No callout on message rows, and no selection while a finger is +// down; copying text is in the sheet. +globalStyle('[data-touch-actions]', { WebkitTouchCallout: 'none' }); +globalStyle('[data-touch-actions][data-pressing], [data-touch-actions][data-pressing] *', { + userSelect: 'none', + WebkitUserSelect: 'none', +}); diff --git a/src/app/hooks/useLongPress.ts b/src/app/hooks/useLongPress.ts index 1a13b81ff..56b8219da 100644 --- a/src/app/hooks/useLongPress.ts +++ b/src/app/hooks/useLongPress.ts @@ -17,22 +17,37 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) { const timer = useRef(undefined); const start = useRef<{ x: number; y: number } | null>(null); const fired = useRef(false); + // [Gitea #199] The element being pressed carries `data-pressing` (set on the + // DOM directly, no re-render) so iOS can't start a text selection under + // the finger; see `[data-touch-actions]` in room/message/styles.css.ts. + const pressed = useRef(null); - const clear = useCallback(() => { + const stopTimer = useCallback(() => { if (timer.current !== undefined) window.clearTimeout(timer.current); timer.current = undefined; start.current = null; }, []); + // The finger is up (or the gesture was cancelled): only now drop + // `data-pressing`, so the system selection can't start under a finger that + // is still down after the sheet opened. + const clear = useCallback(() => { + stopTimer(); + pressed.current?.removeAttribute('data-pressing'); + pressed.current = null; + }, [stopTimer]); + const onTouchStart = useCallback( (evt: React.TouchEvent) => { if (!coarse || evt.touches.length !== 1) return; const t = evt.touches[0]; start.current = { x: t.clientX, y: t.clientY }; fired.current = false; + pressed.current = evt.currentTarget as HTMLElement; + pressed.current.setAttribute('data-pressing', ''); timer.current = window.setTimeout(() => { const s = start.current; - clear(); + stopTimer(); if (!s) return; fired.current = true; window.setTimeout(() => { @@ -41,7 +56,7 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) { onLongPress(s.x, s.y); }, LONG_PRESS_MS); }, - [coarse, clear, onLongPress], + [coarse, stopTimer, onLongPress], ); const onTouchMove = useCallback( @@ -72,7 +87,16 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) { return { coarse, - handlers: coarse ? { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: clear } : {}, + handlers: coarse + ? { + // [Gitea #199] Marks the element for the iOS hardening CSS. + 'data-touch-actions': '', + onTouchStart, + onTouchMove, + onTouchEnd, + onTouchCancel: clear, + } + : {}, /** True for ~1 s after a long-press fired (drop the Android contextmenu echo). */ suppressContextMenu: fired, };