fix(mobile): sync resume, iOS layout, and connecting banner fixes#874
Open
Just-Insane wants to merge 30 commits into
Open
fix(mobile): sync resume, iOS layout, and connecting banner fixes#874Just-Insane wants to merge 30 commits into
Just-Insane wants to merge 30 commits into
Conversation
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.
…d to survive iOS network resume latency
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.
Contributor
There was a problem hiding this comment.
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.
- 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
11 tasks
…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.
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
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
Checklist:
AI disclosure:
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
PREPAREDorRECONNECTINGafter the splash clears. The unread indicator fix schedules a re-render after the timeline reset event fires on sync resume.