Skip to content

fix(mobile): sync resume, iOS layout, and connecting banner fixes#874

Open
Just-Insane wants to merge 30 commits into
SableClient:devfrom
Just-Insane:feat/mobile
Open

fix(mobile): sync resume, iOS layout, and connecting banner fixes#874
Just-Insane wants to merge 30 commits into
SableClient:devfrom
Just-Insane:feat/mobile

Conversation

@Just-Insane
Copy link
Copy Markdown
Contributor

@Just-Insane Just-Insane commented May 19, 2026

Description

Bundle of mobile-specific fixes: correct network-resume retries with iOS-specific delayed fallback to survive iOS background network latency, show a Connecting banner when the fast-path clears the splash screen before sync is complete, show unread indicators after timeline reset on sync resume, and fix iPad members-drawer layout so it uses the sidebar layout instead of the full-screen overlay.

Fixes #

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

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
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

AI disclosure:

  • Fully AI generated (explain what all the generated code does in moderate detail).
  • Partially AI assisted (clarify which code was AI assisted and briefly explain what it does).

The retry logic adds an iOS-specific second attempt with a longer delay after the initial network-resume callback, since iOS can report network-available before the radio is actually usable. The Connecting banner listens to the sync state and renders when sync is PREPARED or RECONNECTING after the splash clears. The unread indicator fix schedules a re-render after the timeline reset event fires on sync resume.

Tracks Issue #9 with root cause analysis and proposed fix using visualViewport API.
Implementation will follow after proper testing on iOS devices.
When the thread drawer is open alongside the main room view, two
RoomInput components each mount their own useKeyboardHeight instance.
On keyboard open the thread instance had savedHeight=0 (freshly mounted),
so its immediate-estimate branch fell back to viewport.height — the
wrong mid-animation value — and overwrote the correct estimate already
written by the main room instance.  This produced a third layout change
(wrong height → correct height) visible as jank on every keyboard open.

Fix: promote savedHeight, cssVarsSet, and the mount reference counter to
module-level variables so all instances share them.

- sharedSavedHeight: all instances read and write the same value, so the
  estimate is always correct even for newly-mounted instances.
- cssVarsApplied: the 'only set once while keyboard open' guard now works
  across instances, preventing double setCSSVars calls.
- mountCount: reference-counted so only the last instance to unmount
  clears the CSS variables — prevents the thread drawer unmounting while
  the main room keyboard is still open from wiping --sable-visible-height.
- 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 root causes for notifications stopping after a while:

1. sw.ts hasVisibleClient used OR logic (appIsVisible || matchAll visible):
   On iOS the SW is killed between pushes so appIsVisible resets to false
   on restart.  With OR logic, a stale matchAll() result with
   visibilityState='visible' still set hasVisibleClient=true, silently
   suppressing every notification after the first.

   Fix: switch to AND logic so BOTH appIsVisible AND a visible client are
   required to suppress.  A cold-start SW (appIsVisible=false) never
   suppresses, regardless of stale matchAll() data.

2. useAppVisibility.ts was passing isMobile as keepEnabledWhenVisible,
   meaning on desktop the pusher was deleted from the homeserver whenever
   the tab was visible.  If the async re-enable in enablePushNotifications
   didn't complete before the page was torn down, the homeserver was left
   with no pusher — so no more push notifications until a manual
   background/foreground cycle.

   Fix: always pass true for keepEnabledWhenVisible so the pusher stays
   registered permanently.  The SW's hasVisibleClient check handles
   OS-notification suppression in the foreground; the homeserver never
   needs to be without a pusher.
- SlidingSyncManager.onConnectionChange now calls slidingSync.resend()
  when the device comes back online, so the sync retries immediately
  instead of staying idle.
- Add SlidingSyncManager.retryNow() public method that calls resend().
- useAppVisibility: on visibilitychange → visible, call
  mx.retryImmediately() (classic sync) and getSlidingSyncManager(mx)?.retryNow()
  (sliding sync) so the PWA reconnects when opened from the home screen.

The SDK's SlidingSyncSdk.retryImmediately() is a no-op stub, so the
visibility path was previously a dead end for sliding sync users.
SyncStatus initialised stateData.current as null, so when the splash was
dismissed via the fast-path (cached rooms before first sync), the component
would miss the null→Syncing transition if sync had already started by the
time it mounted. Initialise current from mx.getSyncState() so the banner
correctly shows "Connecting..." whenever the component first renders while a
sync is already in progress.
When a sync gap triggers TimelineReset (e.g. mobile PWA returning from
background or opening from notification), getInitialTimeline() loads new
events directly into timeline state. useLiveEventArrive never fires for
events that arrived via reset, so if the user was scrolled up the unread
jump bar was never shown. Call setUnreadInfo explicitly in that case.
@Just-Insane Just-Insane marked this pull request as ready for review May 19, 2026 23:39
@Just-Insane Just-Insane requested review from 7w1 and hazre as code owners May 19, 2026 23:39
Copilot AI review requested due to automatic review settings May 19, 2026 23:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR bundles several mobile-focused fixes across sync resume behavior, service worker push-notification suppression, iOS safe-area/keyboard layout issues, and tablet-vs-phone layout routing so iPads use the desktop/two-panel layout instead of mobile overlays.

Changes:

  • Improve iOS/mobile reliability for sync resume and SW-mediated notifications (session “warming”, sync retry triggers, and push suppression rules).
  • Adjust mobile UI/UX behaviors (connecting banner plumbing, unread indicator after timeline reset, safer swipe thresholds, long-press message menu).
  • Fix iOS/iPad layout and keyboard/safe-area handling (root height var, safe-area padding, scroll/keyboard stabilization hooks).

Reviewed changes

Copilot reviewed 51 out of 51 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/sw.ts Tightens OS push-notification suppression logic using visibility + sync health.
src/sw-session.ts Posts session info to SW more robustly (controller/registration targets).
src/index.tsx Restores SW register options usage and ensures session is sent post-registration.
src/index.css Adds overscroll behavior + iOS keyboard-driven root height via CSS var.
src/client/slidingSync.ts Triggers sliding sync resend on network-online and exposes retryNow().
src/client/initMatrix.ts Clears SW registrations on clearLoginData for “fresh start” on next load.
src/app/utils/user-agent.ts Adds iPad-as-desktop detection and splits “phone layout” vs “touch device”.
src/app/utils/dom.ts Makes file picker more reliable on iOS by DOM-attaching input + better cancel handling.
src/app/styles/overrides/General.css.ts Ensures #root background covers safe-area padding regions.
src/app/pages/Router.tsx Uses phone-only layout detection for mobile routing decisions.
src/app/pages/MobileFriendly.tsx Updates “mobile friendly” nav gating to phone-only layout.
src/app/pages/client/SyncStatus.tsx Initializes sync state from mx.getSyncState() immediately.
src/app/pages/client/space/Space.tsx Uses phone-only layout checks for sidebar/resizer decisions.
src/app/pages/client/sidebar/SpaceTabs.tsx Fixes space unread aggregation by computing unread across child rooms.
src/app/pages/client/inbox/Inbox.tsx Uses phone-only layout checks for sidebar/resizer decisions.
src/app/pages/client/home/Home.tsx Uses phone-only layout checks for sidebar/resizer decisions.
src/app/pages/client/explore/Explore.tsx Uses phone-only layout checks for sidebar/resizer decisions.
src/app/pages/client/direct/Direct.tsx Uses phone-only layout checks for sidebar/resizer decisions.
src/app/pages/client/ClientRoot.tsx Adds SW session heartbeat/foreground resync and fast-path splash clearing behavior.
src/app/pages/client/ClientNonUIFeatures.tsx Centralizes SW posting for visibility/settings and reports sync health to SW.
src/app/hooks/useNotificationJumper.ts Improves navigation history behavior when jumping from notifications on mobile.
src/app/hooks/useAppVisibility.ts Keeps pushers enabled + triggers retry on foreground with iOS fallback timers.
src/app/hooks/timeline/useTimelineSync.ts Updates unread bar behavior on timeline reset during resume/sync gaps.
src/app/hooks/ios-keyboard-fix/useScrollLock.ts Adds iOS scroll lock safety net while keyboard interactions occur.
src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts Adds VisualViewport-based keyboard measurement and CSS var management.
src/app/hooks/ios-keyboard-fix/index.ts Exposes vendored iOS keyboard fix utilities/hooks.
src/app/hooks/ios-keyboard-fix/device.ts Adds device helpers (standalone PWA, tablet detection, virtual keyboard need).
src/app/features/settings/notifications/PushNotifications.tsx Updates app_display_name to Sable + improves SW message posting coverage.
src/app/features/settings/general/General.tsx Uses phone-only detection for editor mobile behaviors.
src/app/features/room/ThreadDrawer.tsx Avoids re-render churn by reading reply draft via ref in callback.
src/app/features/room/RoomView.tsx Applies safe-area-aware padding/background for bottom area.
src/app/features/room/RoomTimeline.tsx Adds scroll settle/jump guards to avoid “jump to latest” flicker on iOS changes.
src/app/features/room/RoomInput.tsx Adds iOS keyboard hooks + keeps EmojiBoard mounted via portal for performance.
src/app/features/room/message/MobileMessageMenu.tsx Introduces a bottom-sheet style message action menu for mobile.
src/app/features/room/message/MobileMessageMenu.css.ts Styles for the new mobile message action sheet.
src/app/features/room/message/Message.tsx Adds long-press menu + copy-text action and integrates mobile sheet.
src/app/features/room/MembersDrawer.tsx Switches iPad to sidebar layout behavior (phone-only overlay).
src/app/components/SwipeableOverlayWrapper.tsx Adjusts swipe thresholds/velocity tuning.
src/app/components/SwipeableMessageWrapper.tsx Adjusts reply swipe threshold.
src/app/components/SwipeableChatWrapper.tsx Adjusts swipe thresholds/velocity tuning.
src/app/components/splash-screen/SplashScreen.css.ts Fixes splash/footer safe-area sizing.
src/app/components/page/style.css.ts Makes nav bottom padding safe-area aware.
src/app/components/page/Page.tsx Uses phone-only layout to decide when to render nav separator line.
src/app/components/notification-banner/NotificationBanner.tsx Removes visualViewport-driven positioning logic (now handled elsewhere).
src/app/components/message/layout/layout.css.ts Ensures message text body spans full width to better support RTL alignment.
src/app/components/emoji-board/EmojiBoard.tsx Adds active prop to control FocusTrap when picker is hidden but mounted.
src/app/components/editor/Editor.tsx Improves mobile autocapitalize behavior + adjusts Enter handling for phones only + dir=auto.
src/app/components/editor/Editor.css.ts Adds unicode-bidi plaintext for RTL-friendly editor behavior.
src/app/components/editor/autocomplete/AutocompleteMenu.tsx Tweaks FocusTrap tabbable options for menu behavior.
docs/MOBILE_FIXES.md Adds a tracking document for mobile UX issues/fixes.
.changeset/mobile.md Adds a patch changeset entry describing the mobile fixes bundle.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/app/features/room/message/Message.tsx Outdated
Comment thread src/app/features/room/message/Message.tsx
Comment thread src/app/features/room/RoomTimeline.tsx Outdated
Comment thread src/app/features/room/RoomTimeline.tsx
Comment thread src/sw-session.ts
Comment thread src/client/initMatrix.ts
Comment thread src/app/features/room/RoomInput.tsx
- View Reactions (when relations are available)
- Pin / Unpin Message (when canPinEvent)
- Set / Edit Nickname for other users (inline edit within the sheet)
- Kick from Room for moderators with sufficient power level

Also passes canPinEvent and relations from MessageInternal to
MobileMessageMenu so the desktop and mobile menus stay in parity.

Fix missing mobileOrTablet import in useAppVisibility.
- Add bookmark add/remove action to mobile long-press menu (respects
  enableMessageBookmarks setting, same as desktop menu)
- Fix: when the nickname input opens, use visualViewport resize events
  to lift the bottom sheet above the virtual keyboard so the input is
  not covered on iOS/Android
- Gate pronoun pills on showPronouns setting (regression fix)
- Add unmount cleanup for useMobileLongPress setTimeout
- Clear jumpScrollBlockTimerRef on RoomTimeline unmount
- Void loadEventTimeline promise to suppress no-floating-promises
- Only register controllerchange listener when SW controller is absent
- Wrap SW unregister in try/catch so clearLoginData always reloads
- Re-read emoji/sticker picker DOMRect on visualViewport changes
…idle

Two complementary strategies:

Foreground keep-alive (this commit):
- Every 20 s, SyncNotificationSettingsWithServiceWorker sends a cheap
  { type: 'ping' } postMessage to the SW regardless of page visibility.
  The SW handles it with event.waitUntil(Promise.resolve()), extending
  its event-processing budget and preventing iOS from killing it while
  the tab is open but untouched.

Background recovery (already in ClientRoot):
- On visibilitychange → visible, and on a 10-min periodic timer,
  ClientRoot re-pushes the session credentials to the SW.  This ensures
  the SW has fresh auth after iOS kills and restarts it in the background,
  so media fetches and push relaying work when the user returns.
…ile resume

On mobile, a sync gap causes the SDK to emit TimelineReset and deliver an
entire batch of events at once in server receipt order rather than
chronological order. Sort timeline items by origin_server_ts before the
reduce pass in useProcessedTimeline so the rendered order is always
chronological regardless of how the SDK received the events.
Receipt order is kept as a tiebreaker to preserve causally-related event
stability when two events share the same timestamp.

Also fix pre-existing useTimelineSync test failures caused by missing mock
methods (getUnreadNotificationCount, getLiveTimeline, getAccountData).
After triggering a network re-sync via retryImmediately/retryNow, emit
RoomEvent.TimelineRefresh on every room so that any mounted RoomTimeline
rebuilds its React state from the current SDK data immediately. This
covers the case where the sliding sync response has no gap (limited: false)
and therefore never fires a server-side TimelineReset, leaving stale React
state on screen even though the SDK is up to date.
…move dead SW update prompt

- Wrap all localStorage.setItem calls in sessions.ts, settings.ts, and
  atomWithLocalStorage.ts in try/catch to handle QuotaExceededError —
  imagePackCache can fill the 5 MB iOS localStorage limit and cause
  unguarded writes to throw, silently dropping sessions/settings.
- Remove showUpdateAvailablePrompt from index.tsx: the function posted
  SKIP_WAITING_AND_CLAIM to registration.waiting but sw.ts has no
  handler for that message type; skipWaiting() is unconditional at
  install so registration.waiting is always null. Replace the confirm
  dialog with a direct window.location.reload() when a new SW installs.
Show a circular pill indicator (arrow + spinner) during the pull
gesture and while the refresh network request is in flight.

- During pull: indicator slides down from above the safe-area inset,
  an arrow inside rotates from 0° → 180° as the threshold is reached
  (pointing down at start, pointing up at "release to refresh").
- On release: spinner replaces the arrow for 800 ms while
  retryImmediately / retryNow run, then the indicator slides back out.
- On insufficient pull: indicator snaps back with a short ease.
- Styles injected once via a <style> tag (spin keyframe only); all
  other styling uses inline styles with --sable-surface-* CSS vars so
  the indicator automatically matches the active theme.
- Indicator is appended to document.body to avoid overflow-clip from
  the scroll container's parent, and removed on cleanup.
MobileMessageMenu.tsx imported bookmarkDomain and useBookmarks which
only exist on the feat/message-bookmarks branch, causing typecheck,
build, tests, and knip failures on CI.

Also: fix sort() → toSorted() lint warning in useProcessedTimeline,
move makeMx helper to outer scope in useTimelineSync tests, and fix
formatting.
Replace window.location.reload() in the updatefound handler with a
custom 'sable:sw-update' event. ClientRoot listens for that event and
shows a persistent 'Update available — tap to reload' banner (matching
the SyncStatus strip style). The user triggers the reload consciously,
avoiding the disorienting silent full-page reload on mobile.
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.

2 participants