diff --git a/.changeset/mobile.md b/.changeset/mobile.md new file mode 100644 index 000000000..501b1bbd1 --- /dev/null +++ b/.changeset/mobile.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix iOS sync resume, network latency retries, iPad layout, and connecting banner during fast-path sync. Add missing items to mobile long-press message menu; fix bookmark toggle and iOS keyboard cover. diff --git a/docs/MOBILE_FIXES.md b/docs/MOBILE_FIXES.md new file mode 100644 index 000000000..7ab64d21b --- /dev/null +++ b/docs/MOBILE_FIXES.md @@ -0,0 +1,35 @@ +# Mobile UX Fixes + +This document tracks mobile-specific issues that need to be addressed in `feat/mobile`. + +## Issue #9: iOS keyboard show/hide triggers jump button + +**Problem**: Sometimes when opening/closing the keyboard on iOS, the jump to present button is displayed incorrectly. + +**Root Cause**: + +- iOS viewport height changes when keyboard appears/disappears +- Virtual keyboard causes viewport resize events +- Timeline scroll position calculation doesn't account for keyboard state +- Jump button visibility logic triggers on viewport changes + +**Proposed Fix**: + +- Detect iOS virtual keyboard state changes +- Exclude keyboard-triggered viewport changes from jump button logic +- Use `visualViewport` API instead of window.innerHeight on iOS +- Debounce jump button visibility checks during keyboard transitions +- Store keyboard state and ignore scroll position during keyboard animation + +**Implementation Notes**: + +- Use `window.visualViewport.height` vs `window.innerHeight` to detect keyboard +- Listen to `visualViewport` resize events +- Add keyboard state to timeline context +- Filter out scroll events during keyboard animation (~300ms) + +**Related Files**: + +- Timeline scroll handling +- Jump to present button logic +- iOS-specific viewport handling diff --git a/src/app/components/SwipeableChatWrapper.tsx b/src/app/components/SwipeableChatWrapper.tsx index d4a547298..9e151e94f 100644 --- a/src/app/components/SwipeableChatWrapper.tsx +++ b/src/app/components/SwipeableChatWrapper.tsx @@ -44,8 +44,8 @@ export function SwipeableChatWrapper({ if (active) { x.set(val); } else { - const swipeThreshold = 120; - const velocityThreshold = 0.5; + const swipeThreshold = 180; + const velocityThreshold = 1.2; if (val > swipeThreshold || (vx > velocityThreshold && dx > 0 && val > 0)) { onOpenSidebar?.(); diff --git a/src/app/components/SwipeableMessageWrapper.tsx b/src/app/components/SwipeableMessageWrapper.tsx index 58fc9293e..9ae30cb99 100644 --- a/src/app/components/SwipeableMessageWrapper.tsx +++ b/src/app/components/SwipeableMessageWrapper.tsx @@ -18,9 +18,9 @@ function ActiveSwipeWrapper({ children, onReply }: { children: ReactNode; onRepl if (active) { const val = mx < 0 ? mx : 0; x.set(Math.max(-80, val)); - if (mx < -50 !== isReady) setIsReady(mx < -50); + if (mx < -80 !== isReady) setIsReady(mx < -80); } else { - if (mx < -50) onReply(); + if (mx < -80) onReply(); x.set(0); setIsReady(false); } diff --git a/src/app/components/SwipeableOverlayWrapper.tsx b/src/app/components/SwipeableOverlayWrapper.tsx index a77b802f5..477919845 100644 --- a/src/app/components/SwipeableOverlayWrapper.tsx +++ b/src/app/components/SwipeableOverlayWrapper.tsx @@ -40,8 +40,8 @@ export function SwipeableOverlayWrapper({ if (active) { x.set(val); } else { - const swipeThreshold = 100; - const velocityThreshold = 0.5; + const swipeThreshold = 150; + const velocityThreshold = 1.2; const swipedLeft = direction === 'left' && (val < -swipeThreshold || (vx > velocityThreshold && dx < 0)); diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 077519d17..0d465d6bc 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -65,6 +65,9 @@ export const EditorTextarea = style([ { flexGrow: 1, height: 'auto', + // Detect text direction per-keystroke so RTL text right-aligns automatically. + // Matches the unicodeBidi: 'plaintext' pattern used by MessageTextBody. + unicodeBidi: 'plaintext', padding: `${toRem(13)} 0 0`, selectors: { [`${EditorTextareaScroll}:first-child &`]: { diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index e76cce520..888610cc0 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -6,7 +6,7 @@ import { Node, createEditor } from 'slate'; import type { RenderLeafProps, RenderElementProps, RenderPlaceholderProps } from 'slate-react'; import { Slate, Editable, withReact, ReactEditor } from 'slate-react'; import { withHistory } from 'slate-history'; -import { mobileOrTablet } from '$utils/user-agent'; +import { isPhone, mobileOrTablet } from '$utils/user-agent'; import { BlockType } from './types'; import { RenderElement, RenderLeaf } from './Elements'; import type { CustomElement } from './slate'; @@ -114,6 +114,9 @@ export const CustomEditor = forwardRef( const singleLineWidthOffsetRef = useRef(0); const latestValueRef = useRef(editor.children); const isMultilineRef = useRef(false); + // Tracks whether a triggerAutoCapitalize rAF is already queued to avoid stacking + // multiple rAFs when content changes fire rapidly (e.g. IME composition). + const autocapPendingRef = useRef(false); const [isMultiline, setIsMultiline] = useState(false); const [measurementVersion, setMeasurementVersion] = useState(0); const hasBefore = Boolean(before); @@ -348,8 +351,29 @@ export const CustomEditor = forwardRef( updateMultilineLayout(latestValueRef.current); }, [measurementVersion, updateMultilineLayout]); + // Mobile OSes (iOS and Android) do not reliably capitalise the first letter in an empty + // contenteditable. Both platforms render a zero-width placeholder character (\uFEFF) + // inside the Slate DOM node to maintain the cursor, and their keyboards interpret this + // as existing content — so they don't apply sentence-case to the next keystroke. + // Toggling the autocapitalize attribute from 'none' → 'sentences' on the focused + // contenteditable forces the keyboard to re-evaluate capitalisation state with no + // content changes, no focus shifts, and no keyboard dismissal. + const triggerAutoCapitalize = useCallback(() => { + if (!mobileOrTablet()) return; + if (autocapPendingRef.current) return; + const el = editableRef.current; + if (!el) return; + autocapPendingRef.current = true; + el.setAttribute('autocapitalize', 'none'); + requestAnimationFrame(() => { + el.setAttribute('autocapitalize', 'sentences'); + autocapPendingRef.current = false; + }); + }, []); + const handleChange = useCallback( (value: Descendant[]) => { + const prevText = latestValueRef.current.map((node) => Node.string(node)).join(''); latestValueRef.current = value; measurementCacheRef.current = null; if (multilineMeasureFrameRef.current !== null) { @@ -358,8 +382,15 @@ export const CustomEditor = forwardRef( } setMeasurementVersion((version) => version + 1); onChange?.(value); + // After a send, content goes from non-empty to empty while the editor stays focused. + // Trigger the autocap attribute toggle so the next message starts capitalised. + // onBlur keeps focus on the editor so isFocused() is true when this fires. + const nextText = value.map((node) => Node.string(node)).join(''); + if (prevText.length > 0 && nextText.length === 0 && ReactEditor.isFocused(editor)) { + triggerAutoCapitalize(); + } }, - [onChange] + [onChange, editor, triggerAutoCapitalize] ); const renderElement = useCallback( @@ -371,8 +402,10 @@ export const CustomEditor = forwardRef( const handleKeydown: KeyboardEventHandler = useCallback( (evt) => { - // mobile ignores config option - if (mobileOrTablet() && evt.key === 'Enter' && !evt.shiftKey) { + // Phones (on-screen keyboard) ignore the enter-to-send config option. + // Tablets with an external keyboard should still forward Enter to onKeyDown + // so RoomInput can honour the enterForNewline / mod+enter settings. + if (isPhone() && evt.key === 'Enter' && !evt.shiftKey) { return; } @@ -440,6 +473,20 @@ export const CustomEditor = forwardRef( onPaste={onPaste} // Defer to OS capitalization setting (respects iOS sentence-case toggle). autoCapitalize="sentences" + // Detect text direction per-message so RTL languages (Arabic, Hebrew, etc.) + // automatically right-align without any toggle. + dir="auto" + // Trigger autocap re-evaluation when the editor gains focus empty. + // This handles the initial tap-to-focus case: Slate's DOM contains a + // \uFEFF placeholder that the keyboard sees as existing content and so + // skips sentence-case. The attribute toggle forces a re-evaluation. + // autocapPendingRef prevents double-fire if handleChange also fires + // (e.g. the send clears content while focus is transferred). + onFocus={() => { + if (mobileOrTablet() && Node.string(editor).length === 0) { + triggerAutoCapitalize(); + } + }} // keeps focus after pressing send. onBlur={() => { if (mobileOrTablet()) ReactEditor.focus(editor); diff --git a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx index 938c27dcd..72decd462 100644 --- a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx +++ b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx @@ -60,6 +60,7 @@ export function AutocompleteMenu({ isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt), isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), escapeDeactivates: stopPropagation, + tabbableOptions: { displayCheck: 'none' }, }} > void; returnFocusOnDeactivate?: boolean; + /** Controls whether the FocusTrap is active. Pass false when rendering but hiding the board. */ + active?: boolean; onEmojiSelect?: (unicode: string, shortcode: string) => void; onCustomEmojiSelect?: (mxc: string, shortcode: string) => void; onStickerSelect?: (mxc: string, shortcode: string, label: string) => void; @@ -393,6 +395,7 @@ export function EmojiBoard({ imagePackRooms, requestClose, returnFocusOnDeactivate, + active = true, onEmojiSelect, onCustomEmojiSelect, onStickerSelect, @@ -534,6 +537,7 @@ export function EmojiBoard({ return ( ([]); - const containerRef = useRef(null); log.log('[Banner] Component render, queue length:', queue.length, 'banner:', banner); - // Adjust banner position for iOS keyboard - useEffect(() => { - // Only apply on iOS/browsers that support visualViewport - if (!('visualViewport' in window)) return undefined; - - const updatePosition = () => { - const container = containerRef.current; - if (!container) return; - - const visualViewport = window.visualViewport!; - // Calculate how much of the screen is covered by the keyboard - // When keyboard opens, visualViewport.height shrinks - const keyboardHeight = window.innerHeight - visualViewport.height; - - // Position the banner down by the keyboard height so it appears at the top of the visible area - // This puts it "halfway down the page" when keyboard covers half the screen - if (keyboardHeight > 0) { - container.style.top = `${keyboardHeight}px`; - } else { - // Reset to CSS default (env(safe-area-inset-top)) - container.style.top = ''; - } - }; - - const visualViewport = window.visualViewport!; - visualViewport.addEventListener('resize', updatePosition); - visualViewport.addEventListener('scroll', updatePosition); - updatePosition(); // Initial position - - return () => { - visualViewport.removeEventListener('resize', updatePosition); - visualViewport.removeEventListener('scroll', updatePosition); - }; - }, []); - // Push new notifications into the local queue. useEffect(() => { if (!banner) return; @@ -247,7 +211,7 @@ export function NotificationBanner() { log.log('[Banner] Rendering', queue.length, 'banners'); return ( -
+
{queue.map((n) => ( ))} diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index bafa45903..16e5b2842 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -3,6 +3,7 @@ import { Box, Header, Line, Scroll, Text, as } from 'folds'; import classNames from 'classnames'; import { ContainerColor } from '$styles/ContainerColor.css'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { mobileOrTabletLayout } from '$utils/user-agent'; import * as css from './style.css'; type PageRootProps = { @@ -16,7 +17,7 @@ export function PageRoot({ nav, children }: PageRootProps) { return ( {nav} - {screenSize !== ScreenSize.Mobile && ( + {screenSize !== ScreenSize.Mobile && !mobileOrTabletLayout() && ( )} {children} diff --git a/src/app/components/page/style.css.ts b/src/app/components/page/style.css.ts index 7ca01766a..2e9abb960 100644 --- a/src/app/components/page/style.css.ts +++ b/src/app/components/page/style.css.ts @@ -60,7 +60,8 @@ export const PageNavContent = style({ minHeight: '100%', padding: config.space.S200, paddingRight: 0, - paddingBottom: config.space.S700, + // Ensure the last nav item is always above the home indicator / Android nav bar. + paddingBottom: `max(${config.space.S700}, env(safe-area-inset-bottom, 0px))`, }); export const PageHeader = recipe({ diff --git a/src/app/components/splash-screen/SplashScreen.css.ts b/src/app/components/splash-screen/SplashScreen.css.ts index bd3c300a7..ecc90af12 100644 --- a/src/app/components/splash-screen/SplashScreen.css.ts +++ b/src/app/components/splash-screen/SplashScreen.css.ts @@ -2,11 +2,16 @@ import { style } from '@vanilla-extract/css'; import { color, config } from 'folds'; export const SplashScreen = style({ - minHeight: '100%', + flexGrow: 1, backgroundColor: color.Background.Container, color: color.Background.OnContainer, }); export const SplashScreenFooter = style({ - padding: config.space.S400, + paddingTop: config.space.S400, + paddingLeft: config.space.S400, + paddingRight: config.space.S400, + // Ensure footer clears the home indicator / Android nav bar. + // Falls back to S400 on devices without a bottom safe area. + paddingBottom: `max(${config.space.S400}, env(safe-area-inset-bottom, 0px))`, }); diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 779003437..5a846e184 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -57,6 +57,7 @@ import { useSableCosmetics } from '$hooks/useSableCosmetics'; import { formatCompactNumber } from '$utils/formatCompactNumber'; import * as css from './MembersDrawer.css'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; +import { mobileOrTabletLayout } from '$utils/user-agent'; import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; type MemberDrawerHeaderProps = { @@ -316,7 +317,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { }, [memberSidebarWidth]); const screenSize = useScreenSizeContext(); - const isMobile = screenSize === ScreenSize.Mobile; + const isMobile = mobileOrTabletLayout() || screenSize === ScreenSize.Mobile; const hideText = curWidth <= 80 && !isMobile; return ( - {!isMobile && ( + {!mobileOrTabletLayout() && ( ( const [pkCompatEnable] = useSetting(settingsAtom, 'pkCompat'); const [pmpProxyingEnable] = useSetting(settingsAtom, 'pmpProxying'); const emojiBtnRef = useRef(null); + // Hoisted from the UseStateProvider in JSX so EmojiBoard can be kept mounted + // after first open (avoids re-initializing virtualizer on every open). + const [emojiBoardTab, setEmojiBoardTab] = useState(undefined); + const [emojiBoardAnchorRect, setEmojiBoardAnchorRect] = useState(null); + const openEmojiBoard = useCallback((tab: EmojiBoardTab) => { + const rect = emojiBtnRef.current?.getBoundingClientRect() ?? null; + setEmojiBoardAnchorRect(rect); + setEmojiBoardTab(tab); + }, []); + // Keep the emoji/sticker picker position in sync with viewport changes (e.g. + // the iOS virtual keyboard appearing/disappearing while the board is open). + useEffect(() => { + if (emojiBoardTab === undefined) return undefined; + const updateRect = () => { + setEmojiBoardAnchorRect(emojiBtnRef.current?.getBoundingClientRect() ?? null); + }; + const vp = window.visualViewport; + if (vp) { + vp.addEventListener('resize', updateRect); + vp.addEventListener('scroll', updateRect); + return () => { + vp.removeEventListener('resize', updateRect); + vp.removeEventListener('scroll', updateRect); + }; + } + return undefined; + }, [emojiBoardTab]); + const closeEmojiBoard = useCallback(() => { + setEmojiBoardTab((t) => { + if (t) { + if (!mobileOrTablet()) ReactEditor.focus(editor); + return undefined; + } + return t; + }); + }, [editor]); const micBtnRef = useRef(null); // Preserve stable list keys across metadata/description replacements without // storing UI-only IDs in the upload draft state. @@ -347,6 +384,12 @@ export const RoomInput = forwardRef( }, }) ); + // If all files failed to encrypt (e.g. iCloud file not yet downloaded + // on iOS), surface an error rather than silently producing no items. + if (fileItems.length === 0 && safeFiles.length > 0) { + setSendError('Could not read the file. Try downloading it first, then try again.'); + return; + } } else { safeFiles.forEach((f) => fileItems.push({ @@ -389,6 +432,14 @@ export const RoomInput = forwardRef( const [sendError, setSendError] = useState(); const isEncrypted = room.hasEncryptionStateEvent(); + const { triggerPreLift } = useKeyboardHeight(); + // Always active on mobile: iOS can apply window.scrollY even with overflow:hidden + // on body (scroll-prediction bug). The lock snaps scrollY back to 0 immediately + // on any scroll event, preventing the "header scrolls up then snaps" jank. + // useKeyboardHeight now manages --sable-visible-height synchronously in its own + // event handler, so no useEffect here is needed for CSS variable management. + useScrollLock(mobileOrTablet()); + useElementSizeObserver( useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]), useCallback((width) => setHideStickerBtn(width < 500), []) @@ -1273,7 +1324,8 @@ export const RoomInput = forwardRef( }; return ( -
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
{selectedFiles.length > 0 && ( ( - - {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( - { - setEmojiBoardTab((t) => { - if (t) { - if (!mobileOrTablet()) ReactEditor.focus(editor); - return undefined; - } - return t; - }); - }} - /> - } + {/* Emoji/sticker board: kept mounted after first open to avoid re-initialising + the virtualizer on every open. FocusTrap is deactivated when hidden. */} + {emojiBoardAnchorRect && + createPortal( +
{ + const rawRight = window.innerWidth - emojiBoardAnchorRect.right; + const boardWidth = Math.min(432, window.innerWidth - 32); + return Math.max(0, Math.min(rawRight, window.innerWidth - boardWidth)); + })(), + display: emojiBoardTab !== undefined ? undefined : 'none', + }} > - {!hideStickerBtn && ( - setEmojiBoardTab(EmojiBoardTab.Sticker)} - variant="SurfaceVariant" - size="300" - radii="300" - title="open sticker picker" - aria-label="Open sticker picker" - > - - - )} - setEmojiBoardTab(EmojiBoardTab.Emoji)} - variant="SurfaceVariant" - size="300" - radii="300" - title="open emoji picker" - aria-label="Open emoji picker" - > - - - + +
, + document.body )} -
+ {!hideStickerBtn && ( + openEmojiBoard(EmojiBoardTab.Sticker)} + variant="SurfaceVariant" + size="300" + radii="300" + title="open sticker picker" + aria-label="Open sticker picker" + > + + + )} + openEmojiBoard(EmojiBoardTab.Emoji)} + variant="SurfaceVariant" + size="300" + radii="300" + title="open emoji picker" + aria-label="Open emoji picker" + > + + { return timeDayMonthYear(ts); }; +const SCROLL_SETTLE_MS = 250; + export type RoomTimelineProps = { room: Room; eventId?: string; @@ -190,6 +192,14 @@ export function RoomTimeline({ hideReadsRef.current = hideReads; const prevViewportHeightRef = useRef(0); + const prevScrollSizeRef = useRef(0); + // Tracks the VList-reported viewport size (as opposed to prevViewportHeightRef + // which tracks the DOM element height via ResizeObserver). Used in + // handleVListScroll to detect viewport size changes (keyboard opens OR closes) + // without a ResizeObserver race: when VList fires onScroll with a different + // viewportSize, we chase the bottom immediately instead of letting + // setAtBottom(false) fire. + const prevVListViewportRef = useRef(0); const messageListRef = useRef(null); const mediaAuthentication = useMediaAuthentication(); @@ -226,6 +236,10 @@ export function RoomTimeline({ const topSpacerHeightRef = useRef(0); const mountScrollWindowRef = useRef(Date.now() + 3000); const hasInitialScrolledRef = useRef(false); + // Short-lived guard set for ~350 ms after a jump scrollToIndex so that + // intermediate scroll events from the animation don't flip atBottom prematurely. + const jumpScrollBlockRef = useRef(false); + const jumpScrollBlockTimerRef = useRef | undefined>(undefined); // Stored in a ref so eventsLength fluctuations (e.g. onLifecycle timeline reset // firing within the window) cannot cancel it via useLayoutEffect cleanup. const initialScrollTimerRef = useRef | undefined>(undefined); @@ -237,6 +251,10 @@ export function RoomTimeline({ const currentRoomIdRef = useRef(room.roomId); const [isReady, setIsReady] = useState(false); + const isReadyRef = useRef(false); + isReadyRef.current = isReady; + + const lastProgrammaticBottomPinAtRef = useRef(0); if (currentRoomIdRef.current !== room.roomId) { hasInitialScrolledRef.current = false; @@ -260,6 +278,24 @@ export function RoomTimeline({ vListRef.current.scrollTo(vListRef.current.scrollSize); }, []); + // Start a short scroll-settle block after a programmatic jump scrollToIndex. + // After 350 ms the block lifts and atBottom is recomputed from the actual + // VList position so "Jump to Latest" appears correctly. + const startJumpScrollBlock = useCallback(() => { + jumpScrollBlockRef.current = true; + if (jumpScrollBlockTimerRef.current !== undefined) + clearTimeout(jumpScrollBlockTimerRef.current); + jumpScrollBlockTimerRef.current = setTimeout(() => { + jumpScrollBlockRef.current = false; + jumpScrollBlockTimerRef.current = undefined; + const v = vListRef.current; + if (v) { + const dist = v.scrollSize - v.scrollOffset - v.viewportSize; + setAtBottom(dist < 100); + } + }, 350); + }, [setAtBottom]); + const timelineSync = useTimelineSync({ room, mx, @@ -338,6 +374,8 @@ export function RoomTimeline({ useEffect( () => () => { if (initialScrollTimerRef.current !== undefined) clearTimeout(initialScrollTimerRef.current); + if (jumpScrollBlockTimerRef.current !== undefined) + clearTimeout(jumpScrollBlockTimerRef.current); }, [] ); @@ -399,6 +437,7 @@ export function RoomTimeline({ const processedIndex = getRawIndexToProcessedIndex(timelineSync.focusItem.index); if (processedIndex !== undefined) { vListRef.current.scrollToIndex(processedIndex, { align: 'center' }); + startJumpScrollBlock(); timelineSync.setFocusItem((prev) => (prev ? { ...prev, scrollTo: false } : undefined)); } } @@ -409,7 +448,13 @@ export function RoomTimeline({ return () => { if (timeoutId !== undefined) clearTimeout(timeoutId); }; - }, [timelineSync.focusItem, timelineSync, reducedMotion, getRawIndexToProcessedIndex]); + }, [ + timelineSync.focusItem, + timelineSync, + reducedMotion, + getRawIndexToProcessedIndex, + startJumpScrollBlock, + ]); useEffect(() => { if (timelineSync.focusItem) { @@ -420,6 +465,10 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); + // Re-arm the initial-scroll guard so that if the jump fails and + // useTimelineSync falls back to the live timeline, the useLayoutEffect + // can fire and call setIsReady(true) via the normal initial-scroll path. + hasInitialScrolledRef.current = false; void timelineSyncRef.current.loadEventTimeline(eventId); }, [eventId, room.roomId]); @@ -473,6 +522,10 @@ export function RoomTimeline({ const shrank = newHeight < prev; if (shrank && atBottom) { + // Record the programmatic pin so handleVListScroll sees withinSettleWindow=true + // and doesn't flip atBottom to false while VList commits the new scroll position. + // Without this, the "Jump to Present" button flashes every time the keyboard opens. + lastProgrammaticBottomPinAtRef.current = Date.now(); vListRef.current?.scrollTo(vListRef.current.scrollSize); } prevViewportHeightRef.current = newHeight; @@ -533,6 +586,7 @@ export function RoomTimeline({ } if (vListRef.current && processedIndex !== undefined) { vListRef.current.scrollToIndex(processedIndex, { align: 'center' }); + startJumpScrollBlock(); } timelineSync.setFocusItem({ index: focusRawIndex, scrollTo: false, highlight: true }); } else { @@ -675,6 +729,57 @@ export function RoomTimeline({ const distanceFromBottom = v.scrollSize - offset - v.viewportSize; const isNowAtBottom = distanceFromBottom < 100; + const withinSettleWindow = + Date.now() - lastProgrammaticBottomPinAtRef.current < SCROLL_SETTLE_MS; + + // When the user is pinned to the bottom and content grows (images, embeds, + // video thumbnails loading), scrollSize increases while offset stays put, + // pushing distanceFromBottom above the threshold. Instead of flipping + // atBottom to false (which shows the "Jump to Latest" button), chase the + // bottom so the user stays pinned. + const contentGrew = v.scrollSize > prevScrollSizeRef.current; + prevScrollSizeRef.current = v.scrollSize; + + // When the keyboard opens/closes the VList viewportSize changes. The + // scrollOffset doesn't immediately follow, so distanceFromBottom spikes + // and isNowAtBottom becomes false — flashing the "Jump to Present" button. + // This is especially common when the keyboard opens/closes quickly before + // the chase RAF from a previous event has had a chance to execute. + // Detect the change here (inside onScroll, race-free) and chase the + // bottom before setAtBottom(false) is called. + const viewportChanged = + prevVListViewportRef.current > 0 && v.viewportSize !== prevVListViewportRef.current; + prevVListViewportRef.current = v.viewportSize; + + // Skip content-chase and cache saves during init: the timeline is hidden + // (opacity 0) while VList measures items and fires intermediate scroll + // events. Chasing the bottom here causes cascading scrollTo calls that + // upstream doesn't have, producing visible layout churn after isReady. + if (!isReadyRef.current) return; + + // While a jump scroll is settling (briefly after scrollToIndex), VList + // fires intermediate scroll events that can incorrectly flip atBottom. + // Use a short-lived block instead of the full focusItem lifetime so that + // normal scrolling resumes quickly and atBottom is recomputed correctly. + if (jumpScrollBlockRef.current) return; + + if ( + atBottomRef.current && + !isNowAtBottom && + (contentGrew || viewportChanged || withinSettleWindow) + ) { + // Defer the chase to the next animation frame so VList finishes its + // current layout pass. Synchronous scrollTo causes cascading scroll + // events that produce visible jumps when images/embeds load. + requestAnimationFrame(() => { + const vl = vListRef.current; + if (vl && atBottomRef.current) { + lastProgrammaticBottomPinAtRef.current = Date.now(); + vl.scrollTo(vl.scrollSize); + } + }); + return; + } if (isNowAtBottom !== atBottomRef.current) { setAtBottom(isNowAtBottom); } diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index a5329bed4..673aea514 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -160,7 +160,14 @@ export function RoomView({ eventId }: { eventId?: string }) { - + {canMessage && delayedEventsSupported && ( )} diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 070cdaf58..b4fcaae21 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -230,6 +230,10 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(threadRootId)); const replyDraft = useAtomValue(roomIdToReplyDraftAtomFamily(threadRootId)); const activeReplyId = replyDraft?.eventId; + // Keep a ref so handleReplyClick can read the latest draft without being + // recreated on every keystroke (which would re-render all Message instances). + const replyDraftRef = useRef(replyDraft); + replyDraftRef.current = replyDraft; // User profile popup const openUserRoomProfile = useOpenUserRoomProfile(); @@ -581,7 +585,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra }; // Only toggle off if we're actively replying to this event (non-empty body distinguishes // a real reply draft from the seeded base-thread draft, which has body: ''). - if (activeReplyId === replyId && replyDraft?.body) { + if (activeReplyId === replyId && replyDraftRef.current?.body) { // Toggle off — reset to base thread draft setReplyDraft({ userId: mx.getUserId() ?? '', @@ -594,7 +598,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra } } }, - [mx, room, setReplyDraft, activeReplyId, threadRootId, replyDraft] + [mx, room, setReplyDraft, activeReplyId, threadRootId] ); const handleReactionToggle = useCallback( diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index ca1517de8..206079f84 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -82,6 +82,7 @@ import { import type { PerMessageProfileBeeperFormat } from '$hooks/usePerMessageProfile'; import { convertBeeperFormatToOurPerMessageProfile } from '$hooks/usePerMessageProfile'; import { MessageEditor } from './MessageEditor'; +import { MobileMessageMenu } from './MobileMessageMenu'; import * as css from './styles.css'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -158,6 +159,40 @@ export const MessageCopyLinkItem = as< ); }); +export const MessageCopyTextItem = as< + 'button', + { + mEvent: MatrixEvent; + onClose?: () => void; + } +>(({ mEvent, onClose, ...props }, ref) => { + const content = mEvent.getContent(); + // For edited messages, prefer the new content body + const body: string | undefined = content['m.new_content']?.body ?? content.body; + + if (!body || mEvent.isRedacted()) return null; + + const handleCopy = () => { + copyToClipboard(body); + onClose?.(); + }; + + return ( + } + radii="300" + onClick={handleCopy} + {...props} + ref={ref} + > + + Copy Text + + + ); +}); + // message pinning export const MessagePinItem = as< 'button', @@ -254,22 +289,73 @@ export type MessageProps = { msc2723ForwardedMessageProps?: MSC2723ForwardedMessageProps; }; -function useMobileDoubleTap(callback: () => void, delay = 300) { - const lastTapRef = useRef(0); +function useMobileLongPress(callback: () => void, delay = 500) { + const timerRef = useRef | null>(null); + const startPosRef = useRef<{ x: number; y: number } | null>(null); + const firedRef = useRef(false); - return useCallback(() => { - if (!mobileOrTablet()) return; + const cancel = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + startPosRef.current = null; + }, []); + + const onTouchStart = useCallback( + (e: React.TouchEvent) => { + if (!mobileOrTablet()) return; + const touch = e.touches[0]; + if (!touch) return; + startPosRef.current = { x: touch.clientX, y: touch.clientY }; + firedRef.current = false; + timerRef.current = setTimeout(() => { + timerRef.current = null; + firedRef.current = true; + // Clear any text selection the browser started during the long-press gesture. + window.getSelection()?.removeAllRanges(); + callback(); + }, delay); + }, + [callback, delay] + ); - const now = Date.now(); - const timeSinceLastTap = now - lastTapRef.current; + const onTouchMove = useCallback( + (e: React.TouchEvent) => { + if (!startPosRef.current) return; + const touch = e.touches[0]; + if (!touch) return; + const dx = touch.clientX - startPosRef.current.x; + const dy = touch.clientY - startPosRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) > 10) cancel(); + }, + [cancel] + ); - if (timeSinceLastTap < delay && timeSinceLastTap > 0) { - callback(); - lastTapRef.current = 0; - } else { - lastTapRef.current = now; - } - }, [callback, delay]); + const onTouchEnd = useCallback(() => { + cancel(); + }, [cancel]); + + // Prevent the browser from selecting message text during a long-press gesture. + // Only applied on touch devices — desktop users can still select text normally. + const style = mobileOrTablet() + ? ({ userSelect: 'none', WebkitUserSelect: 'none' } as React.CSSProperties) + : undefined; + + useEffect( + () => () => { + cancel(); + }, + [cancel] + ); + + return { + onTouchStart, + onTouchMove, + onTouchEnd, + onTouchCancel: onTouchEnd, + style, + }; } const clamp = (str: string, len: number) => (str.length > len ? `${str.slice(0, len)}...` : str); @@ -488,7 +574,6 @@ function MessageInternal( const [mobileOptionsOpen, setMobileOptionsOpen] = useState(false); const optionsRef = useRef(null); - const [showPronouns] = useSetting(settingsAtom, 'showPronouns'); const [parsePronouns] = useSetting(settingsAtom, 'parsePronouns'); @@ -515,17 +600,6 @@ function MessageInternal( return existing; }, [pronouns, inlinePronoun]); - useEffect(() => { - if (!mobileOptionsOpen) return undefined; - const handleClickOutside = (e: globalThis.Event) => { - if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) { - setMobileOptionsOpen(false); - } - }; - document.addEventListener('pointerdown', handleClickOutside, { capture: true }); - return () => document.removeEventListener('pointerdown', handleClickOutside, { capture: true }); - }, [mobileOptionsOpen]); - const headerJSX = !collapse && ( {cleanedDisplayName} - {showPronouns && ( + {showPronouns && mergedPronouns.length > 0 && ( )} {showPmPInfo && ( @@ -796,6 +870,7 @@ function MessageInternal( const handleContextMenu: MouseEventHandler = (evt) => { if (mobileOrTablet()) { evt.preventDefault(); + setMobileOptionsOpen(true); return; } @@ -851,7 +926,7 @@ function MessageInternal( onReplyClick(mockEvent); }; - const onDoubleTap = useMobileDoubleTap(() => { + const longPress = useMobileLongPress(() => { setMobileOptionsOpen(true); }); @@ -881,7 +956,7 @@ function MessageInternal( {...focusWithinProps} ref={ref} > - {!edit && (isDesktopHover || !!menuAnchor || !!emojiBoardAnchor || mobileOptionsOpen) && ( + {!edit && (isDesktopHover || !!menuAnchor || !!emojiBoardAnchor) && (
@@ -1115,6 +1190,7 @@ function MessageInternal( )} + {canPinEvent && ( @@ -1237,7 +1313,7 @@ function MessageInternal( {messageLayout === MessageLayout.Compact && ( -
{msgContentJSX}
+
{msgContentJSX}
)} @@ -1249,20 +1325,38 @@ function MessageInternal( onContextMenu={handleContextMenu} align={useRightBubbles && senderId === mx.getUserId() ? 'right' : 'left'} > -
{msgContentJSX}
+
{msgContentJSX}
)} {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( -
+
{headerJSX} {msgContentJSX}
)} + {mobileOptionsOpen && ( + setMobileOptionsOpen(false)} + /> + )} ); } @@ -1314,6 +1408,7 @@ export const Event = as<'div', EventProps>( const handleContextMenu: MouseEventHandler = (evt) => { if (mobileOrTablet()) { evt.preventDefault(); + setMobileOptionsOpen(true); return; } @@ -1357,18 +1452,7 @@ export const Event = as<'div', EventProps>( const optionsRef = useRef(null); - useEffect(() => { - if (!mobileOptionsOpen) return undefined; - const handleClick = (e: globalThis.Event) => { - if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) { - setMobileOptionsOpen(false); - } - }; - document.addEventListener('pointerdown', handleClick, { capture: true }); - return () => document.removeEventListener('pointerdown', handleClick, { capture: true }); - }, [mobileOptionsOpen]); - - const onDoubleTap = useMobileDoubleTap(() => { + const longPress = useMobileLongPress(() => { setMobileOptionsOpen(true); }); @@ -1393,7 +1477,7 @@ export const Event = as<'div', EventProps>( {...focusWithinProps} ref={ref} > - {(isDesktopHover || !!menuAnchor || mobileOptionsOpen) && ( + {(isDesktopHover || !!menuAnchor) && (
@@ -1453,6 +1537,7 @@ export const Event = as<'div', EventProps>( )} + {((!mEvent.isRedacted() && canDelete && !stateEvent) || @@ -1497,9 +1582,22 @@ export const Event = as<'div', EventProps>(
)} -
+
{children}
+ {mobileOptionsOpen && ( + {}} + imagePackRooms={[]} + onClose={() => setMobileOptionsOpen(false)} + /> + )} ); } diff --git a/src/app/features/room/message/MobileMessageMenu.css.ts b/src/app/features/room/message/MobileMessageMenu.css.ts new file mode 100644 index 000000000..2ffd07695 --- /dev/null +++ b/src/app/features/room/message/MobileMessageMenu.css.ts @@ -0,0 +1,169 @@ +import { keyframes, style } from '@vanilla-extract/css'; +import { DefaultReset, color, config, toRem } from 'folds'; + +const slideUp = keyframes({ + from: { transform: 'translateY(100%)' }, + to: { transform: 'translateY(0)' }, +}); + +export const Backdrop = style({ + position: 'fixed', + inset: 0, + // Theme-scrim overlay dims the timeline behind the sheet, just like Discord does. + background: color.Other.Overlay, + zIndex: 100, +}); + +export const Sheet = style([ + DefaultReset, + { + position: 'fixed', + bottom: 'calc(100vh - var(--sable-visible-height, 100vh))', + left: 0, + right: 0, + zIndex: 101, + background: color.Surface.Container, + borderRadius: `${toRem(16)} ${toRem(16)} 0 0`, + paddingBottom: `max(${config.space.S400}, env(safe-area-inset-bottom))`, + boxShadow: '0 -4px 24px rgba(0,0,0,0.18)', + animation: `${slideUp} 220ms cubic-bezier(0.4, 0, 0.2, 1)`, + maxHeight: '80vh', + overflowY: 'auto', + }, +]); + +export const Handle = style({ + width: toRem(36), + height: toRem(4), + background: color.SurfaceVariant.ContainerLine, + borderRadius: toRem(2), + margin: `${config.space.S200} auto ${config.space.S100}`, +}); + +export const ReactionsRow = style({ + display: 'flex', + gap: config.space.S200, + padding: `${config.space.S300} ${config.space.S400}`, + justifyContent: 'center', + flexWrap: 'wrap', +}); + +export const ReactionBtn = style({ + fontSize: toRem(28), + lineHeight: 1, + padding: config.space.S100, + background: color.SurfaceVariant.Container, + border: 'none', + borderRadius: '50%', + cursor: 'pointer', + minWidth: toRem(48), + minHeight: toRem(48), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + selectors: { + '&:active': { + background: color.SurfaceVariant.ContainerActive, + }, + }, +}); + +// A rounded-card group for visually separating action sections, like Discord. +export const ActionGroup = style({ + margin: `0 ${config.space.S300} ${config.space.S300}`, + borderRadius: toRem(12), + background: color.SurfaceVariant.Container, + overflow: 'hidden', +}); + +export const ActionItem = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S300, + padding: `${config.space.S300} ${config.space.S400}`, + cursor: 'pointer', + background: 'transparent', + border: 'none', + width: '100%', + textAlign: 'left', + color: color.Surface.OnContainer, + selectors: { + // Separator between adjacent items inside a group + '& + &': { + borderTop: `1px solid ${color.SurfaceVariant.ContainerLine}`, + }, + '&:active': { + background: color.SurfaceVariant.ContainerActive, + }, + }, +}); + +export const ActionItemDanger = style({ + color: color.Critical.Main, +}); + +export const EmojiPickerHeader = style({ + display: 'flex', + alignItems: 'center', + padding: `${config.space.S200} ${config.space.S300}`, + borderBottom: `1px solid ${color.SurfaceVariant.ContainerLine}`, +}); + +export const EmojiPickerBackBtn = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: toRem(36), + height: toRem(36), + background: 'transparent', + border: 'none', + borderRadius: '50%', + cursor: 'pointer', + color: color.Surface.OnContainer, + flexShrink: 0, + selectors: { + '&:active': { + background: color.SurfaceVariant.ContainerActive, + }, + }, +}); + +export const EmojiPickerTitle = style({ + flexGrow: 1, + textAlign: 'center', + marginRight: toRem(36), // balance the back button width +}); + +export const EmojiPickerWrap = style({ + display: 'flex', + justifyContent: 'center', + padding: config.space.S200, +}); + +export const NickEditSection = style({ + padding: `${config.space.S300} ${config.space.S400}`, + display: 'flex', + flexDirection: 'column', + gap: config.space.S200, +}); + +export const NickEditInput = style({ + background: color.Surface.Container, + color: color.Surface.OnContainer, + border: `1px solid ${color.SurfaceVariant.ContainerLine}`, + borderRadius: toRem(6), + padding: `${config.space.S100} ${config.space.S200}`, + fontSize: toRem(14), + width: '100%', + outline: 'none', + selectors: { + '&:focus': { + borderColor: color.Primary.Main, + }, + }, +}); + +export const NickEditActions = style({ + display: 'flex', + gap: config.space.S200, +}); diff --git a/src/app/features/room/message/MobileMessageMenu.tsx b/src/app/features/room/message/MobileMessageMenu.tsx new file mode 100644 index 000000000..095e20c82 --- /dev/null +++ b/src/app/features/room/message/MobileMessageMenu.tsx @@ -0,0 +1,551 @@ +import { createPortal } from 'react-dom'; +import { Icon, Icons, MenuItem, Text } from 'folds'; +import * as messageCss from './styles.css'; +import type { MouseEventHandler, ReactNode, TouchEvent as ReactTouchEvent } from 'react'; +import { useEffect, useCallback, useRef, useState } from 'react'; +import { EmojiBoard } from '$components/emoji-board'; +import { useAtomValue, useSetAtom } from 'jotai'; +import type { MatrixEvent, Relations, Room, RoomPinnedEventsEventContent } from '$types/matrix-sdk'; +import { EventType } from '$types/matrix-sdk'; +import type { StateEvents } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useRecentEmoji } from '$hooks/useRecentEmoji'; +import { canEditEvent, getEventEdits } from '$utils/room'; +import { modalAtom, ModalType } from '$state/modal'; +import { MessageDeleteItem } from '$components/message/modals/MessageDelete'; +import { MessageReportItem } from '$components/message/modals/MessageReport'; +import { copyToClipboard } from '$utils/dom'; +import { getMatrixToRoomEvent } from '$plugins/matrix-to'; +import { getViaServers } from '$plugins/via-servers'; +import { nicknamesAtom, setNicknameAtom } from '$state/nicknames'; +import { useRoomPinnedEvents } from '$hooks/useRoomPinnedEvents'; +import { useKeyboardHeight } from '$hooks/ios-keyboard-fix'; +import { usePowerLevels } from '$hooks/usePowerLevels'; +import { useRoomCreators } from '$hooks/useRoomCreators'; +import { useRoomPermissions } from '$hooks/useRoomPermissions'; +import { useMemberPowerCompare } from '$hooks/useMemberPowerCompare'; +import * as css from './MobileMessageMenu.css'; + +export type MobileMessageMenuProps = { + room: Room; + mEvent: MatrixEvent; + canDelete?: boolean; + canSendReaction?: boolean; + canPinEvent?: boolean; + relations?: Relations; + isThreadedMessage?: boolean; + hideReadReceipts?: boolean; + showDeveloperTools?: boolean; + onReplyClick: ( + ev: Parameters>[0], + startThread?: boolean + ) => void; + onEditId?: (eventId?: string) => void; + imagePackRooms: Room[]; + onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; + onClose: () => void; +}; + +function QuickReactions({ + onReaction, + onOpenEmojiBoard, +}: { + onReaction: (key: string, shortcode: string) => void; + onOpenEmojiBoard?: () => void; +}) { + const mx = useMatrixClient(); + const recentEmojis = useRecentEmoji(mx, 5); + + return ( +
+ {recentEmojis.map((emoji) => ( + + ))} + {onOpenEmojiBoard && ( + + )} +
+ ); +} + +type ActionItemProps = { + icon: ReactNode; + label: string; + danger?: boolean; + onClick: () => void; +}; + +function ActionItem({ icon, label, danger, onClick }: ActionItemProps) { + return ( + + ); +} + +export function MobileMessageMenu({ + room, + mEvent, + canDelete, + canSendReaction, + canPinEvent, + relations, + isThreadedMessage, + hideReadReceipts, + showDeveloperTools, + onReplyClick, + onEditId, + onReactionToggle, + imagePackRooms, + onClose, +}: MobileMessageMenuProps) { + const mx = useMatrixClient(); + const setModal = useSetAtom(modalAtom); + const evtId = mEvent.getId()!; + const evtTimeline = room.getTimelineForEvent(evtId); + const edits = + evtTimeline && + getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations(); + const isEdited = edits !== undefined; + + // Pinning + const pinnedEvents = useRoomPinnedEvents(room); + const isPinned = pinnedEvents.includes(evtId); + + // Nicknames + const nicknames = useAtomValue(nicknamesAtom); + const setNickname = useSetAtom(setNicknameAtom); + const [nickEditOpen, setNickEditOpen] = useState(false); + const [nickDraft, setNickDraft] = useState(''); + const nickInputRef = useRef(null); + + // Register a keyboard-height listener so --sable-visible-height is set when the + // nickname input is focused. The Sheet CSS uses that variable to stay above the keyboard. + useKeyboardHeight(); + + // Delay focus so iOS's synthesised tap fires before the keyboard opens and + // shifts the sheet, preventing the tap from landing on the backdrop. + useEffect(() => { + if (nickEditOpen) { + const id = setTimeout(() => nickInputRef.current?.focus(), 100); + return () => clearTimeout(id); + } + return undefined; + }, [nickEditOpen]); + + // Kick permissions + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + const roomPermissions = useRoomPermissions(creators, powerLevels); + const { hasMorePower } = useMemberPowerCompare(creators, powerLevels); + const myUserId = mx.getSafeUserId(); + const senderId = mEvent.getSender() ?? ''; + const canKick = + senderId !== myUserId && + roomPermissions.action('kick', myUserId) && + hasMorePower(myUserId, senderId); + + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + + // Refs for direct DOM manipulation during drag (avoids React re-renders on every frame) + const sheetRef = useRef(null); + const backdropRef = useRef(null); + const dragStartYRef = useRef(null); + + const handleSheetTouchStart = useCallback((e: ReactTouchEvent) => { + if (e.currentTarget.scrollTop === 0) { + dragStartYRef.current = e.touches[0]?.clientY ?? null; + } + }, []); + + const handleSheetTouchMove = useCallback((e: ReactTouchEvent) => { + if (dragStartYRef.current === null) return; + const touch = e.touches[0]; + if (!touch) return; + const deltaY = Math.max(0, touch.clientY - dragStartYRef.current); + if (sheetRef.current) { + sheetRef.current.style.transform = `translateY(${deltaY}px)`; + sheetRef.current.style.transition = 'none'; + } + if (backdropRef.current) { + backdropRef.current.style.opacity = String(Math.max(0, 1 - deltaY / 200)); + } + }, []); + + const handleSheetTouchEnd = useCallback( + (e: ReactTouchEvent) => { + if (dragStartYRef.current === null) return; + const startY = dragStartYRef.current; + dragStartYRef.current = null; + const deltaY = Math.max(0, (e.changedTouches[0]?.clientY ?? startY) - startY); + if (deltaY > 80) { + // Animate out then close + if (sheetRef.current) { + sheetRef.current.style.transform = 'translateY(100%)'; + sheetRef.current.style.transition = 'transform 200ms ease'; + } + if (backdropRef.current) { + backdropRef.current.style.opacity = '0'; + backdropRef.current.style.transition = 'opacity 200ms ease'; + } + setTimeout(onClose, 200); + } else { + // Spring back + if (sheetRef.current) { + sheetRef.current.style.transform = ''; + sheetRef.current.style.transition = 'transform 220ms cubic-bezier(0.4, 0, 0.2, 1)'; + } + if (backdropRef.current) { + backdropRef.current.style.opacity = ''; + backdropRef.current.style.transition = ''; + } + } + }, + [onClose] + ); + + // Close on Escape + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onClose]); + + // Prevent body scroll while open + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, []); + + const handleReplyClick = useCallback(() => { + const mockEvent = { + currentTarget: { getAttribute: (attr: string) => (attr === 'data-event-id' ? evtId : null) }, + } as unknown as Parameters>[0]; + onReplyClick(mockEvent); + onClose(); + }, [evtId, onReplyClick, onClose]); + + const handleThreadReplyClick = useCallback(() => { + const mockEvent = { + currentTarget: { getAttribute: (attr: string) => (attr === 'data-event-id' ? evtId : null) }, + } as unknown as Parameters>[0]; + onReplyClick(mockEvent, true); + onClose(); + }, [evtId, onReplyClick, onClose]); + + const handleEditClick = useCallback(() => { + onEditId?.(evtId); + onClose(); + }, [evtId, onEditId, onClose]); + + const handlePinClick = useCallback(() => { + const pinContent: RoomPinnedEventsEventContent = { + pinned: Array.from(pinnedEvents).filter((id) => id !== evtId), + }; + if (!isPinned) pinContent.pinned.push(evtId); + mx.sendStateEvent(room.roomId, EventType.RoomPinnedEvents as keyof StateEvents, pinContent); + onClose(); + }, [pinnedEvents, isPinned, evtId, mx, room, onClose]); + + const handleKick = useCallback(async () => { + await mx.kick(room.roomId, senderId); + onClose(); + }, [mx, room, senderId, onClose]); + + const stopPropHandler = useCallback((e: React.MouseEvent) => e.stopPropagation(), []); + + const portalContainer = document.getElementById('portalContainer') ?? document.body; + + return createPortal( + <> + {/* Backdrop */} +
e.key === 'Escape' && onClose()} + /> + + {/* Sheet */} +
e.stopPropagation()} + onTouchStart={handleSheetTouchStart} + onTouchMove={handleSheetTouchMove} + onTouchEnd={handleSheetTouchEnd} + > +
+ + {showEmojiPicker ? ( + <> +
+ + + Add Reaction + +
+
+ { + onReactionToggle(evtId, key, shortcode); + onClose(); + }} + onCustomEmojiSelect={(mxc, shortcode) => { + onReactionToggle(evtId, mxc, shortcode); + onClose(); + }} + requestClose={() => setShowEmojiPicker(false)} + /> +
+ + ) : ( + <> + {canSendReaction && ( + { + onReactionToggle(evtId, key, shortcode); + onClose(); + }} + onOpenEmojiBoard={() => setShowEmojiPicker(true)} + /> + )} + + {/* Group 1: Message actions */} +
+ } + label="Reply" + onClick={handleReplyClick} + /> + {!isThreadedMessage && ( + } + label="Reply in Thread" + onClick={handleThreadReplyClick} + /> + )} + {canEditEvent(mx, mEvent) && onEditId && ( + } + label="Edit Message" + onClick={handleEditClick} + /> + )} + } + label="Forward" + onClick={() => { + setModal({ type: ModalType.Forward, room, mEvent }); + onClose(); + }} + /> + {!hideReadReceipts && ( + } + label="Read Receipts" + onClick={() => { + setModal({ type: ModalType.ReadReceipts, room, eventId: evtId }); + onClose(); + }} + /> + )} + {isEdited && ( + } + label="Version History" + onClick={() => { + setModal({ type: ModalType.EditHistory, room, mEvent }); + onClose(); + }} + /> + )} + {showDeveloperTools && ( + } + label="View Source" + onClick={() => { + setModal({ type: ModalType.Source, room, mEvent }); + onClose(); + }} + /> + )} + {relations && ( + } + label="View Reactions" + onClick={() => { + setModal({ type: ModalType.Reactions, room, relations }); + onClose(); + }} + /> + )} +
+ + {/* Group 2: Utility actions */} +
+ {canPinEvent && ( + } + label={isPinned ? 'Unpin Message' : 'Pin Message'} + onClick={handlePinClick} + /> + )} + {senderId !== myUserId && + (nickEditOpen ? ( +
+ + Nickname + + setNickDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setNickname(senderId, nickDraft || undefined, mx); + setNickEditOpen(false); + onClose(); + } + if (e.key === 'Escape') setNickEditOpen(false); + }} + /> +
+ } + label="Save" + onClick={() => { + setNickname(senderId, nickDraft || undefined, mx); + setNickEditOpen(false); + onClose(); + }} + /> + {nicknames[senderId] && ( + } + label="Clear" + danger + onClick={() => { + setNickname(senderId, undefined, mx); + setNickEditOpen(false); + onClose(); + }} + /> + )} +
+
+ ) : ( + } + label={nicknames[senderId] ? 'Edit Nickname' : 'Set Nickname'} + onClick={() => { + setNickDraft(nicknames[senderId] ?? ''); + setNickEditOpen(true); + }} + /> + ))} + {(() => { + const content = mEvent.getContent(); + const body: string | undefined = content['m.new_content']?.body ?? content.body; + if (!body || mEvent.isRedacted()) return null; + return ( + } + label="Copy Text" + onClick={() => { + copyToClipboard(body); + onClose(); + }} + /> + ); + })()} + {mEvent.getId() && ( + } + label="Copy Link" + onClick={() => { + copyToClipboard( + getMatrixToRoomEvent(room.roomId, mEvent.getId()!, getViaServers(room)) + ); + onClose(); + }} + /> + )} +
+ + {/* Group 3: Destructive actions */} + {(!mEvent.isRedacted() && canDelete) || + mEvent.getSender() !== mx.getUserId() || + canKick ? ( +
+ {canKick && ( + } + radii="300" + fill="None" + variant="Critical" + onClick={handleKick} + > + + Kick from Room + + + )} + {!mEvent.isRedacted() && canDelete && ( + + )} + {mEvent.getSender() !== mx.getUserId() && ( + + )} +
+ ) : null} + + )} +
+ , + portalContainer + ); +} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 29361bdd6..345924eb1 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -33,7 +33,7 @@ import type { DateFormat, MessageSpacing, CaptionPosition } from '$state/setting import { MessageLayout, RightSwipeAction, settingsAtom } from '$state/settings'; import { SettingTile } from '$components/setting-tile'; import { KeySymbol } from '$utils/key-symbol'; -import { isMacOS, mobileOrTablet } from '$utils/user-agent'; +import { isMacOS, isPhone, mobileOrTablet } from '$utils/user-agent'; import { stopPropagation } from '$utils/keyboard'; import { useMessageLayoutItems } from '$hooks/useMessageLayout'; import { useCaptionPositionItems } from '$hooks/useCaptionPosition'; @@ -1436,7 +1436,7 @@ export function General({ requestBack, requestClose }: Readonly) { - + diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index 7f510b444..3708f27cc 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -9,6 +9,27 @@ type PushSubscriptionState = [ (subscription: PushSubscription | null) => void, ]; +function postToServiceWorker(data: unknown): void { + if (!('serviceWorker' in navigator)) return; + + const posted = new Set(); + const postToWorker = (worker: ServiceWorker | null | undefined) => { + if (!worker || posted.has(worker)) return; + posted.add(worker); + // oxlint-disable-next-line unicorn/require-post-message-target-origin + worker.postMessage(data); + }; + + postToWorker(navigator.serviceWorker.controller); + navigator.serviceWorker.ready + .then((registration) => { + postToWorker(registration.active); + postToWorker(registration.waiting); + postToWorker(registration.installing); + }) + .catch(() => undefined); +} + export async function requestBrowserNotificationPermission(): Promise { if (!('Notification' in window)) { debugLog.warn('notification', 'Notification API not available in this browser'); @@ -57,7 +78,7 @@ export async function enablePushNotifications( kind: 'http' as const, app_id: clientConfig.pushNotificationDetails?.webPushAppID, pushkey: keys.p256dh, - app_display_name: 'Cinny', + app_display_name: 'Sable', device_display_name: 'This Browser', lang: navigator.language || 'en', data: { @@ -69,7 +90,7 @@ export async function enablePushNotifications( }, append: false, }; - navigator.serviceWorker.controller?.postMessage({ + postToServiceWorker({ url: mx.baseUrl, type: 'togglePush', pusherData, @@ -104,7 +125,7 @@ export async function enablePushNotifications( kind: 'http' as const, app_id: clientConfig.pushNotificationDetails?.webPushAppID, pushkey: keys.p256dh, - app_display_name: 'Cinny', + app_display_name: 'Sable', device_display_name: (await mx.getDevice(mx.getDeviceId() ?? '')).display_name ?? 'Unknown Device', lang: navigator.language || 'en', @@ -118,7 +139,7 @@ export async function enablePushNotifications( append: false, }; - navigator.serviceWorker.controller?.postMessage({ + postToServiceWorker({ url: mx.baseUrl, type: 'togglePush', pusherData, @@ -144,7 +165,7 @@ export async function disablePushNotifications( pushkey: pushSubAtom?.keys?.p256dh, }; - navigator.serviceWorker.controller?.postMessage({ + postToServiceWorker({ url: mx.baseUrl, type: 'togglePush', pusherData, diff --git a/src/app/hooks/ios-keyboard-fix/device.ts b/src/app/hooks/ios-keyboard-fix/device.ts new file mode 100644 index 000000000..7b7bb06fc --- /dev/null +++ b/src/app/hooks/ios-keyboard-fix/device.ts @@ -0,0 +1,24 @@ +// Vendored from https://github.com/Crscristi28/ios-pwa-keyboard-fix (MIT) +// Replace this import path with 'ios-pwa-keyboard-fix' once published to npm. + +export const isStandalonePWA = (): boolean => { + if (typeof window === 'undefined') return false; + // iOS Safari uses navigator.standalone (legacy, non-standard). + // Other browsers use the W3C display-mode media query. + const iosStandalone = + 'standalone' in window.navigator && + (window.navigator as Navigator & { standalone?: boolean }).standalone === true; + const displayModeStandalone = window.matchMedia('(display-mode: standalone)').matches; + return iosStandalone || displayModeStandalone; +}; + +export const isTablet = (): boolean => typeof window !== 'undefined' && window.innerWidth >= 768; + +export const needsVirtualKeyboard = (): boolean => { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return false; + } + const hasTouchScreen = navigator.maxTouchPoints > 0; + const isCoarsePointer = window.matchMedia('(pointer: coarse)').matches; + return hasTouchScreen && isCoarsePointer; +}; diff --git a/src/app/hooks/ios-keyboard-fix/index.ts b/src/app/hooks/ios-keyboard-fix/index.ts new file mode 100644 index 000000000..4918c970a --- /dev/null +++ b/src/app/hooks/ios-keyboard-fix/index.ts @@ -0,0 +1,5 @@ +// Vendored from https://github.com/Crscristi28/ios-pwa-keyboard-fix (MIT) +// Replace this import path with 'ios-pwa-keyboard-fix' once published to npm. +export { isStandalonePWA, isTablet, needsVirtualKeyboard } from './device'; +export { useKeyboardHeight } from './useKeyboardHeight'; +export { useScrollLock } from './useScrollLock'; diff --git a/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts new file mode 100644 index 000000000..b9016e5ba --- /dev/null +++ b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts @@ -0,0 +1,174 @@ +// Vendored from https://github.com/Crscristi28/ios-pwa-keyboard-fix (MIT) +// Replace this import path with 'ios-pwa-keyboard-fix' once published to npm. +import { useEffect, useRef, useState } from 'react'; + +// Measures iOS keyboard height via the Visual Viewport API and synchronously +// manages the --sable-visible-height / --sable-safe-bottom CSS custom properties +// that control #root layout height. +// +// CSS variables are set/cleared directly inside the event handler (no React +// useEffect) so there is no frame gap between "keyboard closed" being detected +// and the layout reverting to full height. This eliminates the race condition +// where a follow-on viewport.resize event would re-set the variable after the +// React async effect had already removed it, causing a persistent bottom gap. +// +// Stability filter — only commits React state (isKeyboardVisible, keyboardHeight) +// once iOS reports the same viewport height for STABILITY_MS ms. iOS emits +// chaotic transient values during keyboard transitions (text ↔ emoji), so the +// filter prevents those from triggering unnecessary re-renders. +// +// triggerPreLift: called from onMouseDown so Safari sees the textarea as already +// visible and skips its document-scroll prediction. +const STABILITY_MS = 80; + +// Module-level state shared across all useKeyboardHeight instances. +// The keyboard height is a device property — it's the same regardless of +// which input has focus. Sharing savedHeight prevents the case where two +// simultaneous RoomInput instances (main timeline + open thread drawer) race +// on keyboard open: the thread instance starts with savedHeight=0 and would +// overwrite the main instance's correct estimate with the wrong mid-animation +// viewport.height. +// mountCount is a reference counter so only the last unmounting instance +// clears the CSS vars (prevents the thread drawer unmounting mid-keyboard-open +// from wiping --sable-visible-height while the main room input still uses it). +let sharedSavedHeight = 0; +let mountCount = 0; +// Whether --sable-visible-height is currently applied. Shared so multiple +// instances see the same state and the "only set once while open" guard works +// across instances. +let cssVarsApplied = false; + +export function useKeyboardHeight() { + const [keyboardHeight, setKeyboardHeight] = useState(0); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + + // Mirror state in refs so triggerPreLift sees fresh values from + // an onMouseDown handler without re-creating the function each render. + const hasOpenedOnce = useRef(false); + const isVisibleRef = useRef(false); + + useEffect(() => { + const viewport = window.visualViewport; + if (!viewport) return undefined; + + mountCount += 1; + let baselineHeight = window.innerHeight; + let stabilityTimer: ReturnType | null = null; + let pendingValue = 0; + + const setCSSVars = (viewportHeight: number) => { + document.documentElement.style.setProperty( + '--sable-visible-height', + `${Math.round(viewportHeight)}px` + ); + document.documentElement.style.setProperty('--sable-safe-bottom', '0px'); + cssVarsApplied = true; + }; + + const clearCSSVars = () => { + document.documentElement.style.removeProperty('--sable-visible-height'); + document.documentElement.style.removeProperty('--sable-safe-bottom'); + cssVarsApplied = false; + }; + + const handleResize = () => { + const calculatedHeight = baselineHeight - viewport.height; + + // Keyboard closing — act immediately, no stability wait. + // clearCSSVars() runs synchronously here, before React schedules any + // re-render, so there is no window in which a follow-on resize event + // can observe the variable as missing and incorrectly re-set it. + if (calculatedHeight < 30) { + if (stabilityTimer) { + clearTimeout(stabilityTimer); + stabilityTimer = null; + } + clearCSSVars(); + setKeyboardHeight(0); + setIsKeyboardVisible(false); + isVisibleRef.current = false; + return; + } + + // Keyboard opening / open. + // On the very first resize that signals a keyboard, immediately shrink + // the layout (before the stability gate) so the input bar rises before + // iOS applies its own scroll-prediction pass. + // Use the previously-measured keyboard height as the estimate so the + // immediate and stability-timer setCSSVars calls land on the same pixel + // value — eliminating the second layout change that causes visible + // timeline stutter during the keyboard animation. + if (!cssVarsApplied) { + const estimatedViewportHeight = + sharedSavedHeight > 0 ? baselineHeight - sharedSavedHeight : viewport.height; + setCSSVars(estimatedViewportHeight); + } + + // Cancel any document scroll iOS may have applied as scroll-prediction. + if (window.scrollY !== 0) { + window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); + } + + // Wait for the height to settle. Each resize within STABILITY_MS + // restarts the timer, so transient mid-transition readings never + // commit — only the final settled value does. + pendingValue = calculatedHeight; + if (stabilityTimer) clearTimeout(stabilityTimer); + stabilityTimer = setTimeout(() => { + sharedSavedHeight = pendingValue; + hasOpenedOnce.current = true; + isVisibleRef.current = true; + setCSSVars(viewport.height); // refine to final settled viewport height + setKeyboardHeight(pendingValue); + setIsKeyboardVisible(true); + }, STABILITY_MS); + }; + + // Orientation change resets everything — keyboard heights measured + // in portrait don't apply in landscape and vice versa. Drop saved + // state and start fresh; the next focus will re-measure. + const handleOrientationChange = () => { + if (stabilityTimer) { + clearTimeout(stabilityTimer); + stabilityTimer = null; + } + pendingValue = 0; + sharedSavedHeight = 0; + hasOpenedOnce.current = false; + isVisibleRef.current = false; + clearCSSVars(); + setKeyboardHeight(0); + setIsKeyboardVisible(false); + // Re-baseline after iOS settles the new layout. + setTimeout(() => { + baselineHeight = window.innerHeight; + }, 200); + }; + + viewport.addEventListener('resize', handleResize); + window.addEventListener('orientationchange', handleOrientationChange); + return () => { + mountCount -= 1; + if (stabilityTimer) clearTimeout(stabilityTimer); + viewport.removeEventListener('resize', handleResize); + window.removeEventListener('orientationchange', handleOrientationChange); + // Only clear CSS vars when the last instance unmounts — prevents the thread + // drawer unmounting mid-keyboard-open from wiping the variable while the + // main room's RoomInput still has the keyboard open. + if (mountCount === 0) clearCSSVars(); + }; + }, []); + + // Pre-lift: called from onMouseDown, BEFORE focus event fires. + // Only acts if the keyboard is currently open — otherwise a button + // tap would lift the bar with no keyboard behind it. + // Reads from refs so it always sees the latest state, even when + // captured by an onMouseDown handler that mounted earlier. + const triggerPreLift = () => { + if (hasOpenedOnce.current && sharedSavedHeight > 0 && isVisibleRef.current) { + setKeyboardHeight(sharedSavedHeight); + } + }; + + return { keyboardHeight, isKeyboardVisible, triggerPreLift }; +} diff --git a/src/app/hooks/ios-keyboard-fix/useScrollLock.ts b/src/app/hooks/ios-keyboard-fix/useScrollLock.ts new file mode 100644 index 000000000..d4c07c6e0 --- /dev/null +++ b/src/app/hooks/ios-keyboard-fix/useScrollLock.ts @@ -0,0 +1,37 @@ +// Vendored from https://github.com/Crscristi28/ios-pwa-keyboard-fix (MIT) +// Replace this import path with 'ios-pwa-keyboard-fix' once published to npm. +import { useEffect } from 'react'; + +// Conditional scroll-lock safety net for the input bar. +// While the keyboard is open, Safari can still trigger a document scroll +// when the keyboard mode switches (text ↔ emoji), because there is no +// onMouseDown moment for us to pre-lift on. This listener detects the +// document moving away from scrollY: 0 and snaps it back, so the input +// bar does not drift with the page. +// Outside of this state the page scrolls normally. +// +// Important: this hook assumes the layout where window.scrollY stays at 0 +// because the page itself does not scroll — content scrolls inside
+// with overflow-y:auto, while html/body/#root use overflow:hidden. See +// README "Layout structure" and docs/ARCHITECTURE.md. Without that layout, +// this lock will fight legitimate page scroll while the keyboard is open. +export function useScrollLock(active: boolean) { + useEffect(() => { + // Snap back immediately in case iOS scroll prediction ran during the + // stability window, before this lock became active. + if (active && window.scrollY > 0) { + window.scrollTo(0, 0); + } + + const preventScroll = () => { + if (active && window.scrollY > 0) { + window.scrollTo(0, 0); + } + }; + + window.addEventListener('scroll', preventScroll); + return () => { + window.removeEventListener('scroll', preventScroll); + }; + }, [active]); +} diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 9609dafc0..833affc7c 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -80,12 +80,29 @@ export function useProcessedTimeline({ skipThreadFilter, }: UseProcessedTimelineOptions): ProcessedEvent[] { return useMemo(() => { + // Sort items by origin_server_ts so events always render in chronological + // order even when the SDK stores them in receipt order. This is visible + // after a sliding-sync gap on mobile resume (TimelineReset delivers a full + // batch at once) and for bridge-backfilled or federated messages where + // receipt order ≠ timestamp order. Receipt order is preserved as a + // tiebreaker so threading / causality is not affected. + const sortedItems = items.toSorted((a, b) => { + const [tlA, baseA] = getTimelineAndBaseIndex(linkedTimelines, a); + const [tlB, baseB] = getTimelineAndBaseIndex(linkedTimelines, b); + const evA = tlA ? getTimelineEvent(tlA, getTimelineRelativeIndex(a, baseA)) : null; + const evB = tlB ? getTimelineEvent(tlB, getTimelineRelativeIndex(b, baseB)) : null; + const tsA = evA?.getTs() ?? 0; + const tsB = evB?.getTs() ?? 0; + if (tsA !== tsB) return tsA - tsB; + return a - b; // receipt order tiebreaker keeps causally-related events stable + }); + let prevEvent: MatrixEvent | undefined; let isPrevRendered = false; let newDivider = false; let dayDivider = false; - const result = items.reduce((acc, item) => { + const result = sortedItems.reduce((acc, item) => { const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(linkedTimelines, item); if (!eventTimeline) return acc; diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index b9d253c6a..8c92c8baf 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -64,16 +64,21 @@ function createRoom( emit: roomEmitter.emit.bind(roomEmitter), roomId, getUnfilteredTimelineSet: () => timelineSet as never, + getLiveTimeline: () => timeline, getEventReadUpTo: () => null, getThread: () => null, + getUnreadNotificationCount: () => 0, client: { getUserId: () => '@alice:test', + getAccountData: () => null, }, } as unknown as FakeRoom; return { room, timelineSet, events }; } +const makeMx = () => ({ getUserId: () => '@alice:test', getAccountData: () => null }) as never; + describe('useTimelineSync', () => { it('does not snap a non-bottom user to latest after TimelineReset', async () => { const { room, timelineSet, events } = createRoom(); @@ -82,7 +87,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), isAtBottom: false, isAtBottomRef: { current: false }, scrollToBottom, @@ -114,7 +119,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -142,7 +147,7 @@ describe('useTimelineSync', () => { ({ room, eventId }) => useTimelineSync({ room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), eventId, isAtBottom: false, isAtBottomRef: { current: false }, @@ -179,7 +184,7 @@ describe('useTimelineSync', () => { ({ room, eventId }) => useTimelineSync({ room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), eventId, isAtBottom: false, isAtBottomRef: { current: false }, @@ -214,7 +219,7 @@ describe('useTimelineSync', () => { ({ room }) => useTimelineSync({ room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), eventId: undefined, isAtBottom: false, isAtBottomRef: { current: false }, diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index c10b762b8..cee4c13e5 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -528,8 +528,14 @@ export function useTimelineSync({ setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { scrollToBottom('instant'); + } else { + // Timeline reset with new events loaded by the SDK (e.g. after a sync + // gap on mobile resume). useLiveEventArrive won't fire for events that + // were already in getInitialTimeline's result, so show the unread bar + // here if the room has messages the user hasn't read yet. + setUnreadInfo(getRoomUnreadInfo(room)); } - }, [room, isAtBottomRef, scrollToBottom]) + }, [room, isAtBottomRef, scrollToBottom, setUnreadInfo]) ); useRelationUpdate( diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index b56f564ca..0787d5866 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -7,8 +7,9 @@ import { useClientConfig } from './useClientConfig'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { pushSubscriptionAtom } from '../state/pushSubscription'; -import { mobileOrTablet } from '../utils/user-agent'; import { createDebugLogger } from '../utils/debugLogger'; +import { getSlidingSyncManager } from '$client/initMatrix'; +import { mobileOrTablet } from '$utils/user-agent'; const debugLog = createDebugLogger('AppVisibility'); @@ -16,7 +17,6 @@ export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const pushSubAtom = useAtom(pushSubscriptionAtom); - const isMobile = mobileOrTablet(); useEffect(() => { const handleVisibilityChange = () => { @@ -43,12 +43,74 @@ export function useAppVisibility(mx: MatrixClient | undefined) { if (!mx) return undefined; const handleVisibilityForNotifications = (isVisible: boolean) => { - togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile); + // Always keep the pusher registered regardless of visibility — the SW's + // hasVisibleClient check handles OS-notification suppression when the app + // is in the foreground, so we never need to delete the pusher. Keeping + // it permanently avoids the enable/disable race that can leave the + // homeserver without a valid pusher after rapid tab-focus changes. + togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, true); }; appEvents.onVisibilityChange = handleVisibilityForNotifications; return () => { appEvents.onVisibilityChange = null; }; - }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); + }, [mx, clientConfig, usePushNotifications, pushSubAtom]); + + useEffect(() => { + if (!mx) return undefined; + + const doRetry = () => { + // For classic sync, retryImmediately() breaks out of keepalive backoff immediately. + // For sliding sync the SDK's retryImmediately() is a stub; retryNow() calls + // slidingSync.resend() which aborts any stalled request and retries without backoff. + mx.retryImmediately(); + getSlidingSyncManager(mx)?.retryNow(); + }; + + const handleForeground = () => { + if (document.visibilityState !== 'visible') return; + doRetry(); + debugLog.info('general', 'App foregrounded — sync retry triggered'); + + if (!mobileOrTablet()) return; + // On iOS the network layer is not always immediately available when + // visibilitychange fires after a background suspension. Schedule + // fallback retries so the sync recovers once networking is ready. + // Each timer is cancelled if the app goes back to background first. + const t1 = setTimeout(() => { + if (document.visibilityState === 'visible') { + doRetry(); + debugLog.info('general', 'App foregrounded — sync retry (1.5 s fallback)'); + } + }, 1500); + const t2 = setTimeout(() => { + if (document.visibilityState === 'visible') { + doRetry(); + debugLog.info('general', 'App foregrounded — sync retry (5 s fallback)'); + } + }, 5000); + const cancelOnHide = () => { + if (document.visibilityState === 'visible') return; + clearTimeout(t1); + clearTimeout(t2); + document.removeEventListener('visibilitychange', cancelOnHide); + }; + document.addEventListener('visibilitychange', cancelOnHide); + }; + + // pageshow fires when the page is restored from the browser's back-forward + // cache (bfcache). On some iOS versions the PWA can be restored from bfcache + // without a visibilitychange event, so this acts as an extra safety net. + const handlePageShow = (ev: PageTransitionEvent) => { + if (ev.persisted) handleForeground(); + }; + + document.addEventListener('visibilitychange', handleForeground); + window.addEventListener('pageshow', handlePageShow); + return () => { + document.removeEventListener('visibilitychange', handleForeground); + window.removeEventListener('pageshow', handlePageShow); + }; + }, [mx]); } diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index a03342fc5..c1499ad4b 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -7,7 +7,14 @@ import { mDirectAtom } from '../state/mDirectList'; import { useSyncState } from './useSyncState'; import { useMatrixClient } from './useMatrixClient'; import { getCanonicalAliasOrRoomId } from '../utils/matrix'; -import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath } from '../pages/pathUtils'; +import { + getDirectRoomPath, + getHomeRoomPath, + getSpaceRoomPath, + getDirectPath, + getHomePath, + getSpacePath, +} from '../pages/pathUtils'; import { getOrphanParents, guessPerfectParent } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { createLogger } from '../utils/debug'; @@ -56,9 +63,13 @@ export function NotificationJumper() { jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which // on mobile shows the space-nav panel first instead of the room timeline. + // First replace the current history entry with the section overview so that + // pressing back (including native iOS swipe-back) returns to the section list + // rather than the room the user was in before the notification. const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId); if (mDirects.has(pending.roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getDirectPath(), { replace: true }); + navigate(getDirectRoomPath(roomIdOrAlias)); } else { // If the room lives inside a space, route through the space path so // SpaceRouteRoomProvider can resolve it — HomeRouteRoomProvider only @@ -70,15 +81,12 @@ export function NotificationJumper() { if (orphanParents.length > 0) { const parentSpace = guessPerfectParent(mx, pending.roomId, orphanParents) ?? orphanParents[0]; - navigate( - getSpaceRoomPath( - getCanonicalAliasOrRoomId(mx, parentSpace ?? pending.roomId), - roomIdOrAlias, - pending.eventId - ) - ); + const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace ?? pending.roomId); + navigate(getSpacePath(spaceIdOrAlias), { replace: true }); + navigate(getSpaceRoomPath(spaceIdOrAlias, roomIdOrAlias)); } else { - navigate(getHomeRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getHomePath(), { replace: true }); + navigate(getHomeRoomPath(roomIdOrAlias)); } } setPending(null); diff --git a/src/app/hooks/usePullToRefresh.ts b/src/app/hooks/usePullToRefresh.ts new file mode 100644 index 000000000..440d0f074 --- /dev/null +++ b/src/app/hooks/usePullToRefresh.ts @@ -0,0 +1,212 @@ +import { useEffect, useRef } from 'react'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { getSlidingSyncManager } from '$client/initMatrix'; +import { mobileOrTablet } from '$utils/user-agent'; + +const PULL_THRESHOLD = 72; // px of overscroll needed to trigger refresh +const MAX_PULL = 120; // px cap for visual rubber-band effect + +// Indicator size + gap from the safe-area edge (px). +const INDICATOR_SIZE = 40; +const INDICATOR_GAP = 10; + +// SVGs for the two indicator states. +const ARROW_SVG = ``; +const SPINNER_SVG = ``; + +/** Inject the spin keyframe once into the document. */ +function ensurePTRStyles(): void { + if (document.getElementById('sable-ptr-styles')) return; + const s = document.createElement('style'); + s.id = 'sable-ptr-styles'; + s.textContent = `@keyframes sable-ptr-spin { to { transform: rotate(360deg); } }`; + document.head.appendChild(s); +} + +/** Create the fixed-position circular indicator element. */ +function createIndicator(): HTMLDivElement { + const el = document.createElement('div'); + el.setAttribute('aria-hidden', 'true'); + el.setAttribute('role', 'status'); + Object.assign(el.style, { + position: 'fixed', + // Sit just below the device safe-area (notch / dynamic island). + top: `calc(env(safe-area-inset-top, 0px) + ${INDICATOR_GAP}px)`, + left: '50%', + // Start off-screen above; brought into view during pull. + transform: `translate(-50%, -${INDICATOR_SIZE + INDICATOR_GAP + 4}px)`, + zIndex: '9998', + width: `${INDICATOR_SIZE}px`, + height: `${INDICATOR_SIZE}px`, + borderRadius: '50%', + background: 'var(--sable-surface-container, #fff)', + color: 'var(--sable-surface-on-container, #000)', + border: '1px solid var(--sable-surface-container-line, rgba(0,0,0,0.1))', + boxShadow: '0 2px 10px rgba(0,0,0,0.2)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + pointerEvents: 'none', + willChange: 'transform', + transition: 'none', + }); + el.innerHTML = ARROW_SVG; + return el; +} + +/** + * Attach a pull-to-refresh gesture to a scroll container on mobile. + * + * When the user pulls down from the very top of the list, a CSS transform + * is applied to the container for visual feedback, and a circular indicator + * slides into view. On release, if the pull exceeded PULL_THRESHOLD, + * `mx.retryImmediately()` and `SlidingSyncManager.retryNow()` are called to + * force a network re-sync. + */ +export function usePullToRefresh( + scrollRef: React.RefObject, + mx: MatrixClient +) { + // Mutable refs so event handlers always see the latest values without + // causing re-attachment of listeners. + const startYRef = useRef(null); + const pullDistRef = useRef(0); + const refreshingRef = useRef(false); + + useEffect(() => { + // Only activate on actual mobile / tablet devices. + if (!mobileOrTablet()) return; + + const el = scrollRef.current; + if (!el) return; + + ensurePTRStyles(); + const indicator = createIndicator(); + document.body.appendChild(indicator); + + /** Move the indicator to match the current pull ratio (0–1). */ + const updateIndicator = (ratio: number) => { + // Translate from fully hidden (-size px) to fully visible (0px). + const hidden = -(INDICATOR_SIZE + INDICATOR_GAP + 4); + const translateY = hidden + ratio * (INDICATOR_SIZE + INDICATOR_GAP + 4); + indicator.style.transform = `translate(-50%, ${translateY}px)`; + + // Rotate arrow: 0° at start → 180° at threshold (points up = "release"). + const arrowSvg = indicator.querySelector('svg'); + if (arrowSvg) { + (arrowSvg as SVGElement).style.transform = `rotate(${ratio * 180}deg)`; + } + }; + + const showRefreshing = () => { + indicator.innerHTML = SPINNER_SVG; + indicator.style.transform = 'translate(-50%, 0px)'; + }; + + const hideIndicator = () => { + indicator.style.transition = 'transform 0.25s ease'; + indicator.style.transform = `translate(-50%, -${INDICATOR_SIZE + INDICATOR_GAP + 4}px)`; + // Restore arrow after it has slid out of view. + setTimeout(() => { + indicator.innerHTML = ARROW_SVG; + indicator.style.transition = 'none'; + }, 250); + }; + + const doRefresh = () => { + if (refreshingRef.current) return; + refreshingRef.current = true; + + showRefreshing(); + + // Temporarily clear all active room subscriptions so the server sees + // an empty-subscription request. On the following cycle, subscriptions + // are restored and the server returns initial:true for each room, + // triggering a clean timeline reset with proper backward-pagination + // tokens. This recovers from stale or out-of-order in-memory timelines + // that a normal delta sync cannot fix. + getSlidingSyncManager(mx)?.scheduleForceReset(); + + // Brief delay so the spinner is visible before snapping back. + setTimeout(() => { + refreshingRef.current = false; + el.style.transform = ''; + el.style.transition = ''; + hideIndicator(); + }, 800); + }; + + const onTouchStart = (e: TouchEvent) => { + if (el.scrollTop !== 0) return; + const touch = e.touches[0]; + if (!touch) return; + startYRef.current = touch.clientY; + pullDistRef.current = 0; + }; + + const onTouchMove = (e: TouchEvent) => { + if (startYRef.current === null) return; + // Re-check in case the user scrolled after touch started. + if (el.scrollTop !== 0) { + startYRef.current = null; + return; + } + + const touch = e.touches[0]; + if (!touch) return; + const delta = touch.clientY - startYRef.current; + if (delta <= 0) return; + + // Prevent the browser from scrolling while we handle the pull. + e.preventDefault(); + + // Rubber-band: resistance increases as pull grows. + const capped = Math.min(delta * 0.5, MAX_PULL); + pullDistRef.current = capped; + + el.style.transition = 'none'; + el.style.transform = `translateY(${capped}px)`; + + // Update indicator position and arrow rotation. + indicator.style.transition = 'none'; + updateIndicator(Math.min(capped / PULL_THRESHOLD, 1)); + }; + + const onTouchEnd = () => { + if (startYRef.current === null) return; + startYRef.current = null; + + const dist = pullDistRef.current; + pullDistRef.current = 0; + + if (dist >= PULL_THRESHOLD / 2) { + // Sufficient pull — trigger refresh and animate back. + el.style.transition = 'transform 0.25s ease'; + el.style.transform = ''; + doRefresh(); + } else { + // Insufficient pull — snap everything back. + el.style.transition = 'transform 0.2s ease'; + el.style.transform = ''; + hideIndicator(); + } + }; + + el.addEventListener('touchstart', onTouchStart, { passive: true }); + // passive: false is required so we can call preventDefault() in touchmove. + el.addEventListener('touchmove', onTouchMove, { passive: false }); + el.addEventListener('touchend', onTouchEnd, { passive: true }); + el.addEventListener('touchcancel', onTouchEnd, { passive: true }); + + return () => { + el.removeEventListener('touchstart', onTouchStart); + el.removeEventListener('touchmove', onTouchMove); + el.removeEventListener('touchend', onTouchEnd); + el.removeEventListener('touchcancel', onTouchEnd); + // Clean up any lingering inline styles. + el.style.transform = ''; + el.style.transition = ''; + document.body.removeChild(indicator); + }; + }, [scrollRef, mx]); +} diff --git a/src/app/hooks/useSwUpdateAvailable.ts b/src/app/hooks/useSwUpdateAvailable.ts new file mode 100644 index 000000000..506b24a0f --- /dev/null +++ b/src/app/hooks/useSwUpdateAvailable.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; + +/** + * Returns true once the service worker signals that a new version has been + * installed and is waiting to take over. The caller should prompt the user + * to reload rather than doing so silently, since on mobile an unexpected full- + * page reload is very disorienting. + */ +export function useSwUpdateAvailable(): boolean { + const [updateAvailable, setUpdateAvailable] = useState(false); + + useEffect(() => { + const handleUpdate = () => setUpdateAvailable(true); + window.addEventListener('sable:sw-update', handleUpdate); + return () => window.removeEventListener('sable:sw-update', handleUpdate); + }, []); + + return updateAvailable; +} diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index 83009cda5..5fb203286 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { useMatch } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { mobileOrTabletLayout } from '$utils/user-agent'; import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths'; type MobileFriendlyClientNavProps = { @@ -15,7 +16,7 @@ export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavPro const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true }); if ( - screenSize === ScreenSize.Mobile && + (screenSize === ScreenSize.Mobile || mobileOrTabletLayout()) && !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch) ) { return null; @@ -36,7 +37,7 @@ export function MobileFriendlyPageNav({ path, children }: MobileFriendlyPageNavP end: true, }); - if (screenSize === ScreenSize.Mobile && !exactPath) { + if ((screenSize === ScreenSize.Mobile || mobileOrTabletLayout()) && !exactPath) { return null; } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index aec62cc86..fa1037b5f 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -16,6 +16,7 @@ import { Room } from '$features/room'; import { Lobby } from '$features/lobby'; import { PageRoot } from '$components/page'; import { ScreenSize } from '$hooks/useScreenSize'; +import { mobileOrTabletLayout } from '$utils/user-agent'; import { ReceiveSelfDeviceVerification } from '$components/DeviceVerification'; import { AutoRestoreBackupOnVerification } from '$components/BackupRestore'; import { RoomSettingsRenderer } from '$features/room-settings'; @@ -101,7 +102,7 @@ const getFirstSession = () => { export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; - const mobile = screenSize === ScreenSize.Mobile; + const mobile = screenSize === ScreenSize.Mobile || mobileOrTabletLayout(); const routes = createRoutesFromElements( diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index f847e0856..21dc23dc4 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -62,9 +62,31 @@ import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; +import { useSyncState } from '$hooks/useSyncState'; const pushRelayLog = createDebugLogger('push-relay'); +function postToServiceWorker(data: unknown): void { + if (!('serviceWorker' in navigator)) return; + + const posted = new Set(); + const postToWorker = (worker: ServiceWorker | null | undefined) => { + if (!worker || posted.has(worker)) return; + posted.add(worker); + // oxlint-disable-next-line unicorn/require-post-message-target-origin + worker.postMessage(data); + }; + + postToWorker(navigator.serviceWorker.controller); + navigator.serviceWorker.ready + .then((registration) => { + postToWorker(registration.active); + postToWorker(registration.waiting); + postToWorker(registration.installing); + }) + .catch(() => undefined); +} + function clearMediaSessionQuickly(): void { if (!('mediaSession' in navigator)) return; // iOS registers the lock screen media player as a side-effect of @@ -657,18 +679,27 @@ function SyncNotificationSettingsWithServiceWorker() { const postVisibility = () => { const visible = document.visibilityState === 'visible'; const msg = { type: 'setAppVisible', visible }; - navigator.serviceWorker.controller?.postMessage(msg); - navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); + postToServiceWorker(msg); }; // Report initial visibility immediately, then track changes. postVisibility(); document.addEventListener('visibilitychange', postVisibility); - return () => document.removeEventListener('visibilitychange', postVisibility); + + // iOS kills the SW after ~30 s of inactivity regardless of page + // visibility. Send a cheap keep-alive ping every 20 s so the SW + // stays alive whenever the page is open (foregrounded or not). + const keepAliveId = window.setInterval(() => { + navigator.serviceWorker.controller?.postMessage({ type: 'ping' }); + }, 20_000); + + return () => { + document.removeEventListener('visibilitychange', postVisibility); + window.clearInterval(keepAliveId); + }; }, []); useEffect(() => { - if (!('serviceWorker' in navigator)) return; // notificationSoundEnabled is intentionally excluded: push notification sound // is governed by the push rule's tweakSound alone (OS/Sygnal handles it). // The in-app sound setting only controls the in-page