From 9b15df633b48780cb12c427f66773a1c84839df3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 08:12:24 -0400 Subject: [PATCH 01/34] 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 | 146 +++++++++--------- src/app/features/room/message/Message.tsx | 98 +++++++++--- src/app/utils/dom.ts | 12 +- src/index.css | 4 + 10 files changed, 186 insertions(+), 97 deletions(-) diff --git a/index.html b/index.html index c22ea4274..fed58f86a 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. @@ -1581,77 +1599,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); @@ -842,7 +900,7 @@ function MessageInternal( onReplyClick(mockEvent); }; - const onDoubleTap = useMobileDoubleTap(() => { + const longPress = useMobileLongPress(() => { setMobileOptionsOpen(true); }); @@ -1106,6 +1164,7 @@ function MessageInternal( )} + {canPinEvent && ( @@ -1228,7 +1287,7 @@ function MessageInternal( {messageLayout === MessageLayout.Compact && ( -
{msgContentJSX}
+
{msgContentJSX}
)} @@ -1240,14 +1299,14 @@ function MessageInternal( onContextMenu={handleContextMenu} align={useRightBubbles && senderId === mx.getUserId() ? 'right' : 'left'} > -
{msgContentJSX}
+
{msgContentJSX}
)} {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( -
+
{headerJSX} {msgContentJSX}
@@ -1359,7 +1418,7 @@ export const Event = as<'div', EventProps>( return () => document.removeEventListener('pointerdown', handleClick, { capture: true }); }, [mobileOptionsOpen]); - const onDoubleTap = useMobileDoubleTap(() => { + const longPress = useMobileLongPress(() => { setMobileOptionsOpen(true); }); @@ -1444,6 +1503,7 @@ export const Event = as<'div', EventProps>( )} + {((!mEvent.isRedacted() && canDelete && !stateEvent) || @@ -1488,7 +1548,7 @@ export const Event = as<'div', EventProps>(
)} -
+
{children}
diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index e947d9746..2ba387354 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -64,6 +64,16 @@ 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); + + const cleanup = () => { + input.removeEventListener('change', changeHandler); + if (input.parentNode) input.parentNode.removeChild(input); + }; + const changeHandler = () => { const fileList = input.files; if (!fileList) { @@ -72,7 +82,7 @@ export const selectFile = ( const files: File[] = getFilesFromFileList(fileList); resolve((multiple ? files : files[0]) as FilesOrFile); } - input.removeEventListener('change', changeHandler); + cleanup(); }; input.addEventListener('change', changeHandler); 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 c8cc095d49a20ae65247b5aa8eb1b34fb608c87c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 09:01:07 -0400 Subject: [PATCH 02/34] fix(mobile): iOS file picker selection not propagating to upload board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related issues prevented selected files from appearing in the upload board on iOS Safari: 1. selectFile (dom.ts): the 'change' event can fire with an empty FileList on iOS (e.g. picker dismissed, or iCloud file not yet downloaded). This resolved as [] rather than undefined, so handleFiles was called with no files but produced no upload items and no visible feedback. - Treat an empty FileList as undefined (same as cancellation). - Add a 'cancel' event listener (iOS 15+) for proper cleanup on dismiss. - Use a settled flag to prevent double-resolution. 2. handleFiles (RoomInput.tsx): in encrypted rooms, if encryptFile fails for all files (e.g. undownloaded iCloud file), fileItems is empty and setSelectedFiles is called silently with no items — user sees nothing. - Surface a sendError message when encryption produces zero items. --- src/app/features/room/RoomInput.tsx | 6 ++++++ src/app/utils/dom.ts | 29 +++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index f651a03d7..82812823a 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -365,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({ diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index 2ba387354..abe9fc078 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -69,23 +69,40 @@ export const selectFile = ( 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; } - cleanup(); + 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(); }); From 19ccb8fef37106ff5338ba8f2747bb89e9ec6da0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 11:09:22 -0400 Subject: [PATCH 03/34] feat(messages): replace mobile long-press bar with Discord-style bottom sheet --- src/app/features/room/message/Message.tsx | 89 +++--- .../room/message/MobileMessageMenu.css.ts | 97 ++++++ .../room/message/MobileMessageMenu.tsx | 285 ++++++++++++++++++ 3 files changed, 433 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 3b0c875d1..c6a5c271a 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,43 @@ 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; 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 +558,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 +583,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 && ( @@ -845,6 +844,7 @@ function MessageInternal( const handleContextMenu: MouseEventHandler = (evt) => { if (mobileOrTablet()) { evt.preventDefault(); + setMobileOptionsOpen(true); return; } @@ -930,7 +930,7 @@ function MessageInternal( {...focusWithinProps} ref={ref} > - {!edit && (isDesktopHover || !!menuAnchor || !!emojiBoardAnchor || mobileOptionsOpen) && ( + {!edit && (isDesktopHover || !!menuAnchor || !!emojiBoardAnchor) && (
@@ -1313,6 +1313,19 @@ function MessageInternal( )} + {mobileOptionsOpen && ( + setMobileOptionsOpen(false)} + /> + )} ); } @@ -1364,6 +1377,7 @@ export const Event = as<'div', EventProps>( const handleContextMenu: MouseEventHandler = (evt) => { if (mobileOrTablet()) { evt.preventDefault(); + setMobileOptionsOpen(true); return; } @@ -1407,17 +1421,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); }); @@ -1443,7 +1446,7 @@ export const Event = as<'div', EventProps>( {...focusWithinProps} ref={ref} > - {(isDesktopHover || !!menuAnchor || mobileOptionsOpen) && ( + {(isDesktopHover || !!menuAnchor) && (
@@ -1551,6 +1554,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..d9b8052fe --- /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.5)', + 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 c024d1b2a4999f495a4568f324a518beb8180cc6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 13:02:25 -0400 Subject: [PATCH 04/34] fix(mobile): darken long-press backdrop; clear text selection on fire - Increase MobileMessageMenu backdrop opacity from 0.5 to 0.72 so underlying messages are no longer readable through it. - Call window.getSelection()?.removeAllRanges() inside the long-press setTimeout callback before invoking the menu, clearing any text the browser selected during the gesture. --- src/app/features/room/message/Message.tsx | 2 ++ src/app/features/room/message/MobileMessageMenu.css.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index c6a5c271a..212ad5f39 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -312,6 +312,8 @@ function useMobileLongPress(callback: () => void, delay = 500) { 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); }, diff --git a/src/app/features/room/message/MobileMessageMenu.css.ts b/src/app/features/room/message/MobileMessageMenu.css.ts index d9b8052fe..e1719bbea 100644 --- a/src/app/features/room/message/MobileMessageMenu.css.ts +++ b/src/app/features/room/message/MobileMessageMenu.css.ts @@ -9,7 +9,7 @@ const slideUp = keyframes({ export const Backdrop = style({ position: 'fixed', inset: 0, - background: 'rgba(0,0,0,0.5)', + background: 'rgba(0,0,0,0.72)', zIndex: 100, }); From 6606cb8d5aec82c926dfb2d655552934a28a8cda Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 13:02:46 -0400 Subject: [PATCH 05/34] fix(emoji-picker): clamp position so picker stays on screen on narrow viewports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emoji/sticker board was positioned with right: window.innerWidth - anchorRect.right. When the emoji button is not at the far-right edge of the input bar (e.g. with mic/send buttons beside it) the computed right offset caused the picker's left edge to extend past the left side of the viewport on small screens. Clamp: right = max(0, min(rawRight, viewportWidth − boardWidth)) so the picker is always fully visible. boardWidth mirrors the EmojiBoard CSS: min(432, 100vw − 32). --- src/app/features/room/RoomInput.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 82812823a..892621af8 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1615,8 +1615,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', }} > From e021fcba732f011405471a8b10895fe49561fb95 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 13:02:56 -0400 Subject: [PATCH 06/34] fix(pwa): add viewport-fit=cover; prevent iOS rubber-band overscroll - Add viewport-fit=cover to the meta viewport tag so iOS can expose safe-area-inset values (needed for notched devices / home-screen PWA). - Add overscroll-behavior: none to body in addition to html; Safari requires it on both elements to suppress rubber-band scroll that can cause the whole app UI to shift off the top of the screen. --- index.html | 2 +- src/index.css | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index fed58f86a..6795e3828 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ Sable Client diff --git a/src/index.css b/src/index.css index 1c718af02..e6a64258a 100755 --- a/src/index.css +++ b/src/index.css @@ -58,6 +58,8 @@ 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); } From a928df3e7b8352c730bab040497892a5ebded10a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 13:28:28 -0400 Subject: [PATCH 07/34] fix(mobile): safe-area top padding; opaque backdrop; prevent text selection - index.css: add padding-top: env(safe-area-inset-top, 0px) to #root so content doesn't render behind the iOS notch / Dynamic Island when viewport-fit=cover is set. - MobileMessageMenu.css.ts: increase backdrop opacity 0.72 -> 0.88 so message content beneath is not readable through the sheet overlay. - Message.tsx: return userSelect:none style from useMobileLongPress so the div wrappers carry it on touch devices, preventing iOS from selecting message text during the long-press gesture before the menu opens. Desktop text selection is unaffected. --- src/app/features/room/message/Message.tsx | 7 +++++++ src/app/features/room/message/MobileMessageMenu.css.ts | 2 +- src/index.css | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 212ad5f39..ef948e52a 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, }; } diff --git a/src/app/features/room/message/MobileMessageMenu.css.ts b/src/app/features/room/message/MobileMessageMenu.css.ts index e1719bbea..4363e22f2 100644 --- a/src/app/features/room/message/MobileMessageMenu.css.ts +++ b/src/app/features/room/message/MobileMessageMenu.css.ts @@ -9,7 +9,7 @@ const slideUp = keyframes({ export const Backdrop = style({ position: 'fixed', inset: 0, - background: 'rgba(0,0,0,0.72)', + background: 'rgba(0,0,0,0.88)', zIndex: 100, }); diff --git a/src/index.css b/src/index.css index e6a64258a..fd5e4847d 100755 --- a/src/index.css +++ b/src/index.css @@ -68,6 +68,8 @@ body { height: 100%; display: flex; flex-direction: column; + /* Push content below the iOS notch/Dynamic Island when viewport-fit=cover is set. */ + padding-top: env(safe-area-inset-top, 0px); } *, From e12238bb710dfad950ed0a2879111a5e8531806f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 13:28:43 -0400 Subject: [PATCH 08/34] fix(editor): iOS autocapitalize for empty Slate editor on focus iOS Safari doesn't detect "start of sentence" for autocapitalization on an empty Slate contenteditable when it receives focus programmatically. Result: the first letter typed is not auto-capitalized unless the user manually types and deletes a character first. Fix: add an onFocus handler that, on mobile/tablet + empty editor, inserts a no-break space then immediately deletes it inside a requestAnimationFrame. This primes the iOS input context and makes autocapitalization work correctly from the very first keystroke. --- src/app/components/editor/Editor.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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) && ( From aaa5c08acd874ebdd6059b53ff05c355b430f237 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 13:52:30 -0400 Subject: [PATCH 09/34] fix(mobile): revert dvh layout; remove context menu dark overlay - index.css: remove height:100dvh from html (revert to height:100% only). dvh caused the app layout to be misaligned with the screen on Android/iOS because dvh shrinks with browser UI and keyboard, making the root element smaller than the visible screen area. Remove env(safe-area-inset-top) padding from #root for the same reason -- on non-notched devices this had no effect, but the combo with dvh caused visible offset on test devices. - MobileMessageMenu.css.ts: remove rgba(0,0,0,0.88) background from the Backdrop element. The backdrop div is kept (it still blocks tap-outside-to-close), but with no fill the chat timeline remains fully visible above the bottom sheet. The Sheet already has a solid var(--mx-c-surface) background so no content bleeds through it. --- src/app/features/room/message/MobileMessageMenu.css.ts | 3 ++- src/index.css | 6 ------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/app/features/room/message/MobileMessageMenu.css.ts b/src/app/features/room/message/MobileMessageMenu.css.ts index 4363e22f2..b346fe344 100644 --- a/src/app/features/room/message/MobileMessageMenu.css.ts +++ b/src/app/features/room/message/MobileMessageMenu.css.ts @@ -9,8 +9,9 @@ const slideUp = keyframes({ export const Backdrop = style({ position: 'fixed', inset: 0, - background: 'rgba(0,0,0,0.88)', zIndex: 100, + // No background — the chat timeline stays fully visible above the sheet. + // The div still intercepts pointer events to dismiss the menu on tap-outside. }); export const Sheet = style([ diff --git a/src/index.css b/src/index.css index fd5e4847d..aa1571514 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; } @@ -68,8 +64,6 @@ body { height: 100%; display: flex; flex-direction: column; - /* Push content below the iOS notch/Dynamic Island when viewport-fit=cover is set. */ - padding-top: env(safe-area-inset-top, 0px); } *, From 070c2dc9c57891dbcc62ca719578b54a00a4c830 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 14:08:59 -0400 Subject: [PATCH 10/34] fix(mobile): use real theme tokens in MobileMessageMenu; Discord-style card groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --mx-c-surface / --mx-c-on-surface CSS custom properties referenced in MobileMessageMenu.css.ts were never defined anywhere, causing the sheet background, text, and reaction buttons to all render transparent. Fixes: - Replace all var(--mx-c-*) references with the real folds color tokens: color.Surface.Container (sheet bg), color.SurfaceVariant.Container (reaction buttons / action groups), color.Surface.OnContainer (text), color.SurfaceVariant.ContainerLine (separator / handle), color.Other.Overlay (backdrop scrim), color.Critical.Main (danger text). - maxHeight: 80dvh -> 80vh (dvh was reverted in the layout fix). - Reaction buttons: circular pill shape matching the Discord quick-react row. - Actions restructured into visually-separated rounded card groups (ActionGroup style) instead of a single flat list. Three groups: 1. Reply / Reply in Thread / Edit / Forward 2. Copy Text / Copy Link / Bookmark 3. Delete / Report (danger, only shown when applicable) Adjacent items inside a group get an auto-separator via CSS + selector. - Line (folds) import removed — no longer needed. --- .../room/message/MobileMessageMenu.css.ts | 45 ++++++++++--------- .../room/message/MobileMessageMenu.tsx | 39 +++++++++------- 2 files changed, 48 insertions(+), 36 deletions(-) diff --git a/src/app/features/room/message/MobileMessageMenu.css.ts b/src/app/features/room/message/MobileMessageMenu.css.ts index b346fe344..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,9 +9,9 @@ const slideUp = keyframes({ export const Backdrop = style({ position: 'fixed', inset: 0, + // Theme-scrim overlay dims the timeline behind the sheet, just like Discord does. + background: color.Other.Overlay, zIndex: 100, - // No background — the chat timeline stays fully visible above the sheet. - // The div still intercepts pointer events to dismiss the menu on tap-outside. }); export const Sheet = style([ @@ -22,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', }, ]); @@ -35,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}`, }); @@ -43,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', }); @@ -52,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), @@ -63,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..6c52e4d01 100644 --- a/src/app/features/room/message/MobileMessageMenu.tsx +++ b/src/app/features/room/message/MobileMessageMenu.tsx @@ -1,5 +1,5 @@ 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 type { MatrixEvent, Room } from '$types/matrix-sdk'; @@ -212,11 +212,11 @@ export function MobileMessageMenu({ : undefined } /> - )} -
+ {/* Group 1: Message actions */} +
} label="Reply" @@ -236,6 +236,16 @@ export function MobileMessageMenu({ onClick={handleEditClick} /> )} + +
+ + {/* Group 2: Utility actions */} +
{(() => { const content = mEvent.getContent(); const body: string | undefined = content['m.new_content']?.body ?? content.body; @@ -263,21 +273,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 From 14aa60d2c664b26da9dd79046e53094149b0665f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 14:46:07 -0400 Subject: [PATCH 11/34] fix(layout): restore safe-area padding on #root for notch and home indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With viewport-fit=cover the layout viewport extends into device safe areas. Without padding, content renders behind the iOS status bar at the top and the home indicator bar bleeds into the app layout at the bottom. The earlier attempt added height:100dvh alongside safe-area padding which caused the layout to misalign because dvh shrinks with the soft keyboard — making the root element smaller than the screen. The fix here uses only height:100% (which does not shrink) and adds the two safe-area paddings: padding-top: env(safe-area-inset-top, 0px) // notch / status bar padding-bottom: env(safe-area-inset-bottom, 0px) // home indicator The * { box-sizing: border-box } rule (already present below) ensures the padding is included within the 100% height so nothing overflows. --- src/index.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/index.css b/src/index.css index aa1571514..2d0be9cfc 100755 --- a/src/index.css +++ b/src/index.css @@ -64,6 +64,14 @@ body { height: 100%; display: flex; flex-direction: column; + /* When viewport-fit=cover is set (see index.html), the layout viewport + extends into device safe areas. Push content away from the notch/status + bar at the top and the home-indicator / gesture bar at the bottom. + box-sizing: border-box (set by the * rule below) keeps height at 100% + so this never overflows. Do NOT use 100dvh here — dvh shrinks when the + soft keyboard opens, which would make the padding overflow the viewport. */ + padding-top: env(safe-area-inset-top, 0px); + padding-bottom: env(safe-area-inset-bottom, 0px); } *, From 78d4057beeedef02a75f393271734cf73f2bcc2b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 14:53:56 -0400 Subject: [PATCH 12/34] fix(mobile): add missing menu items; resize root to visual viewport on keyboard Missing menu items: - MobileMessageMenu.tsx: add hideReadReceipts and showDeveloperTools props; import useSetAtom + modalAtom + ModalType + getEventEdits; compute isEdited; add Read Receipts (!hideReadReceipts), Version History (isEdited), and View Source (showDeveloperTools) ActionItems in Group 1 after Forward. - Message.tsx: pass hideReadReceipts and showDeveloperTools to both MobileMessageMenu usages (MessageInternal and Event components). Keyboard overlay fix: - index.tsx: install a visualViewport.resize listener that writes --sable-app-height = visualViewport.height to documentElement. iOS Safari overlays the keyboard over the layout viewport without shrinking it; this makes the app root shrink to fit the visible area above the keyboard. - index.css: #root uses height: var(--sable-app-height, 100%) so the visualViewport-driven height takes effect as soon as JS runs. --- src/app/features/room/message/Message.tsx | 4 ++ .../room/message/MobileMessageMenu.tsx | 44 ++++++++++++++++++- src/index.css | 11 +++-- src/index.tsx | 17 +++++++ 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index ef948e52a..a2ffbdda2 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -1329,6 +1329,8 @@ function MessageInternal( canDelete={canDelete} canSendReaction={canSendReaction} isThreadedMessage={isThreadedMessage} + hideReadReceipts={hideReadReceipts} + showDeveloperTools={showDeveloperTools} onReplyClick={onReplyClick} onEditId={onEditId} onReactionToggle={onReactionToggle} @@ -1568,6 +1570,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.tsx b/src/app/features/room/message/MobileMessageMenu.tsx index 6c52e4d01..f26e83c77 100644 --- a/src/app/features/room/message/MobileMessageMenu.tsx +++ b/src/app/features/room/message/MobileMessageMenu.tsx @@ -2,10 +2,12 @@ import { createPortal } from 'react-dom'; 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(() => { @@ -242,6 +254,36 @@ export function MobileMessageMenu({ mEvent={mEvent} onClose={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(); + }} + /> + )}
{/* Group 2: Utility actions */} diff --git a/src/index.css b/src/index.css index 2d0be9cfc..1fba11260 100755 --- a/src/index.css +++ b/src/index.css @@ -62,14 +62,17 @@ body { #root { width: 100%; height: 100%; + /* When the on-screen keyboard opens, iOS Safari overlays it over the layout + viewport without resizing it. index.tsx installs a visualViewport resize + listener that sets --sable-app-height to the visible area height, causing + the root to shrink so content above the keyboard stays fully visible. + Falls back to 100% on browsers without the VisualViewport API. */ + height: var(--sable-app-height, 100%); display: flex; flex-direction: column; /* When viewport-fit=cover is set (see index.html), the layout viewport extends into device safe areas. Push content away from the notch/status - bar at the top and the home-indicator / gesture bar at the bottom. - box-sizing: border-box (set by the * rule below) keeps height at 100% - so this never overflows. Do NOT use 100dvh here — dvh shrinks when the - soft keyboard opens, which would make the padding overflow the viewport. */ + bar at the top and the home-indicator / gesture bar at the bottom. */ padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px); } diff --git a/src/index.tsx b/src/index.tsx index 1721755d5..8f65e7641 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,6 +30,23 @@ const log = createLogger('index'); document.body.classList.add(configClass, varsClass); +// Resize the root element to the visual viewport height so that when the +// on-screen keyboard opens on iOS Safari (which overlays instead of resizing +// the layout viewport), the app shrinks to fit the visible area above the +// keyboard rather than having content hidden behind it. +function syncAppHeight() { + if (window.visualViewport) { + document.documentElement.style.setProperty( + '--sable-app-height', + `${window.visualViewport.height}px` + ); + } +} +if (window.visualViewport) { + window.visualViewport.addEventListener('resize', syncAppHeight); + syncAppHeight(); +} + const showUpdateAvailablePrompt = (registration: ServiceWorkerRegistration) => { const DONT_SHOW_PROMPT_KEY = 'cinny_dont_show_sw_update_prompt'; const userPreference = localStorage.getItem(DONT_SHOW_PROMPT_KEY); From f2382997967e2442cf0b84099c820c3d71ebee8a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 16:28:30 -0400 Subject: [PATCH 13/34] fix(mobile): threshold-based visual viewport height with keyboard-open safe-area zero - syncAppHeight now compares window.innerHeight vs window.visualViewport.height; only overrides --sable-app-height when the surplus > 150 px (an actual keyboard), not on startup or for small browser-chrome changes (~80 px). This removes the gap that appeared on the splash screen when the page first loaded. - On Android, interactive-widget=resizes-content shrinks the layout viewport so the surplus stays near zero; the 150 px gate leaves height: 100% in control there, avoiding the IME-nav-bar white gap. - When the keyboard IS open, also sets --sable-safe-bottom: 0px so #root padding-bottom (env(safe-area-inset-bottom)) is zeroed out. On iOS the home indicator swipe area sits inside the keyboard region, not above it, so leaving 34 px of padding would push the input bar away from the keyboard. --- src/index.css | 18 ++++++++++-------- src/index.tsx | 27 +++++++++++++++++++++------ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/index.css b/src/index.css index 1fba11260..383c0fea0 100755 --- a/src/index.css +++ b/src/index.css @@ -61,20 +61,22 @@ body { } #root { width: 100%; - height: 100%; - /* When the on-screen keyboard opens, iOS Safari overlays it over the layout - viewport without resizing it. index.tsx installs a visualViewport resize - listener that sets --sable-app-height to the visible area height, causing - the root to shrink so content above the keyboard stays fully visible. - Falls back to 100% on browsers without the VisualViewport API. */ + /* On iOS Safari, when the keyboard opens the layout viewport stays constant + but the visual viewport shrinks. index.tsx detects this large surplus and + sets --sable-app-height so #root fits the area above the keyboard. + On Android the layout viewport already resizes, so height: 100% (the + fallback) is always correct there. */ height: var(--sable-app-height, 100%); display: flex; flex-direction: column; /* When viewport-fit=cover is set (see index.html), the layout viewport extends into device safe areas. Push content away from the notch/status - bar at the top and the home-indicator / gesture bar at the bottom. */ + bar at the top and the home-indicator / gesture bar at the bottom. + --sable-safe-bottom is set to 0 by index.tsx while the keyboard is open, + because the home indicator sits inside the keyboard region on iOS and is + no longer in the visible area above the keyboard. */ padding-top: env(safe-area-inset-top, 0px); - padding-bottom: env(safe-area-inset-bottom, 0px); + padding-bottom: var(--sable-safe-bottom, env(safe-area-inset-bottom, 0px)); } *, diff --git a/src/index.tsx b/src/index.tsx index 8f65e7641..e057d206a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,21 +30,36 @@ const log = createLogger('index'); document.body.classList.add(configClass, varsClass); -// Resize the root element to the visual viewport height so that when the -// on-screen keyboard opens on iOS Safari (which overlays instead of resizing -// the layout viewport), the app shrinks to fit the visible area above the -// keyboard rather than having content hidden behind it. +// On iOS Safari the keyboard overlays the layout viewport without resizing it, +// so window.innerHeight stays constant while window.visualViewport.height drops. +// We detect this by checking whether the surplus is large (> 150 px, which is a +// real keyboard) and only then shrink #root to keep content above the keyboard. +// On Android, interactive-widget=resizes-content already shrinks the layout +// viewport, so window.innerHeight drops with it and the surplus stays small +// (just the IME navigation bar, ~40-80 px). We leave height: 100% in control +// there to avoid the white gap caused by the IME nav bar discrepancy. function syncAppHeight() { - if (window.visualViewport) { + if (!window.visualViewport) return; + const surplus = window.innerHeight - window.visualViewport.height; + if (surplus > 150) { + // Keyboard is open. visualViewport.height is the area above the keyboard. + // On iOS the home-indicator swipe area sits within the keyboard region, not + // above it, so padding-bottom: env(safe-area-inset-bottom) would incorrectly + // eat into the visible space. Override it to 0 while the keyboard is open. document.documentElement.style.setProperty( '--sable-app-height', `${window.visualViewport.height}px` ); + document.documentElement.style.setProperty('--sable-safe-bottom', '0px'); + } else { + document.documentElement.style.removeProperty('--sable-app-height'); + document.documentElement.style.removeProperty('--sable-safe-bottom'); } } if (window.visualViewport) { + // No call at startup — height: 100% handles the initial state correctly. + // The resize event fires when the keyboard opens or browser chrome changes. window.visualViewport.addEventListener('resize', syncAppHeight); - syncAppHeight(); } const showUpdateAvailablePrompt = (registration: ServiceWorkerRegistration) => { From 999ca07cef9779f88a1f48aa7a9067c3e334d988 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 16:48:17 -0400 Subject: [PATCH 14/34] fix(mobile): revert viewport height hack, add ios-pwa-keyboard-fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the syncAppHeight/visualViewport approach that shrunk #root when the keyboard opened — it caused a large gap at page load and pushed the top bar off screen. Replace with the ios-pwa-keyboard-fix technique (vendored from https://github.com/Crscristi28/ios-pwa-keyboard-fix, MIT, until npm package is published): - Vendor 4 source files into src/app/hooks/ios-keyboard-fix/ - useKeyboardHeight: Visual Viewport API + 80ms stability filter; triggerPreLift() pre-lifts bar in onMouseDown before Safari visibility check - useScrollLock: snaps window.scrollY to 0 while keyboard is open - RoomInput: onMouseDown={triggerPreLift} + transform:translateY(-keyboardHeight) on the outer wrapper; gated by mobileOrTablet() - #root restored to plain height:100% and padding-bottom:env(safe-area-inset-bottom) --- src/app/features/room/RoomInput.tsx | 14 ++- src/app/hooks/ios-keyboard-fix/device.ts | 25 +++++ src/app/hooks/ios-keyboard-fix/index.ts | 5 + .../ios-keyboard-fix/useKeyboardHeight.ts | 104 ++++++++++++++++++ .../hooks/ios-keyboard-fix/useScrollLock.ts | 31 ++++++ src/index.css | 15 +-- src/index.tsx | 32 ------ 7 files changed, 180 insertions(+), 46 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/useKeyboardHeight.ts create mode 100644 src/app/hooks/ios-keyboard-fix/useScrollLock.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 892621af8..22f4d57ef 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,9 @@ export const RoomInput = forwardRef( const [sendError, setSendError] = useState(); const isEncrypted = room.hasEncryptionStateEvent(); + const { keyboardHeight, isKeyboardVisible, triggerPreLift } = useKeyboardHeight(); + useScrollLock(isKeyboardVisible && mobileOrTablet()); + useElementSizeObserver( useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]), useCallback((width) => setHideStickerBtn(width < 500), []) @@ -1293,7 +1297,15 @@ export const RoomInput = forwardRef( }; return ( -
+
0 && mobileOrTablet() + ? { transform: `translateY(-${keyboardHeight}px)` } + : undefined + } + > {selectedFiles.length > 0 && ( { + if (typeof window === 'undefined') return false; + // iOS Safari uses navigator.standalone (legacy, non-standard). + // Other browsers use the W3C display-mode media query. + const iosStandalone = + 'standalone' in window.navigator && + (window.navigator as Navigator & { standalone?: boolean }).standalone === true; + const displayModeStandalone = window.matchMedia('(display-mode: standalone)').matches; + return iosStandalone || displayModeStandalone; +}; + +export const isTablet = (): boolean => + typeof window !== 'undefined' && window.innerWidth >= 768; + +export const needsVirtualKeyboard = (): boolean => { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return false; + } + const hasTouchScreen = navigator.maxTouchPoints > 0; + const isCoarsePointer = window.matchMedia('(pointer: coarse)').matches; + return hasTouchScreen && isCoarsePointer; +}; diff --git a/src/app/hooks/ios-keyboard-fix/index.ts b/src/app/hooks/ios-keyboard-fix/index.ts new file mode 100644 index 000000000..4918c970a --- /dev/null +++ b/src/app/hooks/ios-keyboard-fix/index.ts @@ -0,0 +1,5 @@ +// Vendored from https://github.com/Crscristi28/ios-pwa-keyboard-fix (MIT) +// Replace this import path with 'ios-pwa-keyboard-fix' once published to npm. +export { isStandalonePWA, isTablet, needsVirtualKeyboard } from './device'; +export { useKeyboardHeight } from './useKeyboardHeight'; +export { useScrollLock } from './useScrollLock'; diff --git a/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts new file mode 100644 index 000000000..cb6596b1f --- /dev/null +++ b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts @@ -0,0 +1,104 @@ +// 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. +// Stability filter — only commits a height when iOS reports the same +// value for STABILITY_MS. iOS emits chaotic transient values during +// keyboard transitions (text ↔ emoji); waiting for the value to settle +// filters those out without a hardcoded whitelist of device heights. +// +// triggerPreLift lifts the bar to the last known height in onMouseDown, +// before focus, so Safari sees the textarea as already visible and +// skips its document-scroll prediction. +const STABILITY_MS = 80; + +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 savedHeight = useRef(0); + const hasOpenedOnce = useRef(false); + const isVisibleRef = useRef(false); + + useEffect(() => { + const viewport = window.visualViewport; + if (!viewport) return undefined; + + let baselineHeight = window.innerHeight; + let stabilityTimer: ReturnType | null = null; + let pendingValue = 0; + + const handleResize = () => { + const calculatedHeight = baselineHeight - viewport.height; + + // Closing the keyboard — react instantly, no stability check + if (calculatedHeight < 30) { + if (stabilityTimer) { + clearTimeout(stabilityTimer); + stabilityTimer = null; + } + setKeyboardHeight(0); + setIsKeyboardVisible(false); + isVisibleRef.current = false; + return; + } + + // Wait for the value to settle. Each new resize within STABILITY_MS + // restarts the timer, so transient mid-transition readings never + // commit — only the value iOS finally lands on does. + pendingValue = calculatedHeight; + if (stabilityTimer) clearTimeout(stabilityTimer); + stabilityTimer = setTimeout(() => { + savedHeight.current = pendingValue; + hasOpenedOnce.current = true; + isVisibleRef.current = true; + 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; + savedHeight.current = 0; + hasOpenedOnce.current = false; + isVisibleRef.current = false; + 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 () => { + if (stabilityTimer) clearTimeout(stabilityTimer); + viewport.removeEventListener('resize', handleResize); + window.removeEventListener('orientationchange', handleOrientationChange); + }; + }, []); + + // 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 && savedHeight.current > 0 && isVisibleRef.current) { + setKeyboardHeight(savedHeight.current); + } + }; + + return { keyboardHeight, isKeyboardVisible, triggerPreLift }; +} diff --git a/src/app/hooks/ios-keyboard-fix/useScrollLock.ts b/src/app/hooks/ios-keyboard-fix/useScrollLock.ts new file mode 100644 index 000000000..08e14e6db --- /dev/null +++ b/src/app/hooks/ios-keyboard-fix/useScrollLock.ts @@ -0,0 +1,31 @@ +// 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(() => { + 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 383c0fea0..04a69e52e 100755 --- a/src/index.css +++ b/src/index.css @@ -61,22 +61,11 @@ body { } #root { width: 100%; - /* On iOS Safari, when the keyboard opens the layout viewport stays constant - but the visual viewport shrinks. index.tsx detects this large surplus and - sets --sable-app-height so #root fits the area above the keyboard. - On Android the layout viewport already resizes, so height: 100% (the - fallback) is always correct there. */ - height: var(--sable-app-height, 100%); + height: 100%; display: flex; flex-direction: column; - /* When viewport-fit=cover is set (see index.html), the layout viewport - extends into device safe areas. Push content away from the notch/status - bar at the top and the home-indicator / gesture bar at the bottom. - --sable-safe-bottom is set to 0 by index.tsx while the keyboard is open, - because the home indicator sits inside the keyboard region on iOS and is - no longer in the visible area above the keyboard. */ padding-top: env(safe-area-inset-top, 0px); - padding-bottom: var(--sable-safe-bottom, env(safe-area-inset-bottom, 0px)); + padding-bottom: env(safe-area-inset-bottom, 0px); } *, diff --git a/src/index.tsx b/src/index.tsx index e057d206a..1721755d5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,38 +30,6 @@ const log = createLogger('index'); document.body.classList.add(configClass, varsClass); -// On iOS Safari the keyboard overlays the layout viewport without resizing it, -// so window.innerHeight stays constant while window.visualViewport.height drops. -// We detect this by checking whether the surplus is large (> 150 px, which is a -// real keyboard) and only then shrink #root to keep content above the keyboard. -// On Android, interactive-widget=resizes-content already shrinks the layout -// viewport, so window.innerHeight drops with it and the surplus stays small -// (just the IME navigation bar, ~40-80 px). We leave height: 100% in control -// there to avoid the white gap caused by the IME nav bar discrepancy. -function syncAppHeight() { - if (!window.visualViewport) return; - const surplus = window.innerHeight - window.visualViewport.height; - if (surplus > 150) { - // Keyboard is open. visualViewport.height is the area above the keyboard. - // On iOS the home-indicator swipe area sits within the keyboard region, not - // above it, so padding-bottom: env(safe-area-inset-bottom) would incorrectly - // eat into the visible space. Override it to 0 while the keyboard is open. - document.documentElement.style.setProperty( - '--sable-app-height', - `${window.visualViewport.height}px` - ); - document.documentElement.style.setProperty('--sable-safe-bottom', '0px'); - } else { - document.documentElement.style.removeProperty('--sable-app-height'); - document.documentElement.style.removeProperty('--sable-safe-bottom'); - } -} -if (window.visualViewport) { - // No call at startup — height: 100% handles the initial state correctly. - // The resize event fires when the keyboard opens or browser chrome changes. - window.visualViewport.addEventListener('resize', syncAppHeight); -} - const showUpdateAvailablePrompt = (registration: ServiceWorkerRegistration) => { const DONT_SHOW_PROMPT_KEY = 'cinny_dont_show_sw_update_prompt'; const userPreference = localStorage.getItem(DONT_SHOW_PROMPT_KEY); From 85bc002102d2fa9e0e91377f50bd681a24c47527 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 17:26:44 -0400 Subject: [PATCH 15/34] fix(mobile): use CSS var layout for keyboard, reset scroll on open --- src/app/features/room/RoomInput.tsx | 49 +++++++++++++++---- src/app/hooks/ios-keyboard-fix/device.ts | 3 +- .../hooks/ios-keyboard-fix/useScrollLock.ts | 6 +++ src/index.css | 11 ++++- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 22f4d57ef..77852ad94 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -414,9 +414,46 @@ export const RoomInput = forwardRef( const [sendError, setSendError] = useState(); const isEncrypted = room.hasEncryptionStateEvent(); - const { keyboardHeight, isKeyboardVisible, triggerPreLift } = useKeyboardHeight(); + 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), []) @@ -1297,15 +1334,7 @@ export const RoomInput = forwardRef( }; return ( -
0 && mobileOrTablet() - ? { transform: `translateY(-${keyboardHeight}px)` } - : undefined - } - > +
{selectedFiles.length > 0 && ( { return iosStandalone || displayModeStandalone; }; -export const isTablet = (): boolean => - typeof window !== 'undefined' && window.innerWidth >= 768; +export const isTablet = (): boolean => typeof window !== 'undefined' && window.innerWidth >= 768; export const needsVirtualKeyboard = (): boolean => { if (typeof window === 'undefined' || typeof navigator === 'undefined') { diff --git a/src/app/hooks/ios-keyboard-fix/useScrollLock.ts b/src/app/hooks/ios-keyboard-fix/useScrollLock.ts index 08e14e6db..d4c07c6e0 100644 --- a/src/app/hooks/ios-keyboard-fix/useScrollLock.ts +++ b/src/app/hooks/ios-keyboard-fix/useScrollLock.ts @@ -17,6 +17,12 @@ import { useEffect } from 'react'; // 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); diff --git a/src/index.css b/src/index.css index 04a69e52e..bded059b9 100755 --- a/src/index.css +++ b/src/index.css @@ -61,11 +61,18 @@ body { } #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); - padding-bottom: env(safe-area-inset-bottom, 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 fc50b178b99e5c6b5a4d3e3aba7c213d8ffab70e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 17:46:19 -0400 Subject: [PATCH 16/34] fix(mobile): fill safe-area gaps with Background.Container color --- src/app/styles/overrides/General.css.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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( ` From be0f2683082120c5e8d8965ae6a93c5a87d754e4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 18:01:04 -0400 Subject: [PATCH 17/34] fix(mobile): remove #root padding-bottom, scope safe-area to room input bar --- src/app/features/room/RoomView.tsx | 8 +++++++- src/index.css | 9 +++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index a5329bed4..f9e9b1a7f 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -160,7 +160,13 @@ export function RoomView({ eventId }: { eventId?: string }) { - + {canMessage && delayedEventsSupported && ( )} diff --git a/src/index.css b/src/index.css index bded059b9..e484291cf 100755 --- a/src/index.css +++ b/src/index.css @@ -69,10 +69,11 @@ body { 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)); + /* No padding-bottom here — content fills edge-to-edge and the home + indicator floats on top (standard iOS native-app pattern). Safe-area + bottom padding is applied only to the room input bar (via the + --sable-safe-bottom variable in RoomView) so nothing interactive + is hidden behind the indicator. */ } *, From 5f9ac2f66c948c09d3a6a65245080dfe708cf845 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 18:06:43 -0400 Subject: [PATCH 18/34] fix(mobile): forward appearance, keyboard jank, bookmark data integrity - Replace MessageForwardItem (folds MenuItem) with ActionItem in MobileMessageMenu so Forward matches the icon-left style of all other menu items (uses Icons.ArrowGoRight) - Reset window.scrollY immediately on every visualViewport resize event while keyboard is opening, not just after the 80 ms stability timer, so iOS scroll-prediction jank is eliminated before any frame is painted - Rewrite BookmarkActionItem to use useIsBookmarked + useBookmarkActions + createBookmarkItem instead of the legacy useBookmarks/toggleBookmark hook, ensuring bookmarks are saved with the full MSC4438 payload (version, uri, event_ts, bookmarked_ts, room_name, body_preview, etc.) and appear correctly in the Bookmarks panel --- .../room/message/MobileMessageMenu.tsx | 28 +++++++++++-------- .../ios-keyboard-fix/useKeyboardHeight.ts | 8 ++++++ 2 files changed, 25 insertions(+), 11 deletions(-) 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 && ( { From e144ee3a5630fac7882dd248e6e3ce9b52e36c5e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 18:37:37 -0400 Subject: [PATCH 19/34] fix(mobile): safe area for three-dots button and bottom gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add position:relative to #root so ClientRootOptions' position:absolute button has a containing block within the app rather than the viewport - Change ClientRootOptions button top from S100 to calc(env(safe-area-inset-top, 0px) + S100) so the three-dots menu appears below the status bar / notch, not behind it - Change SplashScreen from minHeight:'100%' to flexGrow:1 for reliable flex-item height on WebKit without depending on the percentage resolution quirk that can leave a gap when the flex container uses box-sizing:border-box + padding-top - Split SplashScreenFooter padding so paddingBottom uses max(S400, env(safe-area-inset-bottom,0px)) — footer clears the home indicator / Android gesture bar while keeping a minimum S400 gap on devices with no bottom safe area - Apply the same max() pattern to PageNavContent paddingBottom so the room-list nav never clips content behind the home indicator --- src/app/components/page/style.css.ts | 3 ++- src/app/components/splash-screen/SplashScreen.css.ts | 9 +++++++-- src/app/pages/client/ClientRoot.tsx | 2 +- src/index.css | 1 + 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/app/components/page/style.css.ts b/src/app/components/page/style.css.ts index 8a5472c48..49be2de5b 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/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) { Date: Tue, 12 May 2026 19:08:01 -0400 Subject: [PATCH 20/34] fix(mobile): eliminate keyboard jank and clean up room footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyboard jank: - Destructure triggerPreLift from useKeyboardHeight and wire it to onMouseDown on the RoomInput outer div, so the bar pre-lifts immediately when the user taps the input while the keyboard is already open (prevents iOS scroll prediction between input switches) - Add a second visualViewport resize listener that sets --sable-visible-height on the very first event signalling the keyboard is opening (closed→open transition only), bypassing the 80 ms stability gate; the layout therefore shrinks in the same frame as the iOS keyboard animation starts, before iOS can commit its scroll prediction — eliminating the push-up-then-snap jank on initial open; text↔emoji mode switches are unaffected Room footer white strip: - Give the shrink=No footer Box backgroundColor: Surface.Container so the area around the editor and the safe-area padding strip both use the off-white surface colour instead of showing the white body background through the transparent container Following bar size: - Remove minHeight: 28px from RoomViewFollowing so the bar is sized naturally to its content; replace the zero vertical padding with S100 top/bottom so there is a small breathing margin when text is present without adding unnecessary dead space when empty --- src/app/features/room/RoomInput.tsx | 36 +++++++++++++++++-- src/app/features/room/RoomView.tsx | 1 + .../features/room/RoomViewFollowing.css.ts | 3 +- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 77852ad94..2fd324be2 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -414,7 +414,7 @@ export const RoomInput = forwardRef( const [sendError, setSendError] = useState(); const isEncrypted = room.hasEncryptionStateEvent(); - const { keyboardHeight, isKeyboardVisible } = useKeyboardHeight(); + const { keyboardHeight, isKeyboardVisible, triggerPreLift } = useKeyboardHeight(); useScrollLock(isKeyboardVisible && mobileOrTablet()); // When the keyboard opens, shrink #root to the visual viewport height @@ -445,6 +445,37 @@ export const RoomInput = forwardRef( return undefined; }, [isKeyboardVisible, keyboardHeight]); + // Immediate-resize listener: set --sable-visible-height on the very first + // viewport resize that signals the keyboard is opening, bypassing the 80 ms + // stability gate so the layout shrinks before iOS can apply scroll prediction. + // Only fires on the closed→open transition; text↔emoji mode switches leave + // the existing CSS var intact (the stability-gated path handles those). + useEffect(() => { + if (!mobileOrTablet()) return undefined; + const viewport = window.visualViewport; + if (!viewport) return undefined; + + const handleImmediateResize = () => { + const kbHeight = window.innerHeight - viewport.height; + if ( + kbHeight > 30 && + !document.documentElement.style.getPropertyValue('--sable-visible-height') + ) { + document.documentElement.style.setProperty( + '--sable-visible-height', + `${Math.round(viewport.height)}px` + ); + document.documentElement.style.setProperty('--sable-safe-bottom', '0px'); + if (window.scrollY !== 0) { + window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); + } + } + }; + + viewport.addEventListener('resize', handleImmediateResize); + return () => viewport.removeEventListener('resize', handleImmediateResize); + }, []); + // Safety: remove CSS variables if RoomInput unmounts while keyboard open. useEffect( () => () => { @@ -1334,7 +1365,8 @@ export const RoomInput = forwardRef( }; return ( -
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
{selectedFiles.length > 0 && ( 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, From 86f8609a79ae5662c8b5f07685d0a6d4317a6549 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 20:56:22 -0400 Subject: [PATCH 21/34] fix(pwa): use 100dvh on html to fill full display on iOS On iOS PWA with viewport-fit=cover, html { height: 100% } may resolve to the safe-area viewport rather than the full display due to a WebKit quirk with the Initial Containing Block. Adding padding-top on #root then subtracts safe-area-top from the content height, leaving a blank strip at the bottom equal to safe-area-inset-top (~47px on iPhone 14). Adding height: 100dvh as a progressive enhancement fixes this: the dvh unit always resolves to the full dynamic viewport (= full display in standalone PWA mode). Browsers that don't support dvh ignore the declaration and keep height: 100%. Also format BookmarksTab.tsx import to match oxfmt style. --- src/index.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/index.css b/src/index.css index c32596e26..b3389dff3 100755 --- a/src/index.css +++ b/src/index.css @@ -43,6 +43,14 @@ html { height: 100%; + /* On iOS standalone PWA with viewport-fit=cover the initial containing + block (ICB) may still resolve to the safe-area viewport rather than + the full display, making height:100% shorter than the screen and + leaving a blank strip at the bottom equal to safe-area-top. + 100dvh always equals the full dynamic viewport (= full screen in PWA + mode where there is no browser chrome). Browsers that do not support + dvh units silently ignore this second declaration and keep height:100%. */ + height: 100dvh; overflow: hidden; overscroll-behavior: none; } From f7fdb3ab0aa4c404c8589d06e60bc1b179ecde0d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 13:03:43 -0400 Subject: [PATCH 22/34] fix(sidebar): show unread notification dots on space tabs SpaceTab was using RoomUnreadProvider with the space's own roomId, but space rooms carry no messages so the unread count was always zero. Replace with the same recursive aggregation pattern used by SpaceMenu: useSpaceChildren + useRecursiveChildScopeFactory to gather all descendant room IDs, then useRoomsUnread to sum their unread counts. The SidebarUnreadBadge now lights up correctly whenever any child room (or sub-space room) has unread messages or highlights. --- src/app/pages/client/sidebar/SpaceTabs.tsx | 140 +++++++++++---------- 1 file changed, 73 insertions(+), 67 deletions(-) diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 4ffd0b160..6c1c1243d 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -49,7 +49,7 @@ import { SidebarFolder, SidebarFolderDropTarget, } from '$components/sidebar'; -import { RoomUnreadProvider, RoomsUnreadProvider } from '$components/RoomUnreadProvider'; +import { RoomsUnreadProvider } from '$components/RoomUnreadProvider'; import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; import { getCanonicalAliasOrRoomId, isRoomAlias } from '$utils/matrix'; import { RoomAvatar } from '$components/room-avatar'; @@ -411,6 +411,16 @@ function SpaceTab({ const [menuAnchor, setMenuAnchor] = useState(); + // 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(); @@ -421,74 +431,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} + /> + + } + /> )} - + ); } From 84846afc4b786a3b08931414b76141e1a41a9361 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 21:54:24 -0400 Subject: [PATCH 23/34] fix(mobile): consolidate keyboard CSS vars into hook, fix scroll jank and jump-to-present --- src/app/features/room/RoomInput.tsx | 77 ++----------------- src/app/features/room/RoomTimeline.tsx | 8 ++ .../ios-keyboard-fix/useKeyboardHeight.ts | 73 ++++++++++++++---- 3 files changed, 71 insertions(+), 87 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 2fd324be2..6e4834452 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -414,76 +414,13 @@ export const RoomInput = forwardRef( const [sendError, setSendError] = useState(); const isEncrypted = room.hasEncryptionStateEvent(); - const { keyboardHeight, isKeyboardVisible, triggerPreLift } = 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]); - - // Immediate-resize listener: set --sable-visible-height on the very first - // viewport resize that signals the keyboard is opening, bypassing the 80 ms - // stability gate so the layout shrinks before iOS can apply scroll prediction. - // Only fires on the closed→open transition; text↔emoji mode switches leave - // the existing CSS var intact (the stability-gated path handles those). - useEffect(() => { - if (!mobileOrTablet()) return undefined; - const viewport = window.visualViewport; - if (!viewport) return undefined; - - const handleImmediateResize = () => { - const kbHeight = window.innerHeight - viewport.height; - if ( - kbHeight > 30 && - !document.documentElement.style.getPropertyValue('--sable-visible-height') - ) { - document.documentElement.style.setProperty( - '--sable-visible-height', - `${Math.round(viewport.height)}px` - ); - document.documentElement.style.setProperty('--sable-safe-bottom', '0px'); - if (window.scrollY !== 0) { - window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); - } - } - }; - - viewport.addEventListener('resize', handleImmediateResize); - return () => viewport.removeEventListener('resize', handleImmediateResize); - }, []); - - // 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]), diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d63faa989..4a0983f95 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -475,6 +475,14 @@ export function RoomTimeline({ if (shrank && atBottom) { 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; }); diff --git a/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts index 7b795d1d5..233c01de0 100644 --- a/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts +++ b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts @@ -2,15 +2,23 @@ // 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. -// Stability filter — only commits a height when iOS reports the same -// value for STABILITY_MS. iOS emits chaotic transient values during -// keyboard transitions (text ↔ emoji); waiting for the value to settle -// filters those out without a hardcoded whitelist of device heights. +// 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. // -// triggerPreLift lifts the bar to the last known height in onMouseDown, -// before focus, so Safari sees the textarea as already visible and -// skips its document-scroll prediction. +// 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; export function useKeyboardHeight() { @@ -30,39 +38,68 @@ export function useKeyboardHeight() { let baselineHeight = window.innerHeight; let stabilityTimer: ReturnType | null = null; let pendingValue = 0; + // Tracks whether --sable-visible-height is currently set so the opening + // path only fires setCSSVars once (avoids double-setting on repeated + // resize events while the keyboard is already open). + let cssVarsSet = false; + + const setCSSVars = (viewportHeight: number) => { + document.documentElement.style.setProperty( + '--sable-visible-height', + `${Math.round(viewportHeight)}px` + ); + document.documentElement.style.setProperty('--sable-safe-bottom', '0px'); + cssVarsSet = true; + }; + + const clearCSSVars = () => { + document.documentElement.style.removeProperty('--sable-visible-height'); + document.documentElement.style.removeProperty('--sable-safe-bottom'); + cssVarsSet = false; + }; const handleResize = () => { const calculatedHeight = baselineHeight - viewport.height; - // Closing the keyboard — react instantly, no stability check + // 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; } - // Wait for the value to settle. Each new resize within STABILITY_MS - // restarts the timer, so transient mid-transition readings never - // commit — only the value iOS finally lands on does. - // - // Immediately reset any document scroll iOS may have applied as - // scroll-prediction during the first focus. We do this on every - // resize event while the keyboard is opening so the snap happens - // as early as possible — before the user can see the jank. + // 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. + if (!cssVarsSet) { + setCSSVars(viewport.height); + } + + // 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(() => { savedHeight.current = pendingValue; hasOpenedOnce.current = true; isVisibleRef.current = true; + setCSSVars(viewport.height); // refine to final settled viewport height setKeyboardHeight(pendingValue); setIsKeyboardVisible(true); }, STABILITY_MS); @@ -80,6 +117,7 @@ export function useKeyboardHeight() { savedHeight.current = 0; hasOpenedOnce.current = false; isVisibleRef.current = false; + clearCSSVars(); setKeyboardHeight(0); setIsKeyboardVisible(false); // Re-baseline after iOS settles the new layout. @@ -94,6 +132,7 @@ export function useKeyboardHeight() { if (stabilityTimer) clearTimeout(stabilityTimer); viewport.removeEventListener('resize', handleResize); window.removeEventListener('orientationchange', handleOrientationChange); + clearCSSVars(); }; }, []); From d6aa0d0d4d67ed7d7a45ae365fcf8f2e673f2b68 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 22:10:36 -0400 Subject: [PATCH 24/34] fix(mobile): fix banner position on keyboard close, iOS autocapitalize, notification back nav --- src/app/components/editor/Editor.tsx | 12 ++++-- .../NotificationBanner.tsx | 38 +------------------ src/app/hooks/useNotificationJumper.ts | 28 +++++++++----- 3 files changed, 27 insertions(+), 51 deletions(-) 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/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index a03342fc5..ca73fbb7d 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -7,7 +7,14 @@ import { mDirectAtom } from '../state/mDirectList'; import { useSyncState } from './useSyncState'; import { useMatrixClient } from './useMatrixClient'; import { getCanonicalAliasOrRoomId } from '../utils/matrix'; -import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath } from '../pages/pathUtils'; +import { + getDirectRoomPath, + getHomeRoomPath, + getSpaceRoomPath, + getDirectPath, + getHomePath, + getSpacePath, +} from '../pages/pathUtils'; import { getOrphanParents, guessPerfectParent } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { createLogger } from '../utils/debug'; @@ -56,9 +63,13 @@ export function NotificationJumper() { jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which // on mobile shows the space-nav panel first instead of the room timeline. + // First replace the current history entry with the section overview so that + // pressing back (including native iOS swipe-back) returns to the section list + // rather than the room the user was in before the notification. const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId); if (mDirects.has(pending.roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getDirectPath(), { replace: true }); + navigate(getDirectRoomPath(roomIdOrAlias, targetEventId)); } else { // If the room lives inside a space, route through the space path so // SpaceRouteRoomProvider can resolve it — HomeRouteRoomProvider only @@ -70,15 +81,12 @@ export function NotificationJumper() { if (orphanParents.length > 0) { const parentSpace = guessPerfectParent(mx, pending.roomId, orphanParents) ?? orphanParents[0]; - navigate( - getSpaceRoomPath( - getCanonicalAliasOrRoomId(mx, parentSpace ?? pending.roomId), - roomIdOrAlias, - pending.eventId - ) - ); + const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace ?? pending.roomId); + navigate(getSpacePath(spaceIdOrAlias), { replace: true }); + navigate(getSpaceRoomPath(spaceIdOrAlias, roomIdOrAlias, targetEventId)); } else { - navigate(getHomeRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getHomePath(), { replace: true }); + navigate(getHomeRoomPath(roomIdOrAlias, targetEventId)); } } setPending(null); From d8f1157d9a1b920316724f110b3df3fbe07b0baf Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 22:27:35 -0400 Subject: [PATCH 25/34] fix(mobile): suppress post-close iOS viewport bounce from re-setting CSS height vars --- .../ios-keyboard-fix/useKeyboardHeight.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts index 233c01de0..3ef2f4e1b 100644 --- a/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts +++ b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts @@ -42,6 +42,20 @@ export function useKeyboardHeight() { // path only fires setCSSVars once (avoids double-setting on repeated // resize events while the keyboard is already open). let cssVarsSet = false; + // Timestamp of the last clearCSSVars call. Used to suppress re-setting CSS + // vars from post-close-animation iOS viewport bounces: after the keyboard + // fully closes, iOS sometimes emits one or two more viewport.resize events + // with a residual calculatedHeight (30–80px). Without suppression these + // events call setCSSVars() with a sub-full-screen viewport.height, leaving + // --sable-visible-height stuck and producing a persistent bottom gap. + let recentlyClearedAt = 0; + // How long to suppress small-calculatedHeight re-sets after a clear. + const POST_CLEAR_SUPPRESS_MS = 500; + // Minimum calculatedHeight that overrides the bounce-suppression window. + // Any value >= this is unambiguously a real keyboard (250 px is far below + // the smallest real iOS keyboard). Values below this within the suppress + // window are treated as animation noise and ignored. + const UNAMBIGUOUS_KEYBOARD_PX = 100; const setCSSVars = (viewportHeight: number) => { document.documentElement.style.setProperty( @@ -56,6 +70,7 @@ export function useKeyboardHeight() { document.documentElement.style.removeProperty('--sable-visible-height'); document.documentElement.style.removeProperty('--sable-safe-bottom'); cssVarsSet = false; + recentlyClearedAt = Date.now(); }; const handleResize = () => { @@ -81,8 +96,17 @@ export function useKeyboardHeight() { // 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. + // + // Bounce-suppression: within POST_CLEAR_SUPPRESS_MS of a clearCSSVars + // call, only set vars if the calculatedHeight is unambiguously a real + // keyboard (>= UNAMBIGUOUS_KEYBOARD_PX). This stops post-close-animation + // iOS viewport noise (typically 30–80 px residual) from re-setting + // --sable-visible-height to a sub-full-screen value and creating a gap. if (!cssVarsSet) { - setCSSVars(viewport.height); + const withinSuppressWindow = Date.now() - recentlyClearedAt < POST_CLEAR_SUPPRESS_MS; + if (!withinSuppressWindow || calculatedHeight >= UNAMBIGUOUS_KEYBOARD_PX) { + setCSSVars(viewport.height); + } } // Cancel any document scroll iOS may have applied as scroll-prediction. From ec58f467b1ae6a8100d4b3777100825f02543dcd Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 22:33:08 -0400 Subject: [PATCH 26/34] Revert "fix(mobile): suppress post-close iOS viewport bounce from re-setting CSS height vars" This reverts commit 7faefaa9f2e1632488c0e8a21541f6742aed0848. --- .../ios-keyboard-fix/useKeyboardHeight.ts | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts index 3ef2f4e1b..233c01de0 100644 --- a/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts +++ b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts @@ -42,20 +42,6 @@ export function useKeyboardHeight() { // path only fires setCSSVars once (avoids double-setting on repeated // resize events while the keyboard is already open). let cssVarsSet = false; - // Timestamp of the last clearCSSVars call. Used to suppress re-setting CSS - // vars from post-close-animation iOS viewport bounces: after the keyboard - // fully closes, iOS sometimes emits one or two more viewport.resize events - // with a residual calculatedHeight (30–80px). Without suppression these - // events call setCSSVars() with a sub-full-screen viewport.height, leaving - // --sable-visible-height stuck and producing a persistent bottom gap. - let recentlyClearedAt = 0; - // How long to suppress small-calculatedHeight re-sets after a clear. - const POST_CLEAR_SUPPRESS_MS = 500; - // Minimum calculatedHeight that overrides the bounce-suppression window. - // Any value >= this is unambiguously a real keyboard (250 px is far below - // the smallest real iOS keyboard). Values below this within the suppress - // window are treated as animation noise and ignored. - const UNAMBIGUOUS_KEYBOARD_PX = 100; const setCSSVars = (viewportHeight: number) => { document.documentElement.style.setProperty( @@ -70,7 +56,6 @@ export function useKeyboardHeight() { document.documentElement.style.removeProperty('--sable-visible-height'); document.documentElement.style.removeProperty('--sable-safe-bottom'); cssVarsSet = false; - recentlyClearedAt = Date.now(); }; const handleResize = () => { @@ -96,17 +81,8 @@ export function useKeyboardHeight() { // 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. - // - // Bounce-suppression: within POST_CLEAR_SUPPRESS_MS of a clearCSSVars - // call, only set vars if the calculatedHeight is unambiguously a real - // keyboard (>= UNAMBIGUOUS_KEYBOARD_PX). This stops post-close-animation - // iOS viewport noise (typically 30–80 px residual) from re-setting - // --sable-visible-height to a sub-full-screen value and creating a gap. if (!cssVarsSet) { - const withinSuppressWindow = Date.now() - recentlyClearedAt < POST_CLEAR_SUPPRESS_MS; - if (!withinSuppressWindow || calculatedHeight >= UNAMBIGUOUS_KEYBOARD_PX) { - setCSSVars(viewport.height); - } + setCSSVars(viewport.height); } // Cancel any document scroll iOS may have applied as scroll-prediction. From 8ceec5c3b5a7fa65a4dab358658f9c04b4aea4ec Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 12 May 2026 23:09:56 -0400 Subject: [PATCH 27/34] fix(pwa): extend ICB workaround to body to prevent bottom gap on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS standalone PWA with viewport-fit=cover, the WebKit ICB quirk can cause the entire percentage-height chain (html → body → #root) to resolve against the safe-area viewport rather than the full display. The previous fix applied height:100dvh only to html; body { height:100% } could still resolve against the shorter ICB value, leaving #root (and all content, including the splash screen) short by safe-area-inset-top (~47-59px). Adding height:100dvh as a second declaration on body follows the same progressive-enhancement pattern already used on html: browsers without dvh support ignore the declaration and keep height:100%; iOS 15.4+ uses 100dvh directly, bypassing the ICB chain and giving body the correct full-display height. This propagates to #root { height:100% } without any further changes. --- src/index.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/index.css b/src/index.css index b3389dff3..afb59fcb4 100755 --- a/src/index.css +++ b/src/index.css @@ -59,6 +59,12 @@ body { margin: 0; padding: 0; height: 100%; + /* Same ICB workaround as html: on iOS PWA with viewport-fit=cover the + percentage-height chain (html→body→#root) can resolve against the + safe-area viewport rather than the full display on some WebKit versions. + 100dvh as a progressive enhancement bypasses this; browsers without + dvh support ignore the declaration and keep height:100%. */ + height: 100dvh; font-family: var(--font-secondary); font-size: 16px; font-weight: 400; From 69c73358a96264f634cb4f8efac557eb3e56d814 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 07:32:24 -0400 Subject: [PATCH 28/34] fix(pwa): revert viewport-fit=cover and ICB height workarounds --- index.html | 5 +---- src/index.css | 20 -------------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/index.html b/index.html index 6795e3828..c22ea4274 100644 --- a/index.html +++ b/index.html @@ -3,10 +3,7 @@ - + Sable Client diff --git a/src/index.css b/src/index.css index afb59fcb4..36eb5ff40 100755 --- a/src/index.css +++ b/src/index.css @@ -43,14 +43,6 @@ html { height: 100%; - /* On iOS standalone PWA with viewport-fit=cover the initial containing - block (ICB) may still resolve to the safe-area viewport rather than - the full display, making height:100% shorter than the screen and - leaving a blank strip at the bottom equal to safe-area-top. - 100dvh always equals the full dynamic viewport (= full screen in PWA - mode where there is no browser chrome). Browsers that do not support - dvh units silently ignore this second declaration and keep height:100%. */ - height: 100dvh; overflow: hidden; overscroll-behavior: none; } @@ -59,12 +51,6 @@ body { margin: 0; padding: 0; height: 100%; - /* Same ICB workaround as html: on iOS PWA with viewport-fit=cover the - percentage-height chain (html→body→#root) can resolve against the - safe-area viewport rather than the full display on some WebKit versions. - 100dvh as a progressive enhancement bypasses this; browsers without - dvh support ignore the declaration and keep height:100%. */ - height: 100dvh; font-family: var(--font-secondary); font-size: 16px; font-weight: 400; @@ -83,12 +69,6 @@ body { height: var(--sable-visible-height, 100%); display: flex; flex-direction: column; - padding-top: env(safe-area-inset-top, 0px); - /* No padding-bottom here — content fills edge-to-edge and the home - indicator floats on top (standard iOS native-app pattern). Safe-area - bottom padding is applied only to the room input bar (via the - --sable-safe-bottom variable in RoomView) so nothing interactive - is hidden behind the indicator. */ } *, From 03ba3c1804dface57f9989d4bf294e905cb50db8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 07:32:31 -0400 Subject: [PATCH 29/34] feat(mobile): swipe-down to dismiss long-press menu, add emoji reaction picker --- src/app/features/room/message/Message.tsx | 7 ++++++ .../room/message/MobileMessageMenu.tsx | 25 +++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index a2ffbdda2..b9006a178 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -897,6 +897,12 @@ function MessageInternal( }, 100); }; + const handleMobileOpenEmojiBoard = useCallback(() => { + setTimeout(() => { + setEmojiBoardAnchor({ x: 0, y: 0, width: window.innerWidth, height: 60 }); + }, 100); + }, []); + const handleSwipeReply = () => { const currentId = mEvent.getId(); const targetId = activeReplyId === currentId ? null : currentId; @@ -1334,6 +1340,7 @@ function MessageInternal( onReplyClick={onReplyClick} onEditId={onEditId} onReactionToggle={onReactionToggle} + onOpenEmojiBoard={canSendReaction ? handleMobileOpenEmojiBoard : undefined} onClose={() => setMobileOptionsOpen(false)} /> )} diff --git a/src/app/features/room/message/MobileMessageMenu.tsx b/src/app/features/room/message/MobileMessageMenu.tsx index 40930d447..d27853a7d 100644 --- a/src/app/features/room/message/MobileMessageMenu.tsx +++ b/src/app/features/room/message/MobileMessageMenu.tsx @@ -1,7 +1,7 @@ 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 } from 'react'; import { useSetAtom } from 'jotai'; import type { MatrixEvent, Room } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -149,6 +149,25 @@ export function MobileMessageMenu({ getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations(); const isEdited = edits !== undefined; + const touchStartYRef = useRef(null); + + const handleSheetTouchStart = useCallback((e: ReactTouchEvent) => { + if (e.currentTarget.scrollTop === 0) { + touchStartYRef.current = e.touches[0]?.clientY ?? null; + } + }, []); + + const handleSheetTouchEnd = useCallback( + (e: ReactTouchEvent) => { + if (touchStartYRef.current === null) return; + const startY = touchStartYRef.current; + touchStartYRef.current = null; + const endY = e.changedTouches[0]?.clientY ?? startY; + if (endY - startY > 60) onClose(); + }, + [onClose] + ); + // Close on Escape useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -209,6 +228,8 @@ export function MobileMessageMenu({ aria-modal="true" onClick={stopPropHandler} onKeyDown={(e) => e.stopPropagation()} + onTouchStart={handleSheetTouchStart} + onTouchEnd={handleSheetTouchEnd} >
From fa083302a53bd7e0f8865148d86dddd91bbe154a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 07:59:46 -0400 Subject: [PATCH 30/34] feat(mobile): visual swipe feedback and embedded emoji picker in long-press menu --- src/app/features/room/message/Message.tsx | 9 +- .../room/message/MobileMessageMenu.css.ts | 38 +++ .../room/message/MobileMessageMenu.tsx | 315 +++++++++++------- 3 files changed, 234 insertions(+), 128 deletions(-) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index b9006a178..62a3c7e0f 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -897,12 +897,6 @@ function MessageInternal( }, 100); }; - const handleMobileOpenEmojiBoard = useCallback(() => { - setTimeout(() => { - setEmojiBoardAnchor({ x: 0, y: 0, width: window.innerWidth, height: 60 }); - }, 100); - }, []); - const handleSwipeReply = () => { const currentId = mEvent.getId(); const targetId = activeReplyId === currentId ? null : currentId; @@ -1340,7 +1334,7 @@ function MessageInternal( onReplyClick={onReplyClick} onEditId={onEditId} onReactionToggle={onReactionToggle} - onOpenEmojiBoard={canSendReaction ? handleMobileOpenEmojiBoard : undefined} + imagePackRooms={imagePackRooms ?? []} onClose={() => setMobileOptionsOpen(false)} /> )} @@ -1581,6 +1575,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 d27853a7d..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, TouchEvent as ReactTouchEvent } from 'react'; -import { useEffect, useCallback, useRef } 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,21 +150,61 @@ export function MobileMessageMenu({ getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations(); const isEdited = edits !== undefined; - const touchStartYRef = useRef(null); + 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) { - touchStartYRef.current = e.touches[0]?.clientY ?? null; + 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 (touchStartYRef.current === null) return; - const startY = touchStartYRef.current; - touchStartYRef.current = null; - const endY = e.changedTouches[0]?.clientY ?? startY; - if (endY - startY > 60) onClose(); + 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] ); @@ -215,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="Copy Text" + icon={} + label="Reply" + onClick={handleReplyClick} + /> + {!isThreadedMessage && ( + } + label="Reply in Thread" + onClick={handleThreadReplyClick} + /> + )} + {canEditEvent(mx, mEvent) && onEditId && ( + } + label="Edit Message" + onClick={handleEditClick} + /> + )} + } + 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 fa7169b6d25cd54345129008f198082a2631cb95 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 13:26:51 -0400 Subject: [PATCH 31/34] fix(thread): prevent keyboard flicker on mobile during reply\n\nhandleReplyClick captured the entire replyDraft object in its\nuseCallback deps, so it was recreated on every keystroke. This\ncaused all Message components in the thread to re-render (memo\nsees a new onReplyClick reference) while the iOS keyboard was\ntrying to open, producing the observed flashing/unclickable input.\n\nFix: read replyDraft via a ref (replyDraftRef) inside the callback\nso the only dep that can change is activeReplyId (replyDraft.eventId),\nwhich only changes when the user explicitly taps a reply button." --- src/app/features/room/ThreadDrawer.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index e09b76ec0..9e0aa3a67 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -242,6 +242,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(); @@ -593,7 +597,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() ?? '', @@ -606,7 +610,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra } } }, - [mx, room, setReplyDraft, activeReplyId, threadRootId, replyDraft] + [mx, room, setReplyDraft, activeReplyId, threadRootId] ); const handleReactionToggle = useCallback( From 36065f367ed11796b10dba1f6a190cb0461c82be Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 17:42:23 -0400 Subject: [PATCH 32/34] fix(editor): stop placeholder flicker loop on reply focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onFocus space→delete trick (autocapitalize workaround) was running every time Slate regained focus — including when triggerAutoCapitalize itself returned focus to Slate via ReactEditor.focus(). Sequence causing the loop: 1. User taps reply → Slate focuses → onFocus fires 2. Space inserted, then deleted one frame later 3. handleChange sees non-empty→empty while focused → calls triggerAutoCapitalize 4. triggerAutoCapitalize focuses hiddenInput then returns focus to Slate 5. Slate onFocus fires again → goto 2 justRestoredFocusRef was already set synchronously before the ReactEditor.focus() call in triggerAutoCapitalize but was never checked in onFocus. Add the early return to break the loop. --- src/app/components/editor/Editor.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) 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, ' '); From 19878793e4733192a2706cd59c7e7714ca1c9842 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 12:53:45 -0400 Subject: [PATCH 33/34] fix(settings): enable Enter-to-send toggle for tablets with external keyboards - Add isPhone() to user-agent.ts (device.type === 'mobile' only; tablets use type === 'tablet' in ua-parser-js) - Use isPhone() instead of mobileOrTablet() for the Editor isMobile prop so the 'ENTER for Newline' setting is enabled on iPads with keyboards fix(sw): show OS notifications when Matrix sync is degraded - Add syncIsHealthy flag (default true) to sw.ts - Add setSyncState message handler so the app can report health - Include syncIsHealthy in the hasVisibleClient suppression check so pushes are not silently dropped while the in-app path is broken - Add SyncStateWithServiceWorker component to ClientNonUIFeatures that posts setSyncState whenever SyncState changes --- src/app/features/settings/general/General.tsx | 4 +-- src/app/pages/client/ClientNonUIFeatures.tsx | 32 +++++++++++++++++ src/app/utils/user-agent.ts | 7 ++++ src/sw.ts | 34 ++++++++++++++++--- 4 files changed, 71 insertions(+), 6 deletions(-) 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 b089cf8f8..423c19c83 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 e1a2e0a61..61b5f217e 100644 --- a/src/app/utils/user-agent.ts +++ b/src/app/utils/user-agent.ts @@ -9,6 +9,11 @@ const isMobileOrTablet = (() => { return false; })(); +// True only for phone-form-factor devices (not tablets). +// ua-parser-js sets device.type === 'mobile' for phones and 'tablet' for tablets, +// so this correctly returns false for iPads with external keyboards. +const isPhoneDevice = result.device.type === 'mobile'; + const normalizeMacName = (os?: string) => { if (!os) return os; if (os === 'Mac OS') return 'macOS'; @@ -20,6 +25,8 @@ const isMac = result.os.name === 'Mac OS'; export const ua = () => result; export const isMacOS = () => isMac; export const mobileOrTablet = () => isMobileOrTablet; +/** 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 222156e72..5af45aa23 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 97766533935bae548415dee83647ddb1802ba726 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 20:27:46 -0400 Subject: [PATCH 34/34] fix(layout): restore minHeight on RoomViewFollowing to prevent input bar jump --- src/app/features/room/RoomViewFollowing.css.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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,