Skip to content

feat(desktop): sync themes per community - #3653

Open
tellaho wants to merge 9 commits into
mainfrom
tho/community-theme-config
Open

feat(desktop): sync themes per community#3653
tellaho wants to merge 9 commits into
mainfrom
tho/community-theme-config

Conversation

@tellaho

@tellaho tellaho commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Category: new-feature
User Impact: Users can keep a distinct Appearance scheme for each community and restore it on another desktop signed in with the same identity.

Problem: A single global theme makes it harder to distinguish among communities, and local-only preferences do not follow a user to another device. Solution: Save each community's stable theme, accent, and system-following selection as private encrypted relay state, backed by a responsive local cache and guarded against switch races, invalid future records, and relay failures.

File changes

desktop/src/app/App.tsx
Mounts the community-scoped theme controller inside the active community lifecycle.

desktop/src/features/settings/lib/appearanceScopeCopy.test.mjs
Covers active-community and fallback labels used to explain Appearance scope.

desktop/src/features/settings/lib/appearanceScopeCopy.ts
Builds a safe, trimmed label for the currently active community.

desktop/src/features/settings/ui/SettingsPanels.tsx
Clarifies which Appearance controls are per-community and which apply globally when multiple communities exist.

desktop/src/shared/constants/kinds.ts
Defines the NIP-78 application-data event kind used for theme preferences.

desktop/src/shared/theme/CommunityThemeController.tsx
Coordinates cached appearance, encrypted relay retrieval, live updates, reconnect behavior, and safe community switching.

desktop/src/shared/theme/ThemeProvider.tsx
Exposes a single appearance application path so synchronized preferences use the existing renderer and persistence behavior.

desktop/src/shared/theme/communityThemePreference.test.mjs
Covers contract validation, user/relay isolation, malformed records, cache failures, and switch-race decisions.

desktop/src/shared/theme/communityThemePreference.ts
Defines the versioned stable preference contract, safe defaults, local cache keys, and persistence guards.

desktop/src/shared/theme/communityThemeSync.test.mjs
Covers relay absence, unreadable records, unavailability, seeding safety, and teardown of pending writes.

desktop/src/shared/theme/communityThemeSync.ts
Encrypts theme preferences to the user, publishes and retrieves NIP-78 state, and handles ordering and lifecycle safety.

Reproduction steps

  1. Join at least two communities and open Settings → Appearance.
  2. Choose a different theme, accent, or system-following mode in each community.
  3. Switch between the communities and verify each one restores its own scheme without overwriting the other.
  4. Sign in on another desktop with the same Nostr identity, join the same community, and verify its saved scheme is restored from that community's relay.
  5. Disconnect the relay, change Appearance, and verify the UI remains responsive and the local fallback is retained.

Screenshots / demos

image Screen Recording 2026-07-29 at 4 39 52 PM

@tellaho
tellaho marked this pull request as ready for review July 30, 2026 00:52
@tellaho
tellaho requested a review from a team as a code owner July 30, 2026 00:52

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewing on Wes's behalf: requesting changes for two independently confirmed state-machine failures at 2c9fc6dd29208cbd9bca5721ef09a28974b646b5.

  1. A first visit to an uncached community can seed it with the previous community's appearance. On A→B, the keyed controller remount captures A in initialPreferenceRef (lines 33–38). When the migration marker exists, the layout effect correctly applies DEFAULT_COMMUNITY_THEME (48–58), but the confirmed-absent fetch path later finds no B cache and falls back to that immutable render-time A snapshot (90–101), then caches and publishes it for B. Please make the value applied for B—not the outgoing render snapshot—the only seed candidate, and add controller-level regression coverage for A→uncached B with an existing migration marker and absent remote state.

  2. Remote hydration can overwrite a newer local edit and delete its pending publish. A user edit writes the scoped cache and queues manager.publish (150–153), but any accepted valid fetch/live result calls cancelPendingPublish() before applying remote (68–87). That method clears both the debounce and pending (communityThemeSync.ts 98–104). There is no dirty/local-revision comparison, so an initial fetch still in flight or a qualifying reconnect can replace newer local intent with older relay state. Pending state is also memory-only and is discarded on destroy/restart, despite the PR's offline-retention claim. Please define explicit local-vs-remote revision/dirty semantics, preserve durable unsynced intent through reconnect/restart, acknowledge only the exact revision successfully published, and cover these lifecycle races at the controller/manager boundary.

The general encrypted NIP-78 + relay-scoped cache approach is reasonable, but the synchronization source of truth needs to be explicit rather than inferred from ThemeProvider render effects. These failures can silently mis-scope or lose user preferences, so they are blocking.

npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w and others added 4 commits July 30, 2026 13:29
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…ings

Theme, mode, and accent are saved per community (CommunityThemeController),
but the Appearance panel gave no hint of that scoping. Users switching
communities would see their theme "change on its own" with no explanation.

- Add a "Theme (per community)" SectionHeader above the mode selector,
  theme grid, and accent picker in SettingsPanels.tsx, with an inline
  outline Badge naming the active community (truncated at max-w-56,
  normal-case override of the uppercase badge base style)
- Qualify the global Thread layout row with a muted "(all communities)"
  suffix so its scope contrasts with the per-community controls above
- Gate both scoping labels on communities.length > 1 — with a single
  community there is nothing to disambiguate, and the panel renders
  exactly as it did before the per-community sync feature
- Extract appearanceCommunityLabel into
  features/settings/lib/appearanceScopeCopy.ts (trims whitespace, falls
  back to "this community" when no community is active or the name is
  blank) with node:test coverage in appearanceScopeCopy.test.mjs
- No e2e changes needed: specs only reference the unchanged "Appearance"
  heading and appearance-mode-* / settings-theme test IDs, and the mock
  boot path seeds one community so the gated labels do not render there

Verified: pnpm test (3,733 passed), pnpm typecheck, pnpm check all clean;
visual states captured via just desktop-screenshot.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
- Defer community theme persistence while the incoming scoped appearance is still propagating through the global theme provider.
- Distinguish stale, acknowledged, and persistable appearance states with a shared preference helper.
- Add regression coverage for switching between communities with different themes without modifying E2E tests.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho
tellaho force-pushed the tho/community-theme-config branch from 2c9fc6d to fc1fc55 Compare July 30, 2026 20:35
@tellaho

tellaho commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback by making unsynced per-community appearance edits durable across restart/reconnect. Pending intent is scoped by pubkey + normalized relay, wins over cache/remote hydration, retries on reconnect, and is cleared only when the exact revision is acknowledged. Confirmed-absence-only seeding remains intact.

Verification:

  • desktop typecheck
  • full desktop suite: 3,850 tests passed
  • desktop checks passed (two unrelated informational Biome suggestions only)
  • CI is green

Commit: fc1fc5539f1f63b4f87863e2ab7976301f9c8ec1

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewing on Wes's behalf at fc1fc5539f1f63b4f87863e2ab7976301f9c8ec1: the original A→uncached-B seeding bug and memory-only dirty-state bug are materially fixed, but I am keeping CHANGES_REQUESTED for two remaining release-safety failures.

  1. A transient publish failure has no retry while the connection remains up. doPublish() catches and logs after the debounce timer has already been cleared. The durable outbox survives, but it is only requeued by remount/restart or reconnect; an isolated timeout/rejection on an otherwise connected relay can leave the preference unsynchronized indefinitely. Please add bounded retry/backoff (or another authoritative delivery trigger) and test failure → retry → exact-revision acknowledgement without requiring restart.

  2. A stale in-flight remote can roll back a successfully published local revision. While an outbox exists, applyRemote returns without advancing lastRemoteRef. Publication acknowledgement clears the outbox but does not communicate the published event coordinate to the controller. If an older initial fetch or delayed live decrypt resolves after that acknowledgement, applyRemote sees no dirty state and compares against { createdAt: 0, eventId: "" }, so it can cache/apply the old preference. Publication acknowledgement must advance the same ordering state used by hydration, or remote acceptance must compare against the acknowledged local event coordinate.

The new tests validate isolated outbox equality and pure persistence helpers, but still do not exercise the controller/manager lifecycle: no transient publish retry, publish acknowledgement versus delayed fetch/live delivery, reconnect, or restart integration coverage. Those tests are required before this is safe to hold for a future release.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho

tellaho commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the latest requested changes. Theme publishes now retry automatically with bounded exponential backoff while the session remains connected. Successful publication carries its signed event coordinate into the controller before the exact outbox revision is cleared, so delayed older/same-second hydration cannot roll back the acknowledged local preference.

Added coverage for transient failure → retry → exact event acknowledgement and same-second coordinate ordering.

Verification:

  • typecheck passed
  • full desktop suite: 3,852 tests passed
  • desktop checks passed (two unrelated informational Biome suggestions)
  • CI is green

Commit: 39aed1349

@wesbillman

Copy link
Copy Markdown
Collaborator

Reviewing on Wes's behalf: one blocking correctness issue remains at 39aed13494f959f61901fd1ec797621552a12d9a.

[High] The same-second tie-break is reversed relative to relay persistence. isNewerCommunityThemeCoordinate() treats the lexicographically higher event ID as newer at equal createdAt (desktop/src/shared/theme/communityThemeSync.ts:36-44), and the test at desktop/src/shared/theme/communityThemeSync.test.mjs:125-140 codifies that order. For parameterized replaceable events, the relay instead keeps the lowest event ID when timestamps match (crates/buzz-db/src/lib.rs:4848-4865).

Two clients can sign from the same head at head + 1. If this client publishes the losing higher-ID event, the relay can acknowledge it as an accepted duplicate/no-op; the controller then records that losing coordinate and clears its durable outbox (desktop/src/renderer/components/CommunityThemeController.tsx:90-99). When the winning lower-ID relay event arrives, the reversed comparator rejects it (CommunityThemeController.tsx:104-127), leaving UI/cache on state the relay did not retain until a later lifecycle reset.

Please make the equal-timestamp comparison candidate.eventId < current.eventId and reverse the regression expectations. A controller/manager regression covering “higher-ID publish acknowledged, then lower-ID relay winner arrives” would also protect the outbox-clearing and remote-acceptance interaction.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewing on Wes Billman's behalf. Requesting changes for two convergence blockers at 39aed13494f959f61901fd1ec797621552a12d9a:

  1. [P1] Equal-timestamp tie-breaking disagrees with relay persistence. isNewerCommunityThemeCoordinate accepts the lexicographically higher event ID at equal timestamps (desktop/src/shared/theme/communityThemeSync.ts:36-44), while relay parameterized-replaceable persistence retains the lowest ID (crates/buzz-db/src/lib.rs:4848-4858). The client can therefore reject the event the relay actually retained and remain on state that no longer exists on the relay. Please make the client ordering match relay persistence and cover the equal-timestamp case end to end.

  2. [P1] Stale lastPublished can synthetically acknowledge and discard a durable local edit. doPublish skips publishing when the pending preference equals lastPublished and invokes onPublished (communityThemeSync.ts:140-155), but a newer/different remote event does not invalidate lastPublished. With a durable pending edit, applyRemote requeues the local preference (CommunityThemeController.tsx:104-110), after which the synthetic acknowledgement clears the outbox (:90-99) even though the relay still holds the other device's value. Please only suppress a publish when lastPublished is still the acknowledged current remote coordinate, or invalidate it when a newer/different remote arrives, and add a multi-device A→B→A regression test.

npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w and others added 2 commits August 3, 2026 14:22
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…onfig

* origin/main: (76 commits)
  Polish Share Compute settings (#3735)
  fix(reactions): wrap long popover names (#3834)
  fix(desktop): clarify inherited agent parallelism (#4010)
  feat(desktop): make onboarding model defaults skippable (#3968)
  ci: add guarded desktop release cache prewarm (#4575)
  fix(mobile): recover stale relay sessions (#4372)
  chore(release): release Buzz Desktop version 0.5.4 (#4562)
  test(mobile): assert follow boundary semantics (#4559)
  docs(release): align desktop handoff instructions (#3988)
  fix: report agent usage per provider round, not once per turn (#4545)
  fix(desktop): harden Windows installs against Defender block and orphaned Node (#4382)
  feat(desktop): improve channel template discovery (#4549)
  fix(desktop): save key backups to authorized path (#4022)
  Add channel activity hover menu (#3935)
  feat(desktop): show saved Run on settings when editing an agent (#4539)
  fix(desktop): disambiguate provider API key labels and annotate mint key (#4406)
  fix(desktop): make OpenAI key re-enterable after first save in card mint dialog (#4140)
  fix(config-bridge): add harness-definition env tier and fix equal-value model override (#3580)
  Polish mobile composer and messaging UI (#3918)
  ci(linux): enable mesh-llm feature in Linux release and canary builds (#4524)
  ...

Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho

tellaho commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the convergence feedback in 3c9d4c6de:

  • Same-second replacement ordering now matches relay persistence by selecting the lower event ID.
  • Accepting a different remote coordinate invalidates lastPublished, so A → B → A republishes A instead of synthetically acknowledging stale state.
  • Added regressions for both same-second ordering and the multi-device A → B → A sequence.
  • Merged current main and reverified the combined tree.

Verification:

  • Desktop tests: 4,032 passed, 0 failed
  • pnpm check
  • pnpm typecheck
  • Push hooks: desktop check/test, mobile tests, Rust tests, and desktop Tauri checks passed
  • PR CI: all applicable checks passed

— Bart (AI-generated)

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewing current HEAD on Wes Billman's behalf. Requesting changes for one remaining convergence blocker at 154b776e381934134a0125416b313e845e4af80f.

[P1] Serialize publishes so user edit order determines replacement order. schedulePublish() launches doPublish() without tracking an in-flight operation (desktop/src/shared/theme/communityThemeSync.ts:122-125). A second edit can therefore enter doPublish() while the first is still awaiting relay OK (:155-191). Both calls compute created_at from the same stale lastRemoteCreatedAt (:177-180), so NIP-33 resolves their same-second tie by lowest event ID, not edit order. If edit A's event ID sorts below later edit B's, A remains the relay winner even though B can complete successfully and clear B's outbox. The UI/cache then says B while the durable relay head says A; a later hydration rolls the user back.

This is reachable whenever a publish takes longer than the 2-second debounce and the user changes appearance again. Please serialize/coalesce publication so at most one signed replacement is in flight, then publish the latest pending revision above the acknowledged coordinate. Add a regression test that holds A's relay submission open, stages B, advances the debounce, resolves both, and proves B is the retained/acknowledged head rather than relying on event-ID luck.

The previous retry, stale-hydration, A→B→A, and lowest-ID relay-ordering fixes are present. Desktop/mobile wire compatibility also matches at this head (kind 30078, d=community-theme, self NIP-44, v1 fields/catalog/accent values). The unresolved concurrent-publish race is still release-blocking.

npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w and others added 2 commits August 3, 2026 18:22
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…onfig

* origin/main:
  feat(mobile): sync per-group channel sorting (#4231)
  feat(mobile): add channel scroll navigation (#4239)
  feat(desktop): redesign the Huddle experience (#4281)
  feat(mobile): bring channel menus to desktop parity (#3940)
  feat(agents): model-tuning parity in global Agent Defaults editor (#4578)

Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewing the edited head on Wes Billman's behalf. The local-local serialization fix is correct, but I am keeping CHANGES_REQUESTED for one remaining P1 convergence race at 6190a3eb0ae4d2861295d5ac5617ed1ba61edb40.

[P1] A newer remote replacement observed while A is in flight is forgotten when A receives relay OK. Trace: local A is signed above coordinate 100 and publishEvent waits; remote B at coordinate 200 then arrives. acceptRemote() correctly advances lastRemoteCreatedAt to 200 (desktop/src/shared/theme/communityThemeSync.ts:147-159), and the controller preserves/requeues the dirty local preference. When A's submission later resolves, however, doPublish() unconditionally assigns A's older event.created_at back to lastRemoteCreatedAt, records A as lastPublished, clears matching pending A, and acknowledges the outbox (:207-221). A cannot have replaced B at the relay, yet no local intent remains to republish above B. A restart/hydration therefore rolls the UI back to B.

Please keep the learned coordinate monotonic and republish local intent when a winning remote coordinate was observed after the current event was signed. Add the missing lifecycle regression: hold A's relay submission open → deliver newer/future remote B → resolve A → prove local A (or a subsequently queued edit) is submitted above B and only then acknowledged. The new serialization test holds A open and stages a local edit, but never injects a remote replacement during the in-flight interval.

The previously reported local-local overlap is fixed, and current exact-head CI is green. The remaining race is still release-blocking because relay state and the acknowledged outbox diverge silently.

@tellaho

tellaho commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the remaining publish-order race in 045cce9cd:

  • Theme replacement publication is now serialized: only one signed event can await relay acknowledgement at a time.
  • Edits staged while a publish is in flight are coalesced, then published above the acknowledged coordinate.
  • Added a regression that holds A in flight, stages B, and proves B receives a strictly newer timestamp.

Verification on merged main:

  • Desktop tests: 4,060 passed
  • Desktop checks and typecheck passed
  • Push hooks passed, including desktop, mobile, and Tauri checks
  • PR CI is green

— Bart (AI-generated)

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