Skip to content

feat: sync settings across devices via Matrix account data#1

Merged
Just-Insane merged 5 commits intodevfrom
feat/settings-sync
Mar 21, 2026
Merged

feat: sync settings across devices via Matrix account data#1
Just-Insane merged 5 commits intodevfrom
feat/settings-sync

Conversation

@Just-Insane
Copy link
Copy Markdown
Owner

@Just-Insane Just-Insane commented Mar 21, 2026

What

Adds opt-in settings synchronisation across devices using Matrix account data (moe.sable.app.settings), plus a JSON export/import fallback for users who prefer manual control.

How it works

  • Sync toggle (off by default) in General settings enables live sync
  • On toggle-on, settings are loaded from account data immediately
  • Local changes are debounced 2 s then uploaded to account data
  • Echo-token loop prevention: each upload embeds a random token; when the homeserver echoes the event back the hook recognises it and skips re-applying (avoiding a feedback loop)
  • Events arriving from other devices are applied and merge over local state
  • Non-syncable keys are always taken from local state and never uploaded: usePushNotifications, useInAppNotifications, useSystemNotifications, pageZoom, isPeopleDrawer, isWidgetDrawer, memberSortFilterIndex, developerTools, settingsSyncEnabled

JSON export / import

Available regardless of whether sync is enabled. Export triggers a browser download; import opens a file picker and merges the selected file into current settings (same schema validation as the sync path).

Files changed

File Purpose
src/types/matrix/accountData.ts SableSettings = 'moe.sable.app.settings' enum value
src/types/matrix-sdk-events.d.ts Register 'moe.sable.app.settings' in AccountDataEvents (removes as never casts)
src/app/state/settings.ts settingsSyncEnabled: boolean (default false)
src/app/utils/settingsSync.ts serialize / deserialize / export / import helpers
src/app/hooks/useSettingsSync.ts side-effect hook + status atoms
src/app/pages/client/ClientNonUIFeatures.tsx wire SettingsSyncFeature for session lifetime
src/app/features/settings/general/General.tsx SettingsSyncSection UI
src/app/utils/settingsSync.test.ts 26 tests for pure utility functions
src/app/hooks/useSettingsSync.test.tsx 13 tests for hook behaviour (mount load, debounce, echo-token, remote updates)

Ported from SableClient#409

- Add SableSettings account data event type (moe.sable.app.settings)
- Add settingsSyncEnabled setting (default off, device-local)
- serializeForSync/deserializeFromSync utilities with versioned schema
- Echo-token loop prevention: own uploads don't re-apply on echo
- Debounced (2 s) upload on settings change
- JSON export (browser download) and import (file picker)
- SettingsSyncSection in General settings with sync toggle, status, and export/import buttons
- 39 new tests covering utilities and hook behaviour
@github-actions
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

Status Preview URL Commit Alias Updated (UTC)
✅ Deployment successful! https://pr-1-sable.justin-tech.workers.dev 6b6357b pr-1 Sat, 21 Mar 2026 23:35:45 GMT

@Just-Insane Just-Insane merged commit 4c4c1a8 into dev Mar 21, 2026
9 checks passed
Just-Insane added a commit that referenced this pull request Mar 22, 2026
With classic sync, RoomViewHeader creates Thread objects via
room.createThread(id, rootEvent, [], false) — passing no initialEvents.
This means thread.events starts as just [rootEvent] (or empty).

Two bugs resulted:

1. The gate `if (fromThread.length > 0)` blocked the live-timeline fallback.
   Since thread.events = [rootEvent], length was 1 (truthy), but after
   filtering the root out the array was empty — yielding zero replies even
   though the events were present in the main room timeline.
   Fix: compute the filtered array first, then gate on its length so the
   fallback is reached when the thread object has no actual replies yet.

2. Even after #1, subsequent renders and read-receipt logic used thread.events
   (empty) rather than the live timeline.
   Fix: add a mount-time useEffect that backfills matching events from the
   unfiltered live timeline into the Thread object via thread.addEvents() so
   the authoritative source is populated for future interactions.
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