From 8ac8175794a749b714aaa9d78b3f9bb05ec4f14c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 10:39:57 -0400 Subject: [PATCH 01/33] docs: document mobile iOS keyboard issue Tracks Issue #9 with root cause analysis and proposed fix using visualViewport API. Implementation will follow after proper testing on iOS devices. --- docs/MOBILE_FIXES.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/MOBILE_FIXES.md diff --git a/docs/MOBILE_FIXES.md b/docs/MOBILE_FIXES.md new file mode 100644 index 000000000..d4368ca4f --- /dev/null +++ b/docs/MOBILE_FIXES.md @@ -0,0 +1,31 @@ +# 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 From 840b1b56d86e45af4faf92d2ac486fa0e595624b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 17:33:43 -0400 Subject: [PATCH 02/33] fix(layout): iPad/tablet detection, desktop website mode, and mobileOrTabletLayout() --- src/app/components/page/Page.tsx | 3 ++- src/app/pages/MobileFriendly.tsx | 5 +++-- src/app/pages/Router.tsx | 3 ++- src/app/pages/client/direct/Direct.tsx | 5 +++-- src/app/pages/client/explore/Explore.tsx | 5 +++-- src/app/pages/client/home/Home.tsx | 5 +++-- src/app/pages/client/inbox/Inbox.tsx | 5 +++-- src/app/pages/client/space/Space.tsx | 6 +++--- src/app/utils/user-agent.ts | 17 +++++++++++++++++ 9 files changed, 39 insertions(+), 15 deletions(-) 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/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/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 2a81b849d..f3b128c84 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -54,6 +54,7 @@ import { import { useDirectCreateSelected } from '$hooks/router/useDirectSelected'; import { useDirectRooms } from './useDirectRooms'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; +import { mobileOrTabletLayout } from '$utils/user-agent'; import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; type DirectMenuProps = { @@ -255,7 +256,7 @@ export function Direct() { ); const screenSize = useScreenSizeContext(); - const isMobile = screenSize === ScreenSize.Mobile; + const isMobile = mobileOrTabletLayout() || screenSize === ScreenSize.Mobile; const hideText = curWidth <= 80 && !isMobile; return ( @@ -366,7 +367,7 @@ export function Direct() { )} - {!isMobile && ( + {!mobileOrTabletLayout() && ( - {!isMobile && ( + {!mobileOrTabletLayout() && ( )} - {!isMobile && ( + {!mobileOrTabletLayout() && ( - {!isMobile && ( + {!mobileOrTabletLayout() && ( - {!isMobile && ( + {!mobileOrTabletLayout() && ( { const { os, device } = result; if (device.type === 'mobile' || device.type === 'tablet') return true; if (os.name === 'Android' || os.name === 'iOS') return true; + // iPad on iOS 13+ sends a macOS Safari user agent by default ("Request Desktop Website"). + // ua-parser-js therefore reports os.name === 'Mac OS' with no device.type. + // Real Macs never have maxTouchPoints > 1 (Magic Trackpad reports 1 at most in browsers), + // so this safely identifies iPads masquerading as desktop Safari. + if (os.name === 'Mac OS' && navigator.maxTouchPoints > 1) return true; return false; })(); @@ -15,11 +20,23 @@ const normalizeMacName = (os?: string) => { return os; }; +// True only for phone-form-factor devices for layout/nav decisions. +// Tablets (native iPadOS UA or "Request Desktop Website") always get the desktop +// two-panel layout; only phones collapse to the single-panel mobile layout. +const isMobileOrTabletLayout = result.device.type === 'mobile'; + const isMac = result.os.name === 'Mac OS'; export const ua = () => result; export const isMacOS = () => isMac; export const mobileOrTablet = () => isMobileOrTablet; +/** + * True only for phones. Use this for layout/nav decisions (sidebars, route registration). + * Tablets — whether using native iPadOS UA or iPad "Request Desktop Website" — return false, + * so they always get the full desktop two-panel layout. + * Use `mobileOrTablet` for touch/keyboard/scroll-lock behaviour instead. + */ +export const mobileOrTabletLayout = () => isMobileOrTabletLayout; export const deviceDisplayName = (): string => { const browser = result.browser.name; From 934f378dad9eca76c690ca030524386a4ef02169 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 18:12:22 -0400 Subject: [PATCH 03/33] fix(keyboard): share height state across useKeyboardHeight instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the thread drawer is open alongside the main room view, two RoomInput components each mount their own useKeyboardHeight instance. On keyboard open the thread instance had savedHeight=0 (freshly mounted), so its immediate-estimate branch fell back to viewport.height — the wrong mid-animation value — and overwrote the correct estimate already written by the main room instance. This produced a third layout change (wrong height → correct height) visible as jank on every keyboard open. Fix: promote savedHeight, cssVarsSet, and the mount reference counter to module-level variables so all instances share them. - sharedSavedHeight: all instances read and write the same value, so the estimate is always correct even for newly-mounted instances. - cssVarsApplied: the 'only set once while keyboard open' guard now works across instances, preventing double setCSSVars calls. - mountCount: reference-counted so only the last instance to unmount clears the CSS variables — prevents the thread drawer unmounting while the main room keyboard is still open from wiping --sable-visible-height. --- src/app/features/room/RoomTimeline.tsx | 63 +++++++ .../ios-keyboard-fix/useKeyboardHeight.ts | 174 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 56bf1cca5..6481e1f74 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -190,6 +190,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(); @@ -473,6 +481,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; @@ -675,6 +687,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/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 }; +} From bf92502bf4ac5ed087ff75d5a4faba4570dd8b75 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 08:12:24 -0400 Subject: [PATCH 04/33] fix(mobile): improve mobile UX across swipe, menus, input, and keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase swipe thresholds (velocity 0.5→1.2, distance 100-120→150-180) in SwipeableChatWrapper, SwipeableOverlayWrapper, SwipeableMessageWrapper to reduce accidental navigation triggers - Replace double-tap context menu trigger with 500ms long-press (useMobileLongPress) that cancels on scroll or >10px movement - Add 'Copy Text' menu item (MessageCopyTextItem) to message context menus; prefers m.new_content.body for edited messages, returns null if redacted - Fix file picker on iOS Safari: append hidden input to document.body before .click() and remove after selection so the native dialog reliably appears - Add autoCorrect="on" to Slate Editable alongside autoCapitalize="sentences" for correct iOS sentence-case and autocorrect behaviour - Use height:100dvh on so the layout shrinks when the on-screen keyboard opens (iOS/Android), keeping the app anchored at the top; add interactive-widget=resizes-content to viewport meta for Android Chrome - Keep EmojiBoard mounted after first open via createPortal + display:none toggling instead of unmounting through PopOut; add active prop to EmojiBoard to deactivate FocusTrap when hidden, avoiding re-initialisation of the virtualizer on every open --- index.html | 5 +- src/app/components/SwipeableChatWrapper.tsx | 4 +- .../components/SwipeableMessageWrapper.tsx | 4 +- .../components/SwipeableOverlayWrapper.tsx | 4 +- src/app/components/editor/Editor.tsx | 2 + src/app/components/emoji-board/EmojiBoard.tsx | 4 + src/app/features/room/RoomInput.tsx | 152 ++++++++++-------- src/app/features/room/message/Message.tsx | 98 ++++++++--- src/app/utils/dom.ts | 39 ++++- src/index.css | 4 + 10 files changed, 214 insertions(+), 102 deletions(-) diff --git a/index.html b/index.html index 5cca4f038..586049a0c 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,10 @@ - + Sable Client 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.tsx b/src/app/components/editor/Editor.tsx index e76cce520..9f2901d93 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -440,6 +440,8 @@ export const CustomEditor = forwardRef( onPaste={onPaste} // Defer to OS capitalization setting (respects iOS sentence-case toggle). autoCapitalize="sentences" + // Enables autocorrect on iOS, which also helps autocapitalization work. + autoCorrect="on" // keeps focus after pressing send. onBlur={() => { if (mobileOrTablet()) ReactEditor.focus(editor); diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index d209b2d0a..094729ce3 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -380,6 +380,8 @@ type EmojiBoardProps = { imagePackRooms: Room[]; requestClose: () => 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 [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); + }, []); + 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 +365,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({ @@ -1585,77 +1609,65 @@ export const RoomInput = forwardRef( - - {(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( +
- {!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" + > + + 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 +288,46 @@ 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); - return useCallback(() => { - if (!mobileOrTablet()) return; + const cancel = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + startPosRef.current = null; + }, []); + + const onPointerDown = useCallback( + (e: { clientX: number; clientY: number }) => { + if (!mobileOrTablet()) return; + startPosRef.current = { x: e.clientX, y: e.clientY }; + timerRef.current = setTimeout(() => { + timerRef.current = null; + callback(); + }, delay); + }, + [callback, delay] + ); - const now = Date.now(); - const timeSinceLastTap = now - lastTapRef.current; + const onPointerMove = useCallback( + (e: { clientX: number; clientY: number }) => { + if (!startPosRef.current) return; + const dx = e.clientX - startPosRef.current.x; + const dy = e.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]); + return { + onPointerDown, + onPointerUp: cancel, + onPointerCancel: cancel, + onPointerMove, + }; } const clamp = (str: string, len: number) => (str.length > len ? `${str.slice(0, len)}...` : str); @@ -851,7 +909,7 @@ function MessageInternal( onReplyClick(mockEvent); }; - const onDoubleTap = useMobileDoubleTap(() => { + const longPress = useMobileLongPress(() => { setMobileOptionsOpen(true); }); @@ -1115,6 +1173,7 @@ function MessageInternal( )} + {canPinEvent && ( @@ -1237,7 +1296,7 @@ function MessageInternal( {messageLayout === MessageLayout.Compact && ( -
{msgContentJSX}
+
{msgContentJSX}
)} @@ -1249,14 +1308,14 @@ function MessageInternal( onContextMenu={handleContextMenu} align={useRightBubbles && senderId === mx.getUserId() ? 'right' : 'left'} > -
{msgContentJSX}
+
{msgContentJSX}
)} {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( -
+
{headerJSX} {msgContentJSX}
@@ -1368,7 +1427,7 @@ export const Event = as<'div', EventProps>( return () => document.removeEventListener('pointerdown', handleClick, { capture: true }); }, [mobileOptionsOpen]); - const onDoubleTap = useMobileDoubleTap(() => { + const longPress = useMobileLongPress(() => { setMobileOptionsOpen(true); }); @@ -1453,6 +1512,7 @@ export const Event = as<'div', EventProps>( )} + {((!mEvent.isRedacted() && canDelete && !stateEvent) || @@ -1497,7 +1557,7 @@ export const Event = as<'div', EventProps>(
)} -
+
{children}
diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index e947d9746..abe9fc078 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -64,18 +64,45 @@ export const selectFile = ( if (accept) input.accept = accept; if (multiple) input.multiple = true; + // iOS Safari requires the input to be in the DOM to reliably trigger the + // file picker dialog; remove it immediately after selection. + input.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'; + document.body.appendChild(input); + + let settled = false; + + const cleanup = () => { + input.removeEventListener('change', changeHandler); + input.removeEventListener('cancel', cancelHandler); + if (input.parentNode) input.parentNode.removeChild(input); + }; + + const settle = (value: FilesOrFile | undefined) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + const changeHandler = () => { const fileList = input.files; - if (!fileList) { - resolve(undefined); - } else { - const files: File[] = getFilesFromFileList(fileList); - resolve((multiple ? files : files[0]) as FilesOrFile); + // On iOS Safari, `change` can fire with an empty FileList (e.g. the + // picker was dismissed, or the file isn't available yet). Treat this + // the same as a cancellation so callers receive `undefined` rather than + // an empty array that silently produces no upload items. + if (!fileList || fileList.length === 0) { + settle(undefined); + return; } - input.removeEventListener('change', changeHandler); + const files: File[] = getFilesFromFileList(fileList); + settle(files.length === 0 ? undefined : ((multiple ? files : files[0]) as FilesOrFile)); }; + // iOS 15+: fires when the picker is dismissed without a file selection. + const cancelHandler = () => settle(undefined); + input.addEventListener('change', changeHandler); + input.addEventListener('cancel', cancelHandler); input.click(); }); diff --git a/src/index.css b/src/index.css index 9550a370b..1c718af02 100755 --- a/src/index.css +++ b/src/index.css @@ -43,6 +43,10 @@ html { height: 100%; + /* dvh (dynamic viewport height) shrinks when the on-screen keyboard opens on + iOS/Android, keeping the app layout anchored at the top rather than being + pushed off-screen. Falls back to 100% on older browsers. */ + height: 100dvh; overflow: hidden; overscroll-behavior: none; } From 6c72c43686c0d2798310bf081766095a99d3ed4b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 11:09:22 -0400 Subject: [PATCH 05/33] feat(messages): replace mobile long-press bar with Discord-style bottom sheet --- src/app/features/room/message/Message.tsx | 91 +++--- .../room/message/MobileMessageMenu.css.ts | 97 ++++++ .../room/message/MobileMessageMenu.tsx | 285 ++++++++++++++++++ 3 files changed, 435 insertions(+), 38 deletions(-) create mode 100644 src/app/features/room/message/MobileMessageMenu.css.ts create mode 100644 src/app/features/room/message/MobileMessageMenu.tsx diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 32be83a30..b564ec268 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; @@ -291,6 +292,7 @@ export type MessageProps = { function useMobileLongPress(callback: () => void, delay = 500) { const timerRef = useRef | null>(null); const startPosRef = useRef<{ x: number; y: number } | null>(null); + const firedRef = useRef(false); const cancel = useCallback(() => { if (timerRef.current !== null) { @@ -300,33 +302,45 @@ function useMobileLongPress(callback: () => void, delay = 500) { startPosRef.current = null; }, []); - const onPointerDown = useCallback( - (e: { clientX: number; clientY: number }) => { + const onTouchStart = useCallback( + (e: React.TouchEvent) => { if (!mobileOrTablet()) return; - startPosRef.current = { x: e.clientX, y: e.clientY }; + 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 onPointerMove = useCallback( - (e: { clientX: number; clientY: number }) => { + const onTouchMove = useCallback( + (e: React.TouchEvent) => { if (!startPosRef.current) return; - const dx = e.clientX - startPosRef.current.x; - const dy = e.clientY - startPosRef.current.y; + 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] ); + const onTouchEnd = useCallback(() => { + cancel(); + }, [cancel]); + return { - onPointerDown, - onPointerUp: cancel, - onPointerCancel: cancel, - onPointerMove, + onTouchStart, + onTouchMove, + onTouchEnd, + onTouchCancel: onTouchEnd, }; } @@ -546,8 +560,6 @@ function MessageInternal( const [mobileOptionsOpen, setMobileOptionsOpen] = useState(false); const optionsRef = useRef(null); - - const [showPronouns] = useSetting(settingsAtom, 'showPronouns'); const [parsePronouns] = useSetting(settingsAtom, 'parsePronouns'); const [useRightBubbles] = useSetting(settingsAtom, 'useRightBubbles'); @@ -573,17 +585,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 && ( + {mergedPronouns.length > 0 && ( )} {showPmPInfo && ( @@ -854,6 +855,7 @@ function MessageInternal( const handleContextMenu: MouseEventHandler = (evt) => { if (mobileOrTablet()) { evt.preventDefault(); + setMobileOptionsOpen(true); return; } @@ -939,7 +941,7 @@ function MessageInternal( {...focusWithinProps} ref={ref} > - {!edit && (isDesktopHover || !!menuAnchor || !!emojiBoardAnchor || mobileOptionsOpen) && ( + {!edit && (isDesktopHover || !!menuAnchor || !!emojiBoardAnchor) && (
@@ -1322,6 +1324,19 @@ function MessageInternal( )} + {mobileOptionsOpen && ( + setMobileOptionsOpen(false)} + /> + )} ); } @@ -1373,6 +1388,7 @@ export const Event = as<'div', EventProps>( const handleContextMenu: MouseEventHandler = (evt) => { if (mobileOrTablet()) { evt.preventDefault(); + setMobileOptionsOpen(true); return; } @@ -1416,17 +1432,6 @@ 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 longPress = useMobileLongPress(() => { setMobileOptionsOpen(true); }); @@ -1452,7 +1457,7 @@ export const Event = as<'div', EventProps>( {...focusWithinProps} ref={ref} > - {(isDesktopHover || !!menuAnchor || mobileOptionsOpen) && ( + {(isDesktopHover || !!menuAnchor) && (
@@ -1560,6 +1565,16 @@ export const Event = as<'div', EventProps>(
{children}
+ {mobileOptionsOpen && ( + {}} + 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..e1719bbea --- /dev/null +++ b/src/app/features/room/message/MobileMessageMenu.css.ts @@ -0,0 +1,97 @@ +import { keyframes, style } from '@vanilla-extract/css'; +import { DefaultReset, config, toRem } from 'folds'; + +const slideUp = keyframes({ + from: { transform: 'translateY(100%)' }, + to: { transform: 'translateY(0)' }, +}); + +export const Backdrop = style({ + position: 'fixed', + inset: 0, + background: 'rgba(0,0,0,0.72)', + zIndex: 100, +}); + +export const Sheet = style([ + DefaultReset, + { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + zIndex: 101, + background: 'var(--mx-c-surface)', + 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: '80dvh', + overflowY: 'auto', + }, +]); + +export const Handle = style({ + width: toRem(36), + height: toRem(4), + background: 'var(--mx-c-outline-variant)', + borderRadius: toRem(2), + margin: `${config.space.S200} auto ${config.space.S100}`, +}); + +export const ReactionsRow = style({ + display: 'flex', + gap: config.space.S200, + padding: `${config.space.S200} ${config.space.S400}`, + justifyContent: 'center', + flexWrap: 'wrap', +}); + +export const ReactionBtn = style({ + fontSize: toRem(28), + lineHeight: 1, + padding: config.space.S100, + background: 'none', + border: 'none', + borderRadius: toRem(8), + cursor: 'pointer', + minWidth: toRem(48), + minHeight: toRem(48), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + selectors: { + '&:active': { + background: 'var(--mx-c-surface-variant)', + }, + }, +}); + +export const ActionList = style({ + display: 'flex', + flexDirection: 'column', + padding: `0 ${config.space.S200} ${config.space.S200}`, +}); + +export const ActionItem = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S300, + padding: `${config.space.S300} ${config.space.S300}`, + borderRadius: toRem(8), + cursor: 'pointer', + background: 'none', + border: 'none', + width: '100%', + textAlign: 'left', + color: 'var(--mx-c-on-surface)', + selectors: { + '&:active': { + background: 'var(--mx-c-surface-variant)', + }, + }, +}); + +export const ActionItemDanger = style({ + color: 'var(--mx-c-error)', +}); diff --git a/src/app/features/room/message/MobileMessageMenu.tsx b/src/app/features/room/message/MobileMessageMenu.tsx new file mode 100644 index 000000000..c71a8a918 --- /dev/null +++ b/src/app/features/room/message/MobileMessageMenu.tsx @@ -0,0 +1,285 @@ +import { createPortal } from 'react-dom'; +import { Icon, Icons, Line, Text } from 'folds'; +import type { MouseEventHandler, ReactNode } from 'react'; +import { useEffect, useCallback } from 'react'; +import type { MatrixEvent, Room } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useRecentEmoji } from '$hooks/useRecentEmoji'; +import { canEditEvent } from '$utils/room'; +import { MessageDeleteItem } from '$components/message/modals/MessageDelete'; +import { MessageReportItem } from '$components/message/modals/MessageReport'; +import { MessageForwardItem } from '$components/message/modals/MessageForward'; +import { copyToClipboard } from '$utils/dom'; +import { getMatrixToRoomEvent } from '$plugins/matrix-to'; +import { getViaServers } from '$plugins/via-servers'; +import { useBookmarks, isBookmarked, toggleBookmark } from '$hooks/useBookmarks'; +import * as css from './MobileMessageMenu.css'; + +export type MobileMessageMenuProps = { + room: Room; + mEvent: MatrixEvent; + canDelete?: boolean; + canSendReaction?: boolean; + isThreadedMessage?: boolean; + onReplyClick: ( + ev: Parameters>[0], + startThread?: boolean + ) => void; + onEditId?: (eventId?: string) => void; + onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; + onOpenEmojiBoard?: () => 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 ( + + ); +} + +function BookmarkActionItem({ + room, + mEvent, + onClose, +}: { + room: Room; + mEvent: MatrixEvent; + onClose: () => void; +}) { + const mx = useMatrixClient(); + const bookmarks = useBookmarks(); + const eventId = mEvent.getId() ?? ''; + const bookmarked = isBookmarked(bookmarks, eventId); + + if (mEvent.isRedacted()) return null; + + return ( + } + label={bookmarked ? 'Remove Bookmark' : 'Bookmark'} + onClick={() => { + toggleBookmark(mx, room.roomId, eventId, bookmarks).catch(() => {}); + onClose(); + }} + /> + ); +} + +export function MobileMessageMenu({ + room, + mEvent, + canDelete, + canSendReaction, + isThreadedMessage, + onReplyClick, + onEditId, + onReactionToggle, + onOpenEmojiBoard, + onClose, +}: MobileMessageMenuProps) { + const mx = useMatrixClient(); + const evtId = mEvent.getId()!; + + // 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 stopPropHandler = useCallback((e: React.MouseEvent) => e.stopPropagation(), []); + + const portalContainer = document.getElementById('portalContainer') ?? document.body; + + return createPortal( + <> + {/* Backdrop */} +
e.key === 'Escape' && onClose()} + /> + + {/* Sheet */} +
e.stopPropagation()} + > +
+ + {canSendReaction && ( + <> + { + onReactionToggle(evtId, key, shortcode); + onClose(); + }} + onOpenEmojiBoard={ + onOpenEmojiBoard + ? () => { + onOpenEmojiBoard(); + onClose(); + } + : undefined + } + /> + + + )} + +
+ } + label="Reply" + onClick={handleReplyClick} + /> + {!isThreadedMessage && ( + } + label="Reply in Thread" + onClick={handleThreadReplyClick} + /> + )} + {canEditEvent(mx, mEvent) && onEditId && ( + } + label="Edit Message" + onClick={handleEditClick} + /> + )} + {(() => { + 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(); + }} + /> + )} + + + {!mEvent.isRedacted() && canDelete && ( + <> + + + + )} + {mEvent.getSender() !== mx.getUserId() && ( + <> + + + + )} +
+
+ , + portalContainer + ); +} From 3e3d8d5b15c033efa811eb66bdcafc937db354ea Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 13:02:46 -0400 Subject: [PATCH 06/33] fix(mobile): safe-area, PWA viewport, keyboard CSS layout, and menu tokens --- index.html | 2 +- src/app/components/editor/Editor.tsx | 13 ++- src/app/features/room/RoomInput.tsx | 52 +++++++++++- src/app/features/room/message/Message.tsx | 11 +++ .../room/message/MobileMessageMenu.css.ts | 44 +++++----- .../room/message/MobileMessageMenu.tsx | 83 +++++++++++++++---- src/app/hooks/ios-keyboard-fix/device.ts | 24 ++++++ src/app/hooks/ios-keyboard-fix/index.ts | 5 ++ .../hooks/ios-keyboard-fix/useScrollLock.ts | 37 +++++++++ src/index.css | 17 ++-- 10 files changed, 243 insertions(+), 45 deletions(-) create mode 100644 src/app/hooks/ios-keyboard-fix/device.ts create mode 100644 src/app/hooks/ios-keyboard-fix/index.ts create mode 100644 src/app/hooks/ios-keyboard-fix/useScrollLock.ts diff --git a/index.html b/index.html index 586049a0c..6b7600075 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ Sable Client diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 9f2901d93..807f507e7 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -2,7 +2,7 @@ import type { ClipboardEventHandler, KeyboardEventHandler, ReactNode } from 'rea import { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Box, Scroll, Text } from 'folds'; import type { Descendant, Editor } from 'slate'; -import { Node, createEditor } from 'slate'; +import { Node, Transforms, createEditor } from 'slate'; import type { RenderLeafProps, RenderElementProps, RenderPlaceholderProps } from 'slate-react'; import { Slate, Editable, withReact, ReactEditor } from 'slate-react'; import { withHistory } from 'slate-history'; @@ -446,6 +446,17 @@ export const CustomEditor = forwardRef( onBlur={() => { if (mobileOrTablet()) ReactEditor.focus(editor); }} + // iOS Slate.js bug: an empty contenteditable doesn't signal + // "start of sentence" to autocapitalize. A no-op text + // round-trip primes the input context on focus. + onFocus={() => { + if (!mobileOrTablet()) return; + requestAnimationFrame(() => { + if (!ReactEditor.isFocused(editor) || Node.string(editor).length > 0) return; + Transforms.insertText(editor, '\u00a0'); + Transforms.delete(editor, { reverse: true }); + }); + }} /> {(hasAfter || showResponsiveAfterInline) && ( diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index c4f4cae1d..9a15a4355 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -143,6 +143,7 @@ import { import { ImageUsage } from '$plugins/custom-emoji'; import { SerializableMap } from '$types/wrapper/SerializableMap'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; +import { useKeyboardHeight, useScrollLock } from '$hooks/ios-keyboard-fix'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; import { @@ -413,6 +414,46 @@ export const RoomInput = forwardRef( const [sendError, setSendError] = useState(); const isEncrypted = room.hasEncryptionStateEvent(); + const { keyboardHeight, isKeyboardVisible } = useKeyboardHeight(); + useScrollLock(isKeyboardVisible && mobileOrTablet()); + + // When the keyboard opens, shrink #root to the visual viewport height + // (the area above the keyboard). This is the layout-correct approach + // for Sable's in-flow flex layout: transform moves the input visually + // but leaves the message list sized to the full height, producing a + // gap. CSS variables let the whole layout reflow so messages fill the + // visible area and the input sits at the bottom above the keyboard. + // The 80 ms stability gate in useKeyboardHeight prevents this from + // firing at startup or during transient browser-chrome resize events. + useEffect(() => { + if (!mobileOrTablet()) return undefined; + + if (isKeyboardVisible && keyboardHeight > 0) { + const visibleHeight = window.visualViewport?.height ?? window.innerHeight - keyboardHeight; + document.documentElement.style.setProperty('--sable-visible-height', `${visibleHeight}px`); + document.documentElement.style.setProperty('--sable-safe-bottom', '0px'); + // Reset any scroll iOS applied during the stability window before + // the lock became active. + if (window.scrollY !== 0) { + window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); + } + } else { + document.documentElement.style.removeProperty('--sable-visible-height'); + document.documentElement.style.removeProperty('--sable-safe-bottom'); + } + + return undefined; + }, [isKeyboardVisible, keyboardHeight]); + + // Safety: remove CSS variables if RoomInput unmounts while keyboard open. + useEffect( + () => () => { + document.documentElement.style.removeProperty('--sable-visible-height'); + document.documentElement.style.removeProperty('--sable-safe-bottom'); + }, + [] + ); + useElementSizeObserver( useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]), useCallback((width) => setHideStickerBtn(width < 500), []) @@ -1619,8 +1660,15 @@ export const RoomInput = forwardRef( zIndex: 999, // Position above the emoji button (mirrors PopOut position="Top" offset=16). bottom: window.innerHeight - emojiBoardAnchorRect.top + 16, - // Right-align with the emoji button (mirrors PopOut align="End"). - right: window.innerWidth - emojiBoardAnchorRect.right, + // Right-align with the emoji button, but clamp so the picker + // never extends past the left edge of the screen. + // The EmojiBoard is min(432px, 100vw-32px) wide; ensure + // viewportWidth − right − boardWidth ≥ 0. + right: (() => { + 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', }} > diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index b564ec268..d52557655 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -336,11 +336,18 @@ function useMobileLongPress(callback: () => void, delay = 500) { 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; + return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd, + style, }; } @@ -1331,6 +1338,8 @@ function MessageInternal( canDelete={canDelete} canSendReaction={canSendReaction} isThreadedMessage={isThreadedMessage} + hideReadReceipts={hideReadReceipts} + showDeveloperTools={showDeveloperTools} onReplyClick={onReplyClick} onEditId={onEditId} onReactionToggle={onReactionToggle} @@ -1570,6 +1579,8 @@ export const Event = as<'div', EventProps>( room={room} mEvent={mEvent} canDelete={canDelete} + hideReadReceipts={hideReadReceipts} + showDeveloperTools={showDeveloperTools} onReplyClick={onReplyClick} onReactionToggle={() => {}} onClose={() => setMobileOptionsOpen(false)} diff --git a/src/app/features/room/message/MobileMessageMenu.css.ts b/src/app/features/room/message/MobileMessageMenu.css.ts index e1719bbea..282f2e31c 100644 --- a/src/app/features/room/message/MobileMessageMenu.css.ts +++ b/src/app/features/room/message/MobileMessageMenu.css.ts @@ -1,5 +1,5 @@ import { keyframes, style } from '@vanilla-extract/css'; -import { DefaultReset, config, toRem } from 'folds'; +import { DefaultReset, color, config, toRem } from 'folds'; const slideUp = keyframes({ from: { transform: 'translateY(100%)' }, @@ -9,7 +9,8 @@ const slideUp = keyframes({ export const Backdrop = style({ position: 'fixed', inset: 0, - background: 'rgba(0,0,0,0.72)', + // Theme-scrim overlay dims the timeline behind the sheet, just like Discord does. + background: color.Other.Overlay, zIndex: 100, }); @@ -21,12 +22,12 @@ export const Sheet = style([ left: 0, right: 0, zIndex: 101, - background: 'var(--mx-c-surface)', + 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: '80dvh', + maxHeight: '80vh', overflowY: 'auto', }, ]); @@ -34,7 +35,7 @@ export const Sheet = style([ export const Handle = style({ width: toRem(36), height: toRem(4), - background: 'var(--mx-c-outline-variant)', + background: color.SurfaceVariant.ContainerLine, borderRadius: toRem(2), margin: `${config.space.S200} auto ${config.space.S100}`, }); @@ -42,7 +43,7 @@ export const Handle = style({ export const ReactionsRow = style({ display: 'flex', gap: config.space.S200, - padding: `${config.space.S200} ${config.space.S400}`, + padding: `${config.space.S300} ${config.space.S400}`, justifyContent: 'center', flexWrap: 'wrap', }); @@ -51,9 +52,9 @@ export const ReactionBtn = style({ fontSize: toRem(28), lineHeight: 1, padding: config.space.S100, - background: 'none', + background: color.SurfaceVariant.Container, border: 'none', - borderRadius: toRem(8), + borderRadius: '50%', cursor: 'pointer', minWidth: toRem(48), minHeight: toRem(48), @@ -62,36 +63,41 @@ export const ReactionBtn = style({ justifyContent: 'center', selectors: { '&:active': { - background: 'var(--mx-c-surface-variant)', + background: color.SurfaceVariant.ContainerActive, }, }, }); -export const ActionList = style({ - display: 'flex', - flexDirection: 'column', - padding: `0 ${config.space.S200} ${config.space.S200}`, +// 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.S300}`, - borderRadius: toRem(8), + padding: `${config.space.S300} ${config.space.S400}`, cursor: 'pointer', - background: 'none', + background: 'transparent', border: 'none', width: '100%', textAlign: 'left', - color: 'var(--mx-c-on-surface)', + color: color.Surface.OnContainer, selectors: { + // Separator between adjacent items inside a group + '& + &': { + borderTop: `1px solid ${color.SurfaceVariant.ContainerLine}`, + }, '&:active': { - background: 'var(--mx-c-surface-variant)', + background: color.SurfaceVariant.ContainerActive, }, }, }); export const ActionItemDanger = style({ - color: 'var(--mx-c-error)', + color: color.Critical.Main, }); diff --git a/src/app/features/room/message/MobileMessageMenu.tsx b/src/app/features/room/message/MobileMessageMenu.tsx index c71a8a918..f26e83c77 100644 --- a/src/app/features/room/message/MobileMessageMenu.tsx +++ b/src/app/features/room/message/MobileMessageMenu.tsx @@ -1,11 +1,13 @@ import { createPortal } from 'react-dom'; -import { Icon, Icons, Line, Text } from 'folds'; +import { Icon, Icons, Text } from 'folds'; import type { MouseEventHandler, ReactNode } from 'react'; import { useEffect, useCallback } from 'react'; +import { useSetAtom } from 'jotai'; import type { MatrixEvent, Room } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useRecentEmoji } from '$hooks/useRecentEmoji'; -import { canEditEvent } from '$utils/room'; +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 { MessageForwardItem } from '$components/message/modals/MessageForward'; @@ -21,6 +23,8 @@ export type MobileMessageMenuProps = { canDelete?: boolean; canSendReaction?: boolean; isThreadedMessage?: boolean; + hideReadReceipts?: boolean; + showDeveloperTools?: boolean; onReplyClick: ( ev: Parameters>[0], startThread?: boolean @@ -124,6 +128,8 @@ export function MobileMessageMenu({ canDelete, canSendReaction, isThreadedMessage, + hideReadReceipts, + showDeveloperTools, onReplyClick, onEditId, onReactionToggle, @@ -131,7 +137,13 @@ export function MobileMessageMenu({ 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; // Close on Escape useEffect(() => { @@ -212,11 +224,11 @@ export function MobileMessageMenu({ : undefined } /> - )} -
+ {/* Group 1: Message actions */} +
} label="Reply" @@ -236,6 +248,46 @@ export function MobileMessageMenu({ onClick={handleEditClick} /> )} + + {!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(); + }} + /> + )} +
+ + {/* Group 2: Utility actions */} +
{(() => { const content = mEvent.getContent(); const body: string | undefined = content['m.new_content']?.body ?? content.body; @@ -263,21 +315,18 @@ export function MobileMessageMenu({ }} /> )} - - {!mEvent.isRedacted() && canDelete && ( - <> - - - - )} - {mEvent.getSender() !== mx.getUserId() && ( - <> - - - - )}
+ + {/* Group 3: Destructive actions */} + {(!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId() ? ( +
+ {!mEvent.isRedacted() && canDelete && } + {mEvent.getSender() !== mx.getUserId() && ( + + )} +
+ ) : null}
, portalContainer 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/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/index.css b/src/index.css index 1c718af02..bded059b9 100755 --- a/src/index.css +++ b/src/index.css @@ -43,10 +43,6 @@ html { height: 100%; - /* dvh (dynamic viewport height) shrinks when the on-screen keyboard opens on - iOS/Android, keeping the app layout anchored at the top rather than being - pushed off-screen. Falls back to 100% on older browsers. */ - height: 100dvh; overflow: hidden; overscroll-behavior: none; } @@ -58,14 +54,25 @@ body { font-family: var(--font-secondary); font-size: 16px; font-weight: 400; + /* Prevent iOS Safari rubber-band overscroll from shifting the entire app off-screen. */ + overscroll-behavior: none; /* eslint-disable-next-line css/no-invalid-properties */ background-color: var(--sable-bg-container); } #root { width: 100%; - height: 100%; + /* On iOS PWA, --sable-visible-height is set (via RoomInput's useEffect) + when the keyboard is open, shrinking the root to the visual viewport + so the whole layout reflows correctly above the keyboard. Falls back + to 100% when no keyboard is present. */ + height: var(--sable-visible-height, 100%); display: flex; flex-direction: column; + padding-top: env(safe-area-inset-top, 0px); + /* --sable-safe-bottom is set to 0px when the keyboard is open (home + indicator region is covered by the keyboard). Falls back to the + safe-area inset when the keyboard is closed. */ + padding-bottom: var(--sable-safe-bottom, env(safe-area-inset-bottom, 0px)); } *, From 4720c30da21a226da00293b26d2fa2e16e365857 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 17:46:19 -0400 Subject: [PATCH 07/33] fix(mobile): safe area gaps, keyboard jank, ICB workarounds, and banner fix --- index.html | 5 +- src/app/components/editor/Editor.tsx | 12 +- .../NotificationBanner.tsx | 38 +---- src/app/components/page/style.css.ts | 3 +- .../splash-screen/SplashScreen.css.ts | 9 +- src/app/features/room/RoomInput.tsx | 49 ++---- src/app/features/room/RoomTimeline.tsx | 8 + src/app/features/room/RoomView.tsx | 9 +- .../features/room/RoomViewFollowing.css.ts | 3 +- .../room/message/MobileMessageMenu.tsx | 28 ++-- src/app/hooks/useNotificationJumper.ts | 28 ++-- src/app/pages/client/ClientRoot.tsx | 2 +- src/app/pages/client/sidebar/SpaceTabs.tsx | 140 +++++++++--------- src/app/styles/overrides/General.css.ts | 9 ++ src/index.css | 6 +- 15 files changed, 164 insertions(+), 185 deletions(-) diff --git a/index.html b/index.html index 6b7600075..5cca4f038 100644 --- a/index.html +++ b/index.html @@ -3,10 +3,7 @@ - + Sable Client diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 807f507e7..ac0c7c513 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -447,14 +447,18 @@ export const CustomEditor = forwardRef( if (mobileOrTablet()) ReactEditor.focus(editor); }} // iOS Slate.js bug: an empty contenteditable doesn't signal - // "start of sentence" to autocapitalize. A no-op text - // round-trip primes the input context on focus. + // "start of sentence" to autocapitalize. A two-frame round-trip + // (insert space, then delete it one frame later) lets iOS process + // the intermediate "has text" state before seeing the empty field + // again — this is what triggers sentence-case on the next keystroke. onFocus={() => { if (!mobileOrTablet()) return; requestAnimationFrame(() => { if (!ReactEditor.isFocused(editor) || Node.string(editor).length > 0) return; - Transforms.insertText(editor, '\u00a0'); - Transforms.delete(editor, { reverse: true }); + Transforms.insertText(editor, ' '); + requestAnimationFrame(() => { + Transforms.delete(editor, { reverse: true }); + }); }); }} /> diff --git a/src/app/components/notification-banner/NotificationBanner.tsx b/src/app/components/notification-banner/NotificationBanner.tsx index 4476e8389..8a16e639d 100644 --- a/src/app/components/notification-banner/NotificationBanner.tsx +++ b/src/app/components/notification-banner/NotificationBanner.tsx @@ -177,45 +177,9 @@ export function NotificationBanner() { // We store an array locally so multiple rapid notifications stack briefly. const [banner, setBanner] = useAtom(inAppBannerAtom); const [queue, setQueue] = useState([]); - 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/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/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 9a15a4355..2d36576fa 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -414,45 +414,13 @@ export const RoomInput = forwardRef( const [sendError, setSendError] = useState(); const isEncrypted = room.hasEncryptionStateEvent(); - const { keyboardHeight, isKeyboardVisible } = useKeyboardHeight(); - useScrollLock(isKeyboardVisible && mobileOrTablet()); - - // When the keyboard opens, shrink #root to the visual viewport height - // (the area above the keyboard). This is the layout-correct approach - // for Sable's in-flow flex layout: transform moves the input visually - // but leaves the message list sized to the full height, producing a - // gap. CSS variables let the whole layout reflow so messages fill the - // visible area and the input sits at the bottom above the keyboard. - // The 80 ms stability gate in useKeyboardHeight prevents this from - // firing at startup or during transient browser-chrome resize events. - useEffect(() => { - if (!mobileOrTablet()) return undefined; - - if (isKeyboardVisible && keyboardHeight > 0) { - const visibleHeight = window.visualViewport?.height ?? window.innerHeight - keyboardHeight; - document.documentElement.style.setProperty('--sable-visible-height', `${visibleHeight}px`); - document.documentElement.style.setProperty('--sable-safe-bottom', '0px'); - // Reset any scroll iOS applied during the stability window before - // the lock became active. - if (window.scrollY !== 0) { - window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); - } - } else { - document.documentElement.style.removeProperty('--sable-visible-height'); - document.documentElement.style.removeProperty('--sable-safe-bottom'); - } - - return undefined; - }, [isKeyboardVisible, keyboardHeight]); - - // Safety: remove CSS variables if RoomInput unmounts while keyboard open. - useEffect( - () => () => { - document.documentElement.style.removeProperty('--sable-visible-height'); - document.documentElement.style.removeProperty('--sable-safe-bottom'); - }, - [] - ); + 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]), @@ -1338,7 +1306,8 @@ export const RoomInput = forwardRef( }; return ( -
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
{selectedFiles.length > 0 && ( prev && atBottom) { + lastProgrammaticBottomPinAtRef.current = Date.now(); + vListRef.current?.scrollTo(vListRef.current.scrollSize); + } prevViewportHeightRef.current = newHeight; }); 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/RoomViewFollowing.css.ts b/src/app/features/room/RoomViewFollowing.css.ts index 3f7bee353..4c15cf853 100644 --- a/src/app/features/room/RoomViewFollowing.css.ts +++ b/src/app/features/room/RoomViewFollowing.css.ts @@ -13,8 +13,7 @@ export const RoomViewFollowing = recipe({ base: [ DefaultReset, { - minHeight: toRem(28), - padding: `0 ${config.space.S400}`, + padding: `${config.space.S100} ${config.space.S400}`, width: '100%', backgroundColor: color.Surface.Container, color: color.Surface.OnContainer, diff --git a/src/app/features/room/message/MobileMessageMenu.tsx b/src/app/features/room/message/MobileMessageMenu.tsx index f26e83c77..40930d447 100644 --- a/src/app/features/room/message/MobileMessageMenu.tsx +++ b/src/app/features/room/message/MobileMessageMenu.tsx @@ -10,11 +10,11 @@ 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 { MessageForwardItem } from '$components/message/modals/MessageForward'; import { copyToClipboard } from '$utils/dom'; import { getMatrixToRoomEvent } from '$plugins/matrix-to'; import { getViaServers } from '$plugins/via-servers'; -import { useBookmarks, isBookmarked, toggleBookmark } from '$hooks/useBookmarks'; +import { useIsBookmarked, useBookmarkActions } from '$features/bookmarks/useBookmarks'; +import { createBookmarkItem, computeBookmarkId } from '$features/bookmarks/bookmarkDomain'; import * as css from './MobileMessageMenu.css'; export type MobileMessageMenuProps = { @@ -103,10 +103,9 @@ function BookmarkActionItem({ mEvent: MatrixEvent; onClose: () => void; }) { - const mx = useMatrixClient(); - const bookmarks = useBookmarks(); const eventId = mEvent.getId() ?? ''; - const bookmarked = isBookmarked(bookmarks, eventId); + const bookmarked = useIsBookmarked(room.roomId, eventId); + const { add, remove } = useBookmarkActions(); if (mEvent.isRedacted()) return null; @@ -115,7 +114,12 @@ function BookmarkActionItem({ icon={} label={bookmarked ? 'Remove Bookmark' : 'Bookmark'} onClick={() => { - toggleBookmark(mx, room.roomId, eventId, bookmarks).catch(() => {}); + if (bookmarked) { + remove(computeBookmarkId(room.roomId, eventId)).catch(() => {}); + } else { + const item = createBookmarkItem(room, mEvent); + if (item) add(item).catch(() => {}); + } onClose(); }} /> @@ -248,11 +252,13 @@ export function MobileMessageMenu({ onClick={handleEditClick} /> )} - } + label="Forward" + onClick={() => { + setModal({ type: ModalType.Forward, room, mEvent }); + onClose(); + }} /> {!hideReadReceipts && ( 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, targetEventId)); } else { - navigate(getHomeRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getHomePath(), { replace: true }); + navigate(getHomeRoomPath(roomIdOrAlias, targetEventId)); } } setPending(null); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 70ba9221e..0b8261d82 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -90,7 +90,7 @@ function ClientRootOptions({ mx, onLogout }: ClientRootOptionsProps) { (); + // Aggregate unread across all recursive child rooms (space rooms themselves + // carry no messages, so RoomUnreadProvider would always return nothing). + const roomToParents = useAtomValue(roomToParentsAtom); + const allChild = useSpaceChildren( + allRoomsAtom, + space.roomId, + useRecursiveChildScopeFactory(mx, roomToParents) + ); + const unread = useRoomsUnread(allChild, roomToUnreadAtom); + const handleContextMenu: MouseEventHandler = (evt) => { evt.preventDefault(); const cords = evt.currentTarget.getBoundingClientRect(); @@ -544,74 +554,70 @@ function SpaceTab({ }; return ( - - {(unread) => ( - - - {(triggerRef) => ( - - ( - {nameInitials(space.name, 2)} - )} - /> - - )} - - {unread && ( - 0} - count={unread.highlight > 0 ? unread.highlight : unread.total} - /> - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} - onUnpin={onUnpin} - /> - - } + + + {(triggerRef) => ( + + ( + {nameInitials(space.name, 2)} + )} /> - )} - + + )} + + {unread && ( + 0} + count={unread.highlight > 0 ? unread.highlight : unread.total} + /> + )} + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + onUnpin={onUnpin} + /> + + } + /> )} - + ); } diff --git a/src/app/styles/overrides/General.css.ts b/src/app/styles/overrides/General.css.ts index 8dee3b14c..f0fa49d80 100644 --- a/src/app/styles/overrides/General.css.ts +++ b/src/app/styles/overrides/General.css.ts @@ -1,4 +1,13 @@ import { globalStyle } from '@vanilla-extract/css'; +import { color } from 'folds'; + +// Ensure the safe-area padding areas on #root (top/bottom on iOS) show +// the app's background container color instead of the white body fallback. +// Without this, the 34px home-indicator gap at the bottom is visibly white +// against the gray content, making it look like a UI gap on iOS PWA. +globalStyle('#root', { + backgroundColor: color.Background.Container, +}); globalStyle( ` diff --git a/src/index.css b/src/index.css index bded059b9..36eb5ff40 100755 --- a/src/index.css +++ b/src/index.css @@ -60,6 +60,7 @@ body { background-color: var(--sable-bg-container); } #root { + position: relative; width: 100%; /* On iOS PWA, --sable-visible-height is set (via RoomInput's useEffect) when the keyboard is open, shrinking the root to the visual viewport @@ -68,11 +69,6 @@ body { height: var(--sable-visible-height, 100%); display: flex; flex-direction: column; - padding-top: env(safe-area-inset-top, 0px); - /* --sable-safe-bottom is set to 0px when the keyboard is open (home - indicator region is covered by the keyboard). Falls back to the - safe-area inset when the keyboard is closed. */ - padding-bottom: var(--sable-safe-bottom, env(safe-area-inset-bottom, 0px)); } *, From 7f44bb04b3044326932058c7eddafdb2d7930432 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 07:32:31 -0400 Subject: [PATCH 08/33] feat(mobile): swipe-down to dismiss long-press menu, add emoji reaction picker --- src/app/features/room/message/Message.tsx | 2 + .../room/message/MobileMessageMenu.css.ts | 38 ++ .../room/message/MobileMessageMenu.tsx | 324 +++++++++++------- 3 files changed, 249 insertions(+), 115 deletions(-) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index d52557655..45e44d64c 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -1343,6 +1343,7 @@ function MessageInternal( onReplyClick={onReplyClick} onEditId={onEditId} onReactionToggle={onReactionToggle} + imagePackRooms={imagePackRooms ?? []} onClose={() => setMobileOptionsOpen(false)} /> )} @@ -1583,6 +1584,7 @@ export const Event = as<'div', EventProps>( showDeveloperTools={showDeveloperTools} onReplyClick={onReplyClick} onReactionToggle={() => {}} + imagePackRooms={[]} onClose={() => setMobileOptionsOpen(false)} /> )} diff --git a/src/app/features/room/message/MobileMessageMenu.css.ts b/src/app/features/room/message/MobileMessageMenu.css.ts index 282f2e31c..270ed1b76 100644 --- a/src/app/features/room/message/MobileMessageMenu.css.ts +++ b/src/app/features/room/message/MobileMessageMenu.css.ts @@ -101,3 +101,41 @@ export const ActionItem = style({ 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, +}); diff --git a/src/app/features/room/message/MobileMessageMenu.tsx b/src/app/features/room/message/MobileMessageMenu.tsx index 40930d447..6c1968ca1 100644 --- a/src/app/features/room/message/MobileMessageMenu.tsx +++ b/src/app/features/room/message/MobileMessageMenu.tsx @@ -1,7 +1,8 @@ import { createPortal } from 'react-dom'; import { Icon, Icons, Text } from 'folds'; -import type { MouseEventHandler, ReactNode } from 'react'; -import { useEffect, useCallback } from 'react'; +import type { MouseEventHandler, ReactNode, TouchEvent as ReactTouchEvent } from 'react'; +import { useEffect, useCallback, useRef, useState } from 'react'; +import { EmojiBoard } from '$components/emoji-board'; import { useSetAtom } from 'jotai'; import type { MatrixEvent, Room } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -30,8 +31,8 @@ export type MobileMessageMenuProps = { startThread?: boolean ) => void; onEditId?: (eventId?: string) => void; + imagePackRooms: Room[]; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; - onOpenEmojiBoard?: () => void; onClose: () => void; }; @@ -137,7 +138,7 @@ export function MobileMessageMenu({ onReplyClick, onEditId, onReactionToggle, - onOpenEmojiBoard, + imagePackRooms, onClose, }: MobileMessageMenuProps) { const mx = useMatrixClient(); @@ -149,6 +150,65 @@ export function MobileMessageMenu({ getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations(); const isEdited = edits !== undefined; + 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) => { @@ -196,6 +256,7 @@ export function MobileMessageMenu({ <> {/* Backdrop */}
e.stopPropagation()} + onTouchStart={handleSheetTouchStart} + onTouchMove={handleSheetTouchMove} + onTouchEnd={handleSheetTouchEnd} >
- {canSendReaction && ( + {showEmojiPicker ? ( <> - { - onReactionToggle(evtId, key, shortcode); - onClose(); - }} - onOpenEmojiBoard={ - onOpenEmojiBoard - ? () => { - onOpenEmojiBoard(); - onClose(); - } - : undefined - } - /> +
+ + + Add Reaction + +
+
+ { + onReactionToggle(evtId, key, shortcode); + onClose(); + }} + onCustomEmojiSelect={(mxc, shortcode) => { + onReactionToggle(evtId, mxc, shortcode); + onClose(); + }} + requestClose={() => setShowEmojiPicker(false)} + /> +
- )} - - {/* 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(); - }} - /> - )} -
+ ) : ( + <> + {canSendReaction && ( + { + onReactionToggle(evtId, key, shortcode); + onClose(); + }} + onOpenEmojiBoard={() => setShowEmojiPicker(true)} + /> + )} - {/* Group 2: Utility actions */} -
- {(() => { - const content = mEvent.getContent(); - const body: string | undefined = content['m.new_content']?.body ?? content.body; - if (!body || mEvent.isRedacted()) return null; - return ( + {/* 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="Copy Text" + icon={} + label="Forward" onClick={() => { - copyToClipboard(body); + setModal({ type: ModalType.Forward, room, mEvent }); onClose(); }} /> - ); - })()} - {mEvent.getId() && ( - } - label="Copy Link" - onClick={() => { - copyToClipboard( - getMatrixToRoomEvent(room.roomId, mEvent.getId()!, getViaServers(room)) + {!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(); + }} + /> + )} +
+ + {/* Group 2: Utility actions */} +
+ {(() => { + 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(); + }} + /> ); - 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() ? ( -
- {!mEvent.isRedacted() && canDelete && } - {mEvent.getSender() !== mx.getUserId() && ( - - )} -
- ) : null} + {/* Group 3: Destructive actions */} + {(!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId() ? ( +
+ {!mEvent.isRedacted() && canDelete && ( + + )} + {mEvent.getSender() !== mx.getUserId() && ( + + )} +
+ ) : null} + + )}
, portalContainer From ce173975de35f6d86768f46e9649b0cab40ebab2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 13:26:51 -0400 Subject: [PATCH 09/33] fix(mobile): thread keyboard flicker, timeline scroll, and layout fixes --- src/app/components/editor/Editor.tsx | 8 ++++ src/app/features/room/RoomTimeline.tsx | 39 ++++++++++++++----- .../features/room/RoomViewFollowing.css.ts | 3 +- src/app/features/room/ThreadDrawer.tsx | 8 +++- src/app/features/settings/general/General.tsx | 4 +- src/app/pages/client/ClientNonUIFeatures.tsx | 32 +++++++++++++++ src/app/utils/user-agent.ts | 12 +++--- src/sw.ts | 34 ++++++++++++++-- 8 files changed, 116 insertions(+), 24 deletions(-) diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index ac0c7c513..504256a51 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -451,8 +451,16 @@ export const CustomEditor = forwardRef( // (insert space, then delete it one frame later) lets iOS process // the intermediate "has text" state before seeing the empty field // again — this is what triggers sentence-case on the next keystroke. + // + // Guard: skip the trick when triggerAutoCapitalize is returning focus + // to Slate (justRestoredFocusRef is set synchronously before the + // ReactEditor.focus call). Without this guard the space→delete fires + // again, handleChange sees non-empty→empty while focused, calls + // triggerAutoCapitalize again, and the placeholder flashes in a + // tight loop every time a reply is started. onFocus={() => { if (!mobileOrTablet()) return; + if (justRestoredFocusRef.current) return; requestAnimationFrame(() => { if (!ReactEditor.isFocused(editor) || Node.string(editor).length > 0) return; Transforms.insertText(editor, ' '); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 8f59d50b2..3e4f30309 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -234,6 +234,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); @@ -268,6 +272,23 @@ 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, @@ -407,6 +428,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)); } } @@ -417,7 +439,7 @@ export function RoomTimeline({ return () => { if (timeoutId !== undefined) clearTimeout(timeoutId); }; - }, [timelineSync.focusItem, timelineSync, reducedMotion, getRawIndexToProcessedIndex]); + }, [timelineSync.focusItem, timelineSync, reducedMotion, getRawIndexToProcessedIndex, startJumpScrollBlock]); useEffect(() => { if (timelineSync.focusItem) { @@ -428,7 +450,11 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); - void timelineSyncRef.current.loadEventTimeline(eventId); + // 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; + timelineSyncRef.current.loadEventTimeline(eventId); }, [eventId, room.roomId]); useEffect(() => { @@ -487,14 +513,6 @@ export function RoomTimeline({ lastProgrammaticBottomPinAtRef.current = Date.now(); vListRef.current?.scrollTo(vListRef.current.scrollSize); } - // When the viewport GROWS (e.g. keyboard dismissed), re-pin to the bottom - // so that VList doesn't momentarily report "not at bottom" and flash the - // jump-to-present button. Setting lastProgrammaticBottomPinAtRef ensures - // handleVListScroll's settle-window keeps atBottom=true during the reflow. - if (!shrank && newHeight > prev && atBottom) { - lastProgrammaticBottomPinAtRef.current = Date.now(); - vListRef.current?.scrollTo(vListRef.current.scrollSize); - } prevViewportHeightRef.current = newHeight; }); @@ -553,6 +571,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 { diff --git a/src/app/features/room/RoomViewFollowing.css.ts b/src/app/features/room/RoomViewFollowing.css.ts index 4c15cf853..3f7bee353 100644 --- a/src/app/features/room/RoomViewFollowing.css.ts +++ b/src/app/features/room/RoomViewFollowing.css.ts @@ -13,7 +13,8 @@ export const RoomViewFollowing = recipe({ base: [ DefaultReset, { - padding: `${config.space.S100} ${config.space.S400}`, + minHeight: toRem(28), + padding: `0 ${config.space.S400}`, width: '100%', backgroundColor: color.Surface.Container, color: color.Surface.OnContainer, 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/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/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index f847e0856..342cbe8f7 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -62,6 +62,7 @@ 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'); @@ -688,6 +689,36 @@ function SyncNotificationSettingsWithServiceWorker() { return null; } +/** + * Tells the service worker whether the Matrix sync connection is healthy. + * When sync is in Reconnecting or Error state the in-app notification path is + * broken, so the SW must not suppress OS push notifications even while the app + * is visible. + */ +function SyncStateWithServiceWorker() { + const mx = useMatrixClient(); + + const postSyncHealth = useCallback((healthy: boolean) => { + if (!('serviceWorker' in navigator)) return; + const msg = { type: 'setSyncState', healthy }; + navigator.serviceWorker.controller?.postMessage(msg); + navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); + }, []); + + useSyncState( + mx, + useCallback( + (current) => { + const healthy = current !== SyncState.Reconnecting && current !== SyncState.Error; + postSyncHealth(healthy); + }, + [postSyncHealth] + ) + ); + + return null; +} + function SlidingSyncActiveRoomSubscriber() { useSlidingSyncActiveRoom(); return null; @@ -874,6 +905,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts index 5e4932ad9..707c0ae59 100644 --- a/src/app/utils/user-agent.ts +++ b/src/app/utils/user-agent.ts @@ -20,10 +20,10 @@ const normalizeMacName = (os?: string) => { return os; }; -// True only for phone-form-factor devices for layout/nav decisions. -// Tablets (native iPadOS UA or "Request Desktop Website") always get the desktop -// two-panel layout; only phones collapse to the single-panel mobile layout. -const isMobileOrTabletLayout = result.device.type === 'mobile'; +// True only for phone-form-factor devices for layout/nav decisions and settings +// that should remain available on tablets with external keyboards. +// Tablets (native iPadOS UA or "Request Desktop Website") return false. +const isPhoneDevice = result.device.type === 'mobile'; const isMac = result.os.name === 'Mac OS'; @@ -36,7 +36,9 @@ export const mobileOrTablet = () => isMobileOrTablet; * so they always get the full desktop two-panel layout. * Use `mobileOrTablet` for touch/keyboard/scroll-lock behaviour instead. */ -export const mobileOrTabletLayout = () => isMobileOrTabletLayout; +export const mobileOrTabletLayout = () => isPhoneDevice; +/** True only for phones; returns false for tablets (e.g. iPad with external keyboard). */ +export const isPhone = () => isPhoneDevice; export const deviceDisplayName = (): string => { const browser = result.browser.name; diff --git a/src/sw.ts b/src/sw.ts index 78255b701..e10303157 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -12,6 +12,10 @@ let notificationSoundEnabled = true; // The clients.matchAll() visibilityState is unreliable on iOS Safari PWA, // so we use this explicit flag as a fallback. let appIsVisible = false; +// Tracks whether the Matrix sync connection is healthy. +// Defaults to true; set false when the app reports Reconnecting/Error so that +// OS push notifications are not suppressed while the in-app path is broken. +let syncIsHealthy = true; let showMessageContent = false; let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; @@ -584,6 +588,11 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { appIsVisible = (data as { visible: boolean }).visible; } } + if (type === 'setSyncState') { + if (typeof (data as { healthy?: unknown }).healthy === 'boolean') { + syncIsHealthy = (data as { healthy: boolean }).healthy; + } + } if (type === 'setNotificationSettings') { if ( typeof (data as { notificationSoundEnabled?: unknown }).notificationSoundEnabled === 'boolean' @@ -763,19 +772,36 @@ const onPushNotification = async (event: PushEvent) => { // If the app is open and visible, skip the OS push notification — the in-app // pill notification handles the alert instead. - // Combine clients.matchAll() visibility with the explicit appIsVisible flag - // because iOS Safari PWA often returns empty or stale results from matchAll(). + // + // Require BOTH the explicit appIsVisible flag AND a visible client from + // matchAll() before suppressing. appIsVisible resets to false every time the + // SW starts fresh; on iOS the browser kills the SW between pushes, so on the + // next push appIsVisible is always false — we never suppress on a cold SW + // restart, which prevents the "notifications stop after a while" bug where + // stale matchAll() data (visibilityState stuck at 'visible') would cause all + // subsequent notifications to be silently dropped. + // + // Also require syncIsHealthy: if the Matrix sync is in Reconnecting/Error + // state, the in-app notification path is broken, so we must show the OS + // notification even when the app is visible. + // + // When matchAll() returns zero clients (iOS Safari PWA fully-suspended quirk), + // clients.some() returns false — do NOT suppress. Better to show a duplicate + // (handled gracefully by the in-app banner) than to silently drop a + // notification while the app is backgrounded. const hasVisibleClient = - appIsVisible || clients.some((client) => client.visibilityState === 'visible'); + appIsVisible && syncIsHealthy && clients.some((client) => client.visibilityState === 'visible'); console.debug( '[SW push] appIsVisible:', appIsVisible, + '| syncIsHealthy:', + syncIsHealthy, '| clients:', clients.map((c) => ({ url: c.url, visibility: c.visibilityState })) ); console.debug('[SW push] hasVisibleClient:', hasVisibleClient); if (hasVisibleClient) { - console.debug('[SW push] suppressing OS notification — app is visible'); + console.debug('[SW push] suppressing OS notification — app is visible and sync is healthy'); return; } From be6788076dcec8cfb775f4913044f6583a46ff78 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 8 May 2026 08:50:07 -0400 Subject: [PATCH 10/33] fix(notifications): prevent push dropout after SW restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes for notifications stopping after a while: 1. sw.ts hasVisibleClient used OR logic (appIsVisible || matchAll visible): On iOS the SW is killed between pushes so appIsVisible resets to false on restart. With OR logic, a stale matchAll() result with visibilityState='visible' still set hasVisibleClient=true, silently suppressing every notification after the first. Fix: switch to AND logic so BOTH appIsVisible AND a visible client are required to suppress. A cold-start SW (appIsVisible=false) never suppresses, regardless of stale matchAll() data. 2. useAppVisibility.ts was passing isMobile as keepEnabledWhenVisible, meaning on desktop the pusher was deleted from the homeserver whenever the tab was visible. If the async re-enable in enablePushNotifications didn't complete before the page was torn down, the homeserver was left with no pusher — so no more push notifications until a manual background/foreground cycle. Fix: always pass true for keepEnabledWhenVisible so the pusher stays registered permanently. The SW's hasVisibleClient check handles OS-notification suppression in the foreground; the homeserver never needs to be without a pusher. --- .../notifications/PushNotifications.tsx | 4 +-- src/app/hooks/useAppVisibility.ts | 11 +++++--- src/app/pages/client/ClientRoot.tsx | 26 +++++++++++++++++-- src/client/initMatrix.ts | 8 ++++++ 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index 7f510b444..be9e627fb 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -57,7 +57,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: { @@ -104,7 +104,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', diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index b56f564ca..cc35418f1 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -7,7 +7,6 @@ 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'; const debugLog = createDebugLogger('AppVisibility'); @@ -16,7 +15,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 +41,17 @@ 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]); } diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 0b8261d82..a7f40de5f 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -205,7 +205,7 @@ export function ClientRoot({ children }: ClientRootProps) { log.log('initClient for', activeSession.userId); const newMx = await initClient(activeSession); loadedUserIdRef.current = activeSession.userId; - pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); + pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId); return newMx; }, [activeSession, activeSessionId, setActiveSessionId]) ); @@ -234,7 +234,7 @@ export function ClientRoot({ children }: ClientRootProps) { activeSession.userId, '— reloading client' ); - pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); + pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId); if (mx?.clientRunning) { stopClient(mx); } @@ -259,6 +259,28 @@ export function ClientRoot({ children }: ClientRootProps) { useLogoutListener(mx); useAppVisibility(mx); + // Keep the SW session warm so media fetches and push notifications work + // reliably after iOS kills and restarts the SW in the background. + // - Immediate resync whenever the tab comes back to the foreground. + // - Periodic heartbeat (10 min) keeps the persisted session up to date + // while the app is running. + const swSessionBaseUrl = activeSession?.baseUrl; + const swSessionAccessToken = activeSession?.accessToken; + const swSessionUserId = activeSession?.userId; + useEffect(() => { + if (!swSessionBaseUrl || !swSessionAccessToken) return undefined; + const resync = () => pushSessionToSW(swSessionBaseUrl, swSessionAccessToken, swSessionUserId); + const handleVisibility = () => { + if (document.visibilityState === 'visible') resync(); + }; + document.addEventListener('visibilitychange', handleVisibility); + const timer = setInterval(resync, 10 * 60 * 1000); + return () => { + document.removeEventListener('visibilitychange', handleVisibility); + clearInterval(timer); + }; + }, [swSessionBaseUrl, swSessionAccessToken, swSessionUserId]); + useEffect( () => () => { if (mx?.clientRunning) { diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 9e0496ee3..b35c8a4a7 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -802,5 +802,13 @@ export const clearLoginData = async () => { if (name) window.indexedDB.deleteDatabase(name); }); window.localStorage.clear(); + + // Unregister all service workers so the next load starts fresh. + // Especially important on iOS/mobile where stale SWs can persist. + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((r) => r.unregister())); + } + window.location.reload(); }; From daf581f004dec8e9b8b14266192e80789faa9201 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 8 May 2026 12:33:08 -0400 Subject: [PATCH 11/33] fix(mobile): autocap, RTL alignment, splash screen, and SW notification relay --- src/app/components/editor/Editor.css.ts | 3 + src/app/components/editor/Editor.tsx | 82 ++++++++++++------- .../components/message/layout/layout.css.ts | 3 + src/app/features/room/RoomTimeline.tsx | 17 +++- .../room/message/MobileMessageMenu.tsx | 35 -------- .../notifications/PushNotifications.tsx | 27 +++++- src/app/hooks/useNotificationJumper.ts | 6 +- src/app/pages/client/ClientNonUIFeatures.tsx | 38 ++++++--- src/app/pages/client/ClientRoot.tsx | 28 +++++-- src/index.tsx | 33 ++++---- src/sw-session.ts | 24 +++++- 11 files changed, 184 insertions(+), 112 deletions(-) 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 504256a51..888610cc0 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -2,11 +2,11 @@ import type { ClipboardEventHandler, KeyboardEventHandler, ReactNode } from 'rea import { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Box, Scroll, Text } from 'folds'; import type { Descendant, Editor } from 'slate'; -import { Node, Transforms, createEditor } from 'slate'; +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,35 +473,24 @@ export const CustomEditor = forwardRef( onPaste={onPaste} // Defer to OS capitalization setting (respects iOS sentence-case toggle). autoCapitalize="sentences" - // Enables autocorrect on iOS, which also helps autocapitalization work. - autoCorrect="on" + // 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); }} - // iOS Slate.js bug: an empty contenteditable doesn't signal - // "start of sentence" to autocapitalize. A two-frame round-trip - // (insert space, then delete it one frame later) lets iOS process - // the intermediate "has text" state before seeing the empty field - // again — this is what triggers sentence-case on the next keystroke. - // - // Guard: skip the trick when triggerAutoCapitalize is returning focus - // to Slate (justRestoredFocusRef is set synchronously before the - // ReactEditor.focus call). Without this guard the space→delete fires - // again, handleChange sees non-empty→empty while focused, calls - // triggerAutoCapitalize again, and the placeholder flashes in a - // tight loop every time a reply is started. - onFocus={() => { - if (!mobileOrTablet()) return; - if (justRestoredFocusRef.current) return; - requestAnimationFrame(() => { - if (!ReactEditor.isFocused(editor) || Node.string(editor).length > 0) return; - Transforms.insertText(editor, ' '); - requestAnimationFrame(() => { - Transforms.delete(editor, { reverse: true }); - }); - }); - }} /> {(hasAfter || showResponsiveAfterInline) && ( diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 3d1368d20..6fac34d73 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -231,6 +231,9 @@ export const MessageTextBody = recipe({ base: { unicodeBidi: 'plaintext', alignSelf: 'start', + // Full width ensures RTL text (direction:rtl from dir=auto) has room to right-align + // within the flex column that contains the message body. + width: '100%', wordBreak: 'break-word', fontSize: '1rem !important', // Override folds Text component to enable page zoom scaling }, diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 3e4f30309..358c4ebf7 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -115,6 +115,8 @@ const getDayDividerText = (ts: number) => { return timeDayMonthYear(ts); }; +const SCROLL_SETTLE_MS = 250; + export type RoomTimelineProps = { room: Room; eventId?: string; @@ -249,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; @@ -277,7 +283,8 @@ export function RoomTimeline({ // VList position so "Jump to Latest" appears correctly. const startJumpScrollBlock = useCallback(() => { jumpScrollBlockRef.current = true; - if (jumpScrollBlockTimerRef.current !== undefined) clearTimeout(jumpScrollBlockTimerRef.current); + if (jumpScrollBlockTimerRef.current !== undefined) + clearTimeout(jumpScrollBlockTimerRef.current); jumpScrollBlockTimerRef.current = setTimeout(() => { jumpScrollBlockRef.current = false; jumpScrollBlockTimerRef.current = undefined; @@ -439,7 +446,13 @@ export function RoomTimeline({ return () => { if (timeoutId !== undefined) clearTimeout(timeoutId); }; - }, [timelineSync.focusItem, timelineSync, reducedMotion, getRawIndexToProcessedIndex, startJumpScrollBlock]); + }, [ + timelineSync.focusItem, + timelineSync, + reducedMotion, + getRawIndexToProcessedIndex, + startJumpScrollBlock, + ]); useEffect(() => { if (timelineSync.focusItem) { diff --git a/src/app/features/room/message/MobileMessageMenu.tsx b/src/app/features/room/message/MobileMessageMenu.tsx index 6c1968ca1..f0fa694d9 100644 --- a/src/app/features/room/message/MobileMessageMenu.tsx +++ b/src/app/features/room/message/MobileMessageMenu.tsx @@ -14,8 +14,6 @@ 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 { useIsBookmarked, useBookmarkActions } from '$features/bookmarks/useBookmarks'; -import { createBookmarkItem, computeBookmarkId } from '$features/bookmarks/bookmarkDomain'; import * as css from './MobileMessageMenu.css'; export type MobileMessageMenuProps = { @@ -95,38 +93,6 @@ function ActionItem({ icon, label, danger, onClick }: ActionItemProps) { ); } -function BookmarkActionItem({ - room, - mEvent, - onClose, -}: { - room: Room; - mEvent: MatrixEvent; - onClose: () => void; -}) { - const eventId = mEvent.getId() ?? ''; - const bookmarked = useIsBookmarked(room.roomId, eventId); - const { add, remove } = useBookmarkActions(); - - if (mEvent.isRedacted()) return null; - - return ( - } - label={bookmarked ? 'Remove Bookmark' : 'Bookmark'} - onClick={() => { - if (bookmarked) { - remove(computeBookmarkId(room.roomId, eventId)).catch(() => {}); - } else { - const item = createBookmarkItem(room, mEvent); - if (item) add(item).catch(() => {}); - } - onClose(); - }} - /> - ); -} - export function MobileMessageMenu({ room, mEvent, @@ -411,7 +377,6 @@ export function MobileMessageMenu({ }} /> )} -
{/* Group 3: Destructive actions */} diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index be9e627fb..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'); @@ -69,7 +90,7 @@ export async function enablePushNotifications( }, append: false, }; - navigator.serviceWorker.controller?.postMessage({ + postToServiceWorker({ url: mx.baseUrl, type: 'togglePush', pusherData, @@ -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/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index ca73fbb7d..c1499ad4b 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -69,7 +69,7 @@ export function NotificationJumper() { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId); if (mDirects.has(pending.roomId)) { navigate(getDirectPath(), { replace: true }); - navigate(getDirectRoomPath(roomIdOrAlias, targetEventId)); + navigate(getDirectRoomPath(roomIdOrAlias)); } else { // If the room lives inside a space, route through the space path so // SpaceRouteRoomProvider can resolve it — HomeRouteRoomProvider only @@ -83,10 +83,10 @@ export function NotificationJumper() { guessPerfectParent(mx, pending.roomId, orphanParents) ?? orphanParents[0]; const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace ?? pending.roomId); navigate(getSpacePath(spaceIdOrAlias), { replace: true }); - navigate(getSpaceRoomPath(spaceIdOrAlias, roomIdOrAlias, targetEventId)); + navigate(getSpaceRoomPath(spaceIdOrAlias, roomIdOrAlias)); } else { navigate(getHomePath(), { replace: true }); - navigate(getHomeRoomPath(roomIdOrAlias, targetEventId)); + navigate(getHomeRoomPath(roomIdOrAlias)); } } setPending(null); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 342cbe8f7..0f5e06856 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -66,6 +66,27 @@ 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 @@ -658,8 +679,7 @@ 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. @@ -669,7 +689,6 @@ function SyncNotificationSettingsWithServiceWorker() { }, []); 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
{/* 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; @@ -380,8 +500,18 @@ export function MobileMessageMenu({
{/* Group 3: Destructive actions */} - {(!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId() ? ( + {(!mEvent.isRedacted() && canDelete) || + mEvent.getSender() !== mx.getUserId() || + canKick ? (
+ {canKick && ( + } + label="Kick from Room" + danger + onClick={handleKick} + /> + )} {!mEvent.isRedacted() && canDelete && ( )} From e892c0857e260bf4db445569eac4d901e111ade4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 08:54:17 -0400 Subject: [PATCH 19/33] feat(mobile): add bookmark toggle and fix keyboard cover in message menu - Add bookmark add/remove action to mobile long-press menu (respects enableMessageBookmarks setting, same as desktop menu) - Fix: when the nickname input opens, use visualViewport resize events to lift the bottom sheet above the virtual keyboard so the input is not covered on iOS/Android --- .../room/message/MobileMessageMenu.css.ts | 2 +- .../room/message/MobileMessageMenu.tsx | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/message/MobileMessageMenu.css.ts b/src/app/features/room/message/MobileMessageMenu.css.ts index fdfc954de..2ffd07695 100644 --- a/src/app/features/room/message/MobileMessageMenu.css.ts +++ b/src/app/features/room/message/MobileMessageMenu.css.ts @@ -18,7 +18,7 @@ export const Sheet = style([ DefaultReset, { position: 'fixed', - bottom: 0, + bottom: 'calc(100vh - var(--sable-visible-height, 100vh))', left: 0, right: 0, zIndex: 101, diff --git a/src/app/features/room/message/MobileMessageMenu.tsx b/src/app/features/room/message/MobileMessageMenu.tsx index 4820e7458..6aa390494 100644 --- a/src/app/features/room/message/MobileMessageMenu.tsx +++ b/src/app/features/room/message/MobileMessageMenu.tsx @@ -18,6 +18,11 @@ 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 { computeBookmarkId, createBookmarkItem } from '$features/bookmarks/bookmarkDomain'; +import { useIsBookmarked, useBookmarkActions } from '$features/bookmarks/useBookmarks'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; import { usePowerLevels } from '$hooks/usePowerLevels'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; @@ -138,6 +143,15 @@ export function MobileMessageMenu({ const [nickEditOpen, setNickEditOpen] = useState(false); const [nickDraft, setNickDraft] = useState(''); + // Bookmarks + const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks'); + const isBookmarked = useIsBookmarked(room.roomId, evtId); + const { add: addBookmark, remove: removeBookmark } = useBookmarkActions(); + + // 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(); + // Kick permissions const powerLevels = usePowerLevels(room); const creators = useRoomCreators(room); @@ -262,6 +276,16 @@ export function MobileMessageMenu({ onClose(); }, [mx, room, senderId, onClose]); + const handleBookmarkClick = useCallback(async () => { + if (isBookmarked) { + await removeBookmark(computeBookmarkId(room.roomId, evtId)); + } else { + const item = createBookmarkItem(room, mEvent); + if (item) await addBookmark(item); + } + onClose(); + }, [isBookmarked, removeBookmark, room, evtId, mEvent, addBookmark, onClose]); + const stopPropHandler = useCallback((e: React.MouseEvent) => e.stopPropagation(), []); const portalContainer = document.getElementById('portalContainer') ?? document.body; @@ -415,6 +439,13 @@ export function MobileMessageMenu({ onClick={handlePinClick} /> )} + {enableMessageBookmarks && ( + } + label={isBookmarked ? 'Remove Bookmark' : 'Bookmark Message'} + onClick={handleBookmarkClick} + /> + )} {senderId !== myUserId && (nickEditOpen ? (
From e800c8475f94ab57eb7873403d3e1416582450b3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 12:47:17 -0400 Subject: [PATCH 20/33] fix(mobile): match kick button style to delete/report; fix nickname focus on iOS --- .../room/message/MobileMessageMenu.tsx | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/app/features/room/message/MobileMessageMenu.tsx b/src/app/features/room/message/MobileMessageMenu.tsx index 6aa390494..f9cf2a21d 100644 --- a/src/app/features/room/message/MobileMessageMenu.tsx +++ b/src/app/features/room/message/MobileMessageMenu.tsx @@ -1,5 +1,6 @@ import { createPortal } from 'react-dom'; -import { Icon, Icons, Text } from 'folds'; +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'; @@ -142,6 +143,7 @@ export function MobileMessageMenu({ const setNickname = useSetAtom(setNicknameAtom); const [nickEditOpen, setNickEditOpen] = useState(false); const [nickDraft, setNickDraft] = useState(''); + const nickInputRef = useRef(null); // Bookmarks const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks'); @@ -152,6 +154,16 @@ export function MobileMessageMenu({ // 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); @@ -453,8 +465,7 @@ export function MobileMessageMenu({ Nickname setNickDraft(e.target.value)} @@ -536,12 +547,18 @@ export function MobileMessageMenu({ canKick ? (
{canKick && ( - } - label="Kick from Room" - danger + } + radii="300" + fill="None" + variant="Critical" onClick={handleKick} - /> + > + + Kick from Room + + )} {!mEvent.isRedacted() && canDelete && ( From 0db1f0dd5d96a8512e4ac24961bd5c89b4bb4b66 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 19:46:16 -0400 Subject: [PATCH 21/33] chore: add changeset --- .changeset/mobile.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mobile.md 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. From 4ab861c7dd4453a19055d0141caa414cb1936688 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 19:55:26 -0400 Subject: [PATCH 22/33] fix(mobile): address Copilot review feedback - Gate pronoun pills on showPronouns setting (regression fix) - Add unmount cleanup for useMobileLongPress setTimeout - Clear jumpScrollBlockTimerRef on RoomTimeline unmount - Void loadEventTimeline promise to suppress no-floating-promises - Only register controllerchange listener when SW controller is absent - Wrap SW unregister in try/catch so clearLoginData always reloads - Re-read emoji/sticker picker DOMRect on visualViewport changes --- src/app/features/room/RoomInput.tsx | 18 ++++++++++++++++++ src/app/features/room/RoomTimeline.tsx | 4 +++- src/app/features/room/message/Message.tsx | 10 +++++++++- src/client/initMatrix.ts | 10 +++++++--- src/sw-session.ts | 7 ++++++- 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 2d36576fa..59b7692bd 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -286,6 +286,24 @@ export const RoomInput = forwardRef( 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) { diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 358c4ebf7..44a3005ce 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -374,6 +374,8 @@ export function RoomTimeline({ useEffect( () => () => { if (initialScrollTimerRef.current !== undefined) clearTimeout(initialScrollTimerRef.current); + if (jumpScrollBlockTimerRef.current !== undefined) + clearTimeout(jumpScrollBlockTimerRef.current); }, [] ); @@ -467,7 +469,7 @@ export function RoomTimeline({ // useTimelineSync falls back to the live timeline, the useLayoutEffect // can fire and call setIsReady(true) via the normal initial-scroll path. hasInitialScrolledRef.current = false; - timelineSyncRef.current.loadEventTimeline(eventId); + void timelineSyncRef.current.loadEventTimeline(eventId); }, [eventId, room.roomId]); useEffect(() => { diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 25ccc7399..206079f84 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -342,6 +342,13 @@ function useMobileLongPress(callback: () => void, delay = 500) { ? ({ userSelect: 'none', WebkitUserSelect: 'none' } as React.CSSProperties) : undefined; + useEffect( + () => () => { + cancel(); + }, + [cancel] + ); + return { onTouchStart, onTouchMove, @@ -567,6 +574,7 @@ function MessageInternal( const [mobileOptionsOpen, setMobileOptionsOpen] = useState(false); const optionsRef = useRef(null); + const [showPronouns] = useSetting(settingsAtom, 'showPronouns'); const [parsePronouns] = useSetting(settingsAtom, 'parsePronouns'); const [useRightBubbles] = useSetting(settingsAtom, 'useRightBubbles'); @@ -615,7 +623,7 @@ function MessageInternal( {cleanedDisplayName} - {mergedPronouns.length > 0 && ( + {showPronouns && mergedPronouns.length > 0 && ( )} {showPmPInfo && ( diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index b35c8a4a7..b24e12765 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -805,9 +805,13 @@ export const clearLoginData = async () => { // Unregister all service workers so the next load starts fresh. // Especially important on iOS/mobile where stale SWs can persist. - if ('serviceWorker' in navigator) { - const registrations = await navigator.serviceWorker.getRegistrations(); - await Promise.all(registrations.map((r) => r.unregister())); + try { + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((r) => r.unregister())); + } + } catch { + // SW unregister is best-effort; reload regardless } window.location.reload(); diff --git a/src/sw-session.ts b/src/sw-session.ts index a240c87af..0f72361ec 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -19,7 +19,12 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: const postToController = () => postToWorker(navigator.serviceWorker.controller); postToController(); - navigator.serviceWorker.addEventListener('controllerchange', postToController, { once: true }); + // Only wait for a future controller if there isn't one yet — repeated calls + // (e.g. the 10-minute heartbeat) would otherwise accumulate { once: true } + // listeners that never fire when a controller is already active. + if (!navigator.serviceWorker.controller) { + navigator.serviceWorker.addEventListener('controllerchange', postToController, { once: true }); + } navigator.serviceWorker.ready .then((registration) => { postToWorker(registration.active); From 8ed1d16395120dece31a977ec04b65e560787016 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 20:51:56 -0400 Subject: [PATCH 23/33] fix(mobile/ios): keep service worker alive to prevent full reload on idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two complementary strategies: Foreground keep-alive (this commit): - Every 20 s, SyncNotificationSettingsWithServiceWorker sends a cheap { type: 'ping' } postMessage to the SW regardless of page visibility. The SW handles it with event.waitUntil(Promise.resolve()), extending its event-processing budget and preventing iOS from killing it while the tab is open but untouched. Background recovery (already in ClientRoot): - On visibilitychange → visible, and on a 10-min periodic timer, ClientRoot re-pushes the session credentials to the SW. This ensures the SW has fresh auth after iOS kills and restarts it in the background, so media fetches and push relaying work when the user returns. --- src/app/pages/client/ClientNonUIFeatures.tsx | 13 ++++++++++++- src/sw.ts | 6 ++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 0f5e06856..21dc23dc4 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -685,7 +685,18 @@ function SyncNotificationSettingsWithServiceWorker() { // 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(() => { diff --git a/src/sw.ts b/src/sw.ts index e10303157..5f742faa6 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -593,6 +593,12 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { syncIsHealthy = (data as { healthy: boolean }).healthy; } } + if (type === 'ping') { + // iOS terminates SWs after ~30 s of inactivity. The page sends a cheap + // ping every 20 s (regardless of visibility) so that event.waitUntil + // extends the SW lifetime while the app is open. + event.waitUntil(Promise.resolve()); + } if (type === 'setNotificationSettings') { if ( typeof (data as { notificationSoundEnabled?: unknown }).notificationSoundEnabled === 'boolean' From c76323487a65a276233a09d9d7074a4e0f08fd8c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 09:04:04 -0400 Subject: [PATCH 24/33] Add pull-to-refresh --- src/app/hooks/usePullToRefresh.ts | 116 +++++++++++++++++++++++++ src/app/pages/client/direct/Direct.tsx | 3 + src/app/pages/client/home/Home.tsx | 3 + src/app/pages/client/space/Space.tsx | 4 + 4 files changed, 126 insertions(+) create mode 100644 src/app/hooks/usePullToRefresh.ts diff --git a/src/app/hooks/usePullToRefresh.ts b/src/app/hooks/usePullToRefresh.ts new file mode 100644 index 000000000..9059774b4 --- /dev/null +++ b/src/app/hooks/usePullToRefresh.ts @@ -0,0 +1,116 @@ +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 + +/** + * 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. 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; + + const doRefresh = () => { + if (refreshingRef.current) return; + refreshingRef.current = true; + + mx.retryImmediately(); + getSlidingSyncManager(mx)?.retryNow(); + + // Brief delay so the spinner is visible before snapping back. + setTimeout(() => { + refreshingRef.current = false; + el.style.transform = ''; + el.style.transition = ''; + }, 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)`; + }; + + 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 — just snap back. + el.style.transition = 'transform 0.2s ease'; + el.style.transform = ''; + } + }; + + 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 = ''; + }; + }, [scrollRef, mx]); +} diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index f3b128c84..5316fde92 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -56,6 +56,7 @@ import { useDirectRooms } from './useDirectRooms'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; import { mobileOrTabletLayout } from '$utils/user-agent'; import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; +import { usePullToRefresh } from '$hooks/usePullToRefresh'; type DirectMenuProps = { requestClose: () => void; @@ -259,6 +260,8 @@ export function Direct() { const isMobile = mobileOrTabletLayout() || screenSize === ScreenSize.Mobile; const hideText = curWidth <= 80 && !isMobile; + usePullToRefresh(scrollRef, mx); + return ( void; @@ -266,6 +267,8 @@ export function Home() { const isMobile = mobileOrTabletLayout() || screenSize === ScreenSize.Mobile; const hideText = curWidth <= 80 && !isMobile; + usePullToRefresh(scrollRef, mx); + return ( Date: Wed, 20 May 2026 09:29:11 -0400 Subject: [PATCH 25/33] fix(timeline): sort events by origin_server_ts to fix ordering on mobile resume On mobile, a sync gap causes the SDK to emit TimelineReset and deliver an entire batch of events at once in server receipt order rather than chronological order. Sort timeline items by origin_server_ts before the reduce pass in useProcessedTimeline so the rendered order is always chronological regardless of how the SDK received the events. Receipt order is kept as a tiebreaker to preserve causally-related event stability when two events share the same timestamp. Also fix pre-existing useTimelineSync test failures caused by missing mock methods (getUnreadNotificationCount, getLiveTimeline, getAccountData). --- .../hooks/timeline/useProcessedTimeline.ts | 19 ++++++++++++++++++- .../hooks/timeline/useTimelineSync.test.tsx | 19 ++++++++++++++----- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 9609dafc0..022f1ac98 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].sort((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..ba1dd3eaf 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -64,10 +64,13 @@ 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; @@ -75,6 +78,12 @@ function createRoom( } describe('useTimelineSync', () => { + // Minimal MatrixClient stub that satisfies getRoomUnreadInfo's call chain. + // getAccountData returns null so getNotificationType returns Default (not Mute), + // then getEventReadUpTo returning null short-circuits roomHaveUnread to false. + const makeMx = () => + ({ getUserId: () => '@alice:test', getAccountData: () => null }) as never; + it('does not snap a non-bottom user to latest after TimelineReset', async () => { const { room, timelineSet, events } = createRoom(); const scrollToBottom = vi.fn<() => void>(); @@ -82,7 +91,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 +123,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 +151,7 @@ describe('useTimelineSync', () => { ({ room, eventId }) => useTimelineSync({ room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), eventId, isAtBottom: false, isAtBottomRef: { current: false }, @@ -179,7 +188,7 @@ describe('useTimelineSync', () => { ({ room, eventId }) => useTimelineSync({ room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), eventId, isAtBottom: false, isAtBottomRef: { current: false }, @@ -214,7 +223,7 @@ describe('useTimelineSync', () => { ({ room }) => useTimelineSync({ room, - mx: { getUserId: () => '@alice:test' } as never, + mx: makeMx(), eventId: undefined, isAtBottom: false, isAtBottomRef: { current: false }, From a6259724930c232f7b6d31822eb198e9e88424b0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 09:29:27 -0400 Subject: [PATCH 26/33] fix(mobile): rebuild timelines after pull-to-refresh After triggering a network re-sync via retryImmediately/retryNow, emit RoomEvent.TimelineRefresh on every room so that any mounted RoomTimeline rebuilds its React state from the current SDK data immediately. This covers the case where the sliding sync response has no gap (limited: false) and therefore never fires a server-side TimelineReset, leaving stale React state on screen even though the SDK is up to date. --- src/app/hooks/usePullToRefresh.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/app/hooks/usePullToRefresh.ts b/src/app/hooks/usePullToRefresh.ts index 9059774b4..1eda5626e 100644 --- a/src/app/hooks/usePullToRefresh.ts +++ b/src/app/hooks/usePullToRefresh.ts @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react'; import type { MatrixClient } from '$types/matrix-sdk'; +import { RoomEvent } from '$types/matrix-sdk'; import { getSlidingSyncManager } from '$client/initMatrix'; import { mobileOrTablet } from '$utils/user-agent'; @@ -38,6 +39,15 @@ export function usePullToRefresh( mx.retryImmediately(); getSlidingSyncManager(mx)?.retryNow(); + // Rebuild timelines for every room that currently has a RoomTimeline + // mounted. For rooms without an active subscriber this is a no-op. + // Rooms that received a server-side TimelineReset will already rebuild + // via the SDK event; this covers the case where the sync response has + // no gap (limited: false) but the user still wants fresh React state. + mx.getRooms().forEach((room) => { + room.emit(RoomEvent.TimelineRefresh, room, room.getUnfilteredTimelineSet()); + }); + // Brief delay so the spinner is visible before snapping back. setTimeout(() => { refreshingRef.current = false; From f320c6cae6c15fce649caa563069bba8f1c782d0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 10:10:13 -0400 Subject: [PATCH 27/33] fix(mobile): guard localStorage writes against QuotaExceededError; remove dead SW update prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap all localStorage.setItem calls in sessions.ts, settings.ts, and atomWithLocalStorage.ts in try/catch to handle QuotaExceededError — imagePackCache can fill the 5 MB iOS localStorage limit and cause unguarded writes to throw, silently dropping sessions/settings. - Remove showUpdateAvailablePrompt from index.tsx: the function posted SKIP_WAITING_AND_CLAIM to registration.waiting but sw.ts has no handler for that message type; skipWaiting() is unconditional at install so registration.waiting is always null. Replace the confirm dialog with a direct window.location.reload() when a new SW installs. --- src/app/state/sessions.ts | 12 ++++++++---- src/app/state/settings.ts | 6 +++++- src/app/state/utils/atomWithLocalStorage.ts | 6 +++++- src/index.tsx | 19 +------------------ 4 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index 387692f73..d2ae0ec5a 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -43,10 +43,14 @@ export function setFallbackSession( userId: string, baseUrl: string ) { - localStorage.setItem('cinny_access_token', accessToken); - localStorage.setItem('cinny_device_id', deviceId); - localStorage.setItem('cinny_user_id', userId); - localStorage.setItem('cinny_hs_base_url', baseUrl); + try { + localStorage.setItem('cinny_access_token', accessToken); + localStorage.setItem('cinny_device_id', deviceId); + localStorage.setItem('cinny_user_id', userId); + localStorage.setItem('cinny_hs_base_url', baseUrl); + } catch { + // QuotaExceededError: write best-effort; ignore if storage is full + } } export const removeFallbackSession = () => { localStorage.removeItem('cinny_hs_base_url'); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 5efe57552..71af7a0ee 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -593,7 +593,11 @@ export const getSettings = (): Settings => mergePersistedSettings(localStorage.getItem(STORAGE_KEY), runtimeSettingsDefaults); export const setSettings = (settings: Settings) => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch { + // QuotaExceededError: write best-effort; ignore if storage is full + } }; export const settingsAtom = atom( diff --git a/src/app/state/utils/atomWithLocalStorage.ts b/src/app/state/utils/atomWithLocalStorage.ts index 08f3b2fd4..1cee37f85 100644 --- a/src/app/state/utils/atomWithLocalStorage.ts +++ b/src/app/state/utils/atomWithLocalStorage.ts @@ -12,7 +12,11 @@ export const getLocalStorageItem = (key: string, defaultValue: T): T => { }; export const setLocalStorageItem = (key: string, value: unknown) => { - localStorage.setItem(key, JSON.stringify(value)); + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // QuotaExceededError: write best-effort; ignore if storage is full + } }; export type GetLocalStorageItem = (key: string) => T; diff --git a/src/index.tsx b/src/index.tsx index 52a4c849e..1005efcc6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,23 +30,6 @@ const log = createLogger('index'); document.body.classList.add(configClass, varsClass); -const showUpdateAvailablePrompt = (registration: ServiceWorkerRegistration) => { - const DONT_SHOW_PROMPT_KEY = 'cinny_dont_show_sw_update_prompt'; - const userPreference = localStorage.getItem(DONT_SHOW_PROMPT_KEY); - - if (userPreference === 'true') { - return; - } - - if (window.confirm('A new version of the app is available. Refresh to update?')) { - if (registration.waiting) { - // oxlint-disable-next-line unicorn/require-post-message-target-origin - registration.waiting.postMessage({ type: 'SKIP_WAITING_AND_CLAIM' }); - } - window.location.reload(); - } -}; - if ('serviceWorker' in navigator) { const isProduction = import.meta.env.MODE === 'production'; const swUrl = isProduction @@ -76,7 +59,7 @@ if ('serviceWorker' in navigator) { installingWorker.addEventListener('statechange', () => { if (installingWorker.state === 'installed') { if (navigator.serviceWorker.controller) { - showUpdateAvailablePrompt(registration); + window.location.reload(); } } }); From e6a706485323b7ccd5317c501fd0c146509bfc96 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 11:26:52 -0400 Subject: [PATCH 28/33] feat(mobile): add pull-to-refresh visual indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a circular pill indicator (arrow + spinner) during the pull gesture and while the refresh network request is in flight. - During pull: indicator slides down from above the safe-area inset, an arrow inside rotates from 0° → 180° as the threshold is reached (pointing down at start, pointing up at "release to refresh"). - On release: spinner replaces the arrow for 800 ms while retryImmediately / retryNow run, then the indicator slides back out. - On insufficient pull: indicator snaps back with a short ease. - Styles injected once via a