Skip to content

fix(mobile): keyboard flicker, placeholder loop, tablet Enter-to-send, read-receipt bar jump#813

Draft
Just-Insane wants to merge 34 commits into
SableClient:devfrom
Just-Insane:fix/mobile-ux
Draft

fix(mobile): keyboard flicker, placeholder loop, tablet Enter-to-send, read-receipt bar jump#813
Just-Insane wants to merge 34 commits into
SableClient:devfrom
Just-Insane:fix/mobile-ux

Conversation

@Just-Insane
Copy link
Copy Markdown
Contributor

Description

Several mobile/tablet UX improvements:

  • Prevent keyboard flicker on mobile during replyhandleReplyClick captured the entire replyDraft object in its useCallback deps, causing all thread Message components to re-render (memo sees new onReplyClick reference) while the iOS keyboard was opening. Fixed by reading replyDraft via a ref so only activeReplyId is a dep.

  • Stop placeholder flicker loop on reply focustriggerAutoCapitalize was called inside onFocus, which fired on every focus restoration including the one triggered by triggerAutoCapitalize itself, causing an infinite loop via handleChange (non-empty → empty → retrigger). Added justRestoredFocusRef guard.

  • 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 minHeight on RoomViewFollowing — the read-receipt bar had its upstream minHeight: 28px dropped, causing the input bar to jump when readers appeared/disappeared. Restored to match upstream.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings

AI disclosure:

  • Partially AI assisted (clarify which code was AI assisted and briefly explain what it does).

The AI identified the useCallback dep capture bug in ThreadDrawer and the triggerAutoCapitalize focus loop. The fixes are standard React patterns (ref-based stable callback, guard ref) that I understand fully.

- 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.
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant