fix(mobile): keyboard flicker, placeholder loop, tablet Enter-to-send, read-receipt bar jump#813
Draft
Just-Insane wants to merge 34 commits into
Draft
fix(mobile): keyboard flicker, placeholder loop, tablet Enter-to-send, read-receipt bar jump#813Just-Insane wants to merge 34 commits into
Just-Insane wants to merge 34 commits into
Conversation
- 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 <html> 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
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.
- 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.
… viewports 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).
- 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.
…ection - 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.
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.
- 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.
…e card groups 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.
…dicator
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.
…n 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.
…n 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.
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)
- 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
- 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
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
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.
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.
… and jump-to-present
…e, notification back nav
…setting CSS height vars" This reverts commit 7faefaa.
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.
…eReplyClick 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."
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.
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Several mobile/tablet UX improvements:
Prevent keyboard flicker on mobile during reply —
handleReplyClickcaptured the entirereplyDraftobject in itsuseCallbackdeps, causing all threadMessagecomponents to re-render (memo sees newonReplyClickreference) while the iOS keyboard was opening. Fixed by readingreplyDraftvia a ref so onlyactiveReplyIdis a dep.Stop placeholder flicker loop on reply focus —
triggerAutoCapitalizewas called insideonFocus, which fired on every focus restoration including the one triggered bytriggerAutoCapitalizeitself, causing an infinite loop viahandleChange(non-empty → empty → retrigger). AddedjustRestoredFocusRefguard.Enter-to-send toggle for tablets with external keyboards — exposes the existing Enter-to-send setting for tablet-sized devices (detected via
maxTouchPoints > 1) in addition to mobile. Also propagates the setting to the service worker so the SW keyboard shortcut hint matches.Restore
minHeightonRoomViewFollowing— the read-receipt bar had its upstreamminHeight: 28pxdropped, causing the input bar to jump when readers appeared/disappeared. Restored to match upstream.Type of change
Checklist:
AI disclosure:
The AI identified the
useCallbackdep capture bug inThreadDrawerand thetriggerAutoCapitalizefocus loop. The fixes are standard React patterns (ref-based stable callback, guard ref) that I understand fully.