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

**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.

<details>
<summary>File changes</summary>

**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.

</details>

### 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
<img width="1733" height="948" alt="image"
src="https://github.com/user-attachments/assets/5afeabaa-0def-482c-9b87-8a880ee0a467"
/>


<img width="700" height="412" alt="Screen Recording 2026-07-29 at 4 39
52 PM"
src="https://github.com/user-attachments/assets/d58da329-aec5-4324-a4b2-cbcb2702a81a"
/>

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-05 13:18:48 -07:00
committed by GitHub
parent 6c40ce394f
commit 43cced308d
11 changed files with 1482 additions and 1 deletions

View File

@@ -18,6 +18,7 @@ import {
} from "@/app/communityViewTransition";
import { deriveShellRoute } from "@/app/AppShell.helpers";
import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground";
import { CommunityThemeController } from "@/shared/theme/CommunityThemeController";
import { useReloadShortcut } from "@/app/useReloadShortcut";
import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys";
import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow";
@@ -547,6 +548,7 @@ function CommunityApp({
if (appContent === null && (!transaction || isEnteringCurtain)) {
appContent = communityApplied ? (
<CommunityQueryProvider key={communityKey}>
<CommunityThemeController />
<AppReady
isCommunitySwitch={isCommunitySwitch}
key={communityKey}

View File

@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { appearanceCommunityLabel } from "./appearanceScopeCopy.ts";
test("labels the active community by name", () => {
assert.equal(appearanceCommunityLabel("Block Builders"), "Block Builders");
});
test("trims surrounding whitespace from the community name", () => {
assert.equal(appearanceCommunityLabel(" Buzz HQ "), "Buzz HQ");
});
test("falls back to a generic label when no community is active", () => {
assert.equal(appearanceCommunityLabel(null), "this community");
assert.equal(appearanceCommunityLabel(undefined), "this community");
assert.equal(appearanceCommunityLabel(""), "this community");
assert.equal(appearanceCommunityLabel(" "), "this community");
});

View File

@@ -0,0 +1,20 @@
/**
* Copy helper for the Appearance settings panel's per-community scoping.
*
* Theme, mode, and accent are saved per community (see
* `shared/theme/CommunityThemeController`), so the panel badges the community
* being customized. Kept as a pure function so the copy is unit-testable
* without rendering the settings tree.
*/
/**
* Display label for the community whose appearance is being edited.
* Falls back to a generic phrase when no community is active or the
* stored name is blank.
*/
export function appearanceCommunityLabel(
communityName: string | null | undefined,
): string {
const trimmed = communityName?.trim();
return trimmed ? trimmed : "this community";
}

View File

@@ -38,7 +38,10 @@ import {
type ThreadViewMode,
} from "@/features/channels/lib/threadViewModePreference";
import { cn } from "@/shared/lib/cn";
import { useCommunities } from "@/features/communities/useCommunities";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { SectionHeader } from "@/shared/ui/PageHeader";
import {
DropdownMenu,
DropdownMenuContent,
@@ -69,6 +72,7 @@ import {
useThemePreviewVars,
withAccentPreviewVars,
} from "@/shared/theme/useThemePreviewVars";
import { appearanceCommunityLabel } from "../lib/appearanceScopeCopy";
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel";
import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
@@ -429,6 +433,13 @@ function ThemeSettingsCard() {
setFollowSystem,
} = useTheme();
// Per-community scoping labels only earn their place when the user is
// actually in more than one community; with a single community there is
// nothing to disambiguate.
const { activeCommunity, communities } = useCommunities();
const showCommunityScope = communities.length > 1;
const communityLabel = appearanceCommunityLabel(activeCommunity?.name);
// Buzz themes pin a neutral accent (GitHub black in light, white in dark),
// so the accent picker is hidden while a Buzz theme is active. `themeName` is
// the effective theme, so this also covers System mode resolving to Buzz.
@@ -529,6 +540,34 @@ function ThemeSettingsCard() {
description="Choose a theme for Buzz."
/>
{/* Mode, theme, and accent are saved per community
(CommunityThemeController restores them on switch). When the user is
in multiple communities, a subheader with an inline badge names the
community being edited; with one community there is nothing to
disambiguate, so no scoping labels are shown. */}
{showCommunityScope ? (
<SectionHeader
className="mb-4"
title={
<span className="flex min-w-0 items-center gap-2">
Theme{" "}
<span className="font-normal text-muted-foreground">
(per community)
</span>
{activeCommunity ? (
<Badge
className="max-w-56 shrink-0 font-medium normal-case tracking-normal"
data-testid="appearance-community-badge"
variant="outline"
>
<span className="truncate">{communityLabel}</span>
</Badge>
) : null}
</span>
}
/>
) : null}
{/* Mode selector: System / Light / Dark */}
<div className="mb-4 flex gap-2">
{(
@@ -684,6 +723,11 @@ const THREAD_VIEW_MODE_OPTIONS: {
*/
function ThreadLayoutSetting() {
const threadViewMode = useThreadViewMode();
// The "(all communities)" qualifier contrasts with the per-community theme
// controls above; it's only meaningful when the user has multiple
// communities.
const { communities } = useCommunities();
const showCommunityScope = communities.length > 1;
const activeOption =
THREAD_VIEW_MODE_OPTIONS.find(
(option) => option.value === threadViewMode,
@@ -693,7 +737,15 @@ function ThreadLayoutSetting() {
<SettingsOptionGroup className="mt-8">
<SettingsOptionRow>
<div className="min-w-0">
<p className="text-sm font-medium">Thread layout</p>
<p className="text-sm font-medium">
Thread layout
{showCommunityScope ? (
<span className="font-normal text-muted-foreground">
{" "}
(all communities)
</span>
) : null}
</p>
<p className="text-sm font-normal text-muted-foreground">
{activeOption.description}
</p>

View File

@@ -46,6 +46,7 @@ export const KIND_CHANNEL_SECTIONS = 30078;
export const KIND_CHANNEL_MUTES = 30078;
export const KIND_CHANNEL_STARS = 30078;
export const KIND_CHANNEL_SORT = 30078;
export const KIND_COMMUNITY_THEME = 30078;
// NIP-33 persona/team/managed-agent projection events (d-tag keyed). Published
// backend-side as secrets-stripped snapshots; the inbound sync hook subscribes
// to all three to patch local records. Mirror of buzz-core's KIND_PERSONA etc.

View File

@@ -0,0 +1,208 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { useCommunities } from "@/features/communities/useCommunities";
import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
import {
DEFAULT_COMMUNITY_THEME,
cacheAndApplyCommunityTheme,
clearCommunityThemeOutbox,
communityThemeApplyExpectation,
communityThemePersistenceAction,
hasMigratedCommunityTheme,
markCommunityThemeMigrated,
readCommunityThemeOutbox,
readCommunityThemePreference,
sameCommunityThemePreference,
writeCommunityThemeOutbox,
writeCommunityThemePreference,
type CommunityThemePreference,
} from "./communityThemePreference";
import {
CommunityThemeSyncManager,
isNewerCommunityThemeCoordinate,
shouldSeedCommunityTheme,
type RemoteCommunityTheme,
} from "./communityThemeSync";
import { useTheme } from "./ThemeProvider";
export function CommunityThemeController() {
const { activeCommunity } = useCommunities();
const identity = useIdentityQuery();
const theme = useTheme();
const pubkey = identity.data?.pubkey;
const relayUrl = activeCommunity?.relayUrl;
const managerRef = useRef<CommunityThemeSyncManager | null>(null);
const scopeRef = useRef("");
const expectedAppliedRef = useRef<CommunityThemePreference | null>(null);
const scopedPreferenceRef = useRef<CommunityThemePreference | null>(null);
const lastRemoteRef = useRef({ createdAt: 0, eventId: "" });
const initialPreferenceRef = useRef<CommunityThemePreference>({
version: 1,
theme: theme.selectedThemeName as CommunityThemePreference["theme"],
accent: theme.accentColor,
followSystem: theme.followSystem,
});
const currentPreferenceRef = useRef<CommunityThemePreference>({
version: 1,
theme: theme.selectedThemeName as CommunityThemePreference["theme"],
accent: theme.accentColor,
followSystem: theme.followSystem,
});
currentPreferenceRef.current = {
version: 1,
theme: theme.selectedThemeName as CommunityThemePreference["theme"],
accent: theme.accentColor,
followSystem: theme.followSystem,
};
const applyPreference = useCallback(
(preference: CommunityThemePreference) => {
expectedAppliedRef.current = communityThemeApplyExpectation(
preference,
currentPreferenceRef.current,
);
theme.applyAppearance(preference);
},
[theme.applyAppearance],
);
useLayoutEffect(() => {
if (!pubkey || !relayUrl) return;
const local = readCommunityThemePreference(pubkey, relayUrl);
const dirty = readCommunityThemeOutbox(pubkey, relayUrl);
// Preserve the user's existing global appearance the first time this
// feature sees their current community. Later missing/malformed target
// records use the stable default so the previous community never leaks.
const fallback = hasMigratedCommunityTheme(pubkey)
? DEFAULT_COMMUNITY_THEME
: initialPreferenceRef.current;
const scopedPreference = dirty ?? local ?? fallback;
scopedPreferenceRef.current = scopedPreference;
applyPreference(scopedPreference);
}, [pubkey, relayUrl, applyPreference]);
useEffect(() => {
if (!pubkey || !relayUrl) return;
const scope = `${pubkey}:${relayUrl}`;
scopeRef.current = scope;
lastRemoteRef.current = { createdAt: 0, eventId: "" };
const manager = new CommunityThemeSyncManager(pubkey, (published) => {
const last = lastRemoteRef.current;
if (isNewerCommunityThemeCoordinate(published, last)) {
lastRemoteRef.current = {
createdAt: published.createdAt,
eventId: published.eventId,
};
}
clearCommunityThemeOutbox(pubkey, relayUrl, published.preference);
});
managerRef.current = manager;
const durablePending = readCommunityThemeOutbox(pubkey, relayUrl);
if (durablePending) manager.publish(durablePending);
const applyRemote = (remote: RemoteCommunityTheme) => {
if (scopeRef.current !== scope) return;
const last = lastRemoteRef.current;
if (!isNewerCommunityThemeCoordinate(remote, last)) {
return;
}
lastRemoteRef.current = {
createdAt: remote.createdAt,
eventId: remote.eventId,
};
manager.acceptRemote(remote);
const dirty = readCommunityThemeOutbox(pubkey, relayUrl);
if (dirty) {
manager.publish(dirty);
return;
}
scopedPreferenceRef.current = remote.preference;
manager.cancelPendingPublish();
cacheAndApplyCommunityTheme(
pubkey,
relayUrl,
remote.preference,
applyPreference,
);
};
void manager.fetchRemote().then((result) => {
if (scopeRef.current !== scope) return;
if (result.status === "valid") {
applyRemote(result.remote);
markCommunityThemeMigrated(pubkey);
} else if (shouldSeedCommunityTheme(result)) {
const local =
readCommunityThemeOutbox(pubkey, relayUrl) ??
readCommunityThemePreference(pubkey, relayUrl) ??
scopedPreferenceRef.current ??
DEFAULT_COMMUNITY_THEME;
writeCommunityThemePreference(pubkey, relayUrl, local);
writeCommunityThemeOutbox(pubkey, relayUrl, local);
markCommunityThemeMigrated(pubkey);
manager.publish(local);
}
// Invalid/future or unavailable records use the already-applied local
// fallback without publishing over relay state we cannot safely read.
});
let unsubscribe: (() => Promise<void>) | null = null;
void manager.subscribe(applyRemote).then((dispose) => {
if (scopeRef.current !== scope) void dispose();
else unsubscribe = dispose;
});
const unsubscribeReconnect = relayClient.subscribeToReconnects(() => {
void manager.fetchRemote().then((result) => {
if (result.status === "valid") {
applyRemote(result.remote);
return;
}
if (result.status !== "absent") return;
const pending = readCommunityThemeOutbox(pubkey, relayUrl);
if (pending) manager.publish(pending);
});
});
return () => {
if (scopeRef.current === scope) scopeRef.current = "";
manager.destroy();
if (managerRef.current === manager) managerRef.current = null;
unsubscribeReconnect();
if (unsubscribe) void unsubscribe();
};
}, [pubkey, relayUrl, applyPreference]);
useEffect(() => {
if (!pubkey || !relayUrl) return;
const preference: CommunityThemePreference = {
version: 1,
theme: theme.selectedThemeName as CommunityThemePreference["theme"],
accent: theme.accentColor,
followSystem: theme.followSystem,
};
const persistenceAction = communityThemePersistenceAction(
expectedAppliedRef.current,
preference,
);
if (persistenceAction === "defer") return;
if (persistenceAction === "acknowledge") {
expectedAppliedRef.current = null;
return;
}
const stored = readCommunityThemePreference(pubkey, relayUrl);
if (stored && sameCommunityThemePreference(stored, preference)) return;
scopedPreferenceRef.current = preference;
if (!writeCommunityThemePreference(pubkey, relayUrl, preference)) return;
if (!writeCommunityThemeOutbox(pubkey, relayUrl, preference)) return;
managerRef.current?.publish(preference);
}, [
pubkey,
relayUrl,
theme.selectedThemeName,
theme.accentColor,
theme.followSystem,
]);
return null;
}

View File

@@ -60,6 +60,11 @@ type ThemeContextValue = {
setTheme: (name: string) => void;
setAccentColor: (color: string) => void;
setFollowSystem: (enabled: boolean) => void;
applyAppearance: (appearance: {
theme: SyntaxThemeName;
accent: string;
followSystem: boolean;
}) => void;
};
type ThemeProviderProps = {
@@ -618,6 +623,31 @@ export function ThemeProvider({
setFollowSystemState(enabled);
}, []);
const applyAppearance = useCallback(
(appearance: {
theme: SyntaxThemeName;
accent: string;
followSystem: boolean;
}) => {
// Write the complete preference before updating state so applyTheme reads
// the target community's accent in the same batch, never the previous one.
try {
window.localStorage.setItem(THEME_STORAGE_KEY, appearance.theme);
window.localStorage.setItem(ACCENT_STORAGE_KEY, appearance.accent);
window.localStorage.setItem(
FOLLOW_SYSTEM_KEY,
appearance.followSystem ? "true" : "false",
);
} catch {
// Keep the active appearance responsive even if the local cache is full.
}
setSelectedTheme(appearance.theme);
setAccentColorState(appearance.accent);
setFollowSystemState(appearance.followSystem);
},
[],
);
const value: ThemeContextValue = {
themeName: effectiveTheme,
selectedThemeName: selectedTheme,
@@ -630,6 +660,7 @@ export function ThemeProvider({
setTheme,
setAccentColor,
setFollowSystem,
applyAppearance,
};
return (

View File

@@ -0,0 +1,183 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_COMMUNITY_THEME,
cacheAndApplyCommunityTheme,
clearCommunityThemeOutbox,
communityThemeApplyExpectation,
communityThemeOutboxKey,
communityThemePersistenceAction,
communityThemeStorageKey,
parseCommunityThemePreference,
readCommunityThemeOutbox,
readCommunityThemePreference,
writeCommunityThemeOutbox,
writeCommunityThemePreference,
} from "./communityThemePreference.ts";
function localStorageStub() {
const data = new Map();
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => data.set(key, String(value)),
removeItem: (key) => data.delete(key),
};
}
test("parses only the versioned stable appearance contract", () => {
const valid = {
version: 1,
theme: "houston",
accent: "#a855f7",
followSystem: false,
};
assert.deepEqual(parseCommunityThemePreference(valid), valid);
assert.equal(parseCommunityThemePreference({ ...valid, version: 2 }), null);
assert.equal(
parseCommunityThemePreference({ ...valid, theme: "future-theme" }),
null,
);
assert.equal(
parseCommunityThemePreference({ ...valid, accent: "url(image)" }),
null,
);
assert.equal(
parseCommunityThemePreference({ ...valid, followSystem: "false" }),
null,
);
});
test("local preferences are isolated by pubkey and normalized relay", () => {
globalThis.window = { localStorage: localStorageStub() };
const aliceA = {
...DEFAULT_COMMUNITY_THEME,
theme: "houston",
followSystem: false,
};
const aliceB = { ...DEFAULT_COMMUNITY_THEME, theme: "catppuccin-latte" };
const bobA = { ...DEFAULT_COMMUNITY_THEME, accent: "#ef4444" };
assert.equal(
writeCommunityThemePreference("alice", "WSS://A.EXAMPLE/", aliceA),
true,
);
assert.equal(
writeCommunityThemePreference("alice", "wss://b.example", aliceB),
true,
);
assert.equal(
writeCommunityThemePreference("bob", "wss://a.example", bobA),
true,
);
assert.deepEqual(
readCommunityThemePreference("alice", "wss://a.example"),
aliceA,
);
assert.deepEqual(
readCommunityThemePreference("alice", "wss://b.example/"),
aliceB,
);
assert.deepEqual(
readCommunityThemePreference("bob", "wss://a.example"),
bobA,
);
assert.notEqual(
communityThemeStorageKey("alice", "wss://a.example"),
communityThemeStorageKey("alice", "wss://b.example"),
);
});
test("dirty outbox survives restart and clears only its exact revision", () => {
globalThis.window = { localStorage: localStorageStub() };
const first = { ...DEFAULT_COMMUNITY_THEME, theme: "houston" };
const second = { ...DEFAULT_COMMUNITY_THEME, accent: "#ef4444" };
assert.equal(
writeCommunityThemeOutbox("alice", "WSS://A.EXAMPLE/", first),
true,
);
assert.deepEqual(readCommunityThemeOutbox("alice", "wss://a.example"), first);
writeCommunityThemeOutbox("alice", "wss://a.example", second);
clearCommunityThemeOutbox("alice", "wss://a.example", first);
assert.deepEqual(
readCommunityThemeOutbox("alice", "wss://a.example"),
second,
);
clearCommunityThemeOutbox("alice", "wss://a.example", second);
assert.equal(readCommunityThemeOutbox("alice", "wss://a.example"), null);
assert.notEqual(
communityThemeOutboxKey("alice", "wss://a.example"),
communityThemeStorageKey("alice", "wss://a.example"),
);
});
test("malformed local data returns null so switching can apply the safe default", () => {
globalThis.window = { localStorage: localStorageStub() };
const key = communityThemeStorageKey("alice", "wss://broken.example");
window.localStorage.setItem(
key,
JSON.stringify({ version: 1, theme: "missing" }),
);
assert.equal(
readCommunityThemePreference("alice", "wss://broken.example"),
null,
);
window.localStorage.setItem(key, "{");
assert.equal(
readCommunityThemePreference("alice", "wss://broken.example"),
null,
);
});
test("remote preference still applies when its local cache write fails", () => {
globalThis.window = {
localStorage: {
getItem: () => null,
setItem: () => {
throw new Error("quota exceeded");
},
},
};
let applied = null;
cacheAndApplyCommunityTheme(
"alice",
"wss://a.example",
DEFAULT_COMMUNITY_THEME,
(preference) => {
applied = preference;
},
);
assert.deepEqual(applied, DEFAULT_COMMUNITY_THEME);
});
test("already-applied relay state leaves the next user edit publishable", () => {
const applied = {
...DEFAULT_COMMUNITY_THEME,
theme: "catppuccin-latte",
followSystem: false,
};
assert.equal(communityThemeApplyExpectation(applied, applied), null);
assert.deepEqual(
communityThemeApplyExpectation(applied, DEFAULT_COMMUNITY_THEME),
applied,
);
});
test("community switch defers stale outgoing appearance persistence", () => {
const outgoing = {
...DEFAULT_COMMUNITY_THEME,
theme: "houston",
followSystem: false,
};
const incoming = {
...DEFAULT_COMMUNITY_THEME,
theme: "catppuccin-latte",
};
assert.equal(communityThemePersistenceAction(incoming, outgoing), "defer");
assert.equal(
communityThemePersistenceAction(incoming, incoming),
"acknowledge",
);
assert.equal(communityThemePersistenceAction(null, incoming), "persist");
});

View File

@@ -0,0 +1,199 @@
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
import { ACCENT_COLORS } from "./ThemeProvider";
import { SYNTAX_THEMES, type SyntaxThemeName } from "./theme-loader";
const STORAGE_KEY_PREFIX = "buzz-community-theme.v1";
const OUTBOX_KEY_PREFIX = "buzz-community-theme-outbox.v1";
const MIGRATION_KEY_PREFIX = "buzz-community-theme-migrated.v1";
export type CommunityThemePreference = {
version: 1;
theme: SyntaxThemeName;
accent: string;
followSystem: boolean;
};
export const DEFAULT_COMMUNITY_THEME: CommunityThemePreference = Object.freeze({
version: 1,
theme: "buzz",
accent: "#3b82f6",
followSystem: true,
});
const THEME_NAMES = new Set<string>(SYNTAX_THEMES);
const ACCENTS = new Set<string>(ACCENT_COLORS.map(({ value }) => value));
export function communityThemeStorageKey(
pubkey: string,
relayUrl: string,
): string {
return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
export function communityThemeOutboxKey(
pubkey: string,
relayUrl: string,
): string {
return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
export function parseCommunityThemePreference(
value: unknown,
): CommunityThemePreference | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return null;
}
const candidate = value as Record<string, unknown>;
if (
candidate.version !== 1 ||
typeof candidate.theme !== "string" ||
!THEME_NAMES.has(candidate.theme) ||
typeof candidate.accent !== "string" ||
!ACCENTS.has(candidate.accent) ||
typeof candidate.followSystem !== "boolean"
) {
return null;
}
return {
version: 1,
theme: candidate.theme as SyntaxThemeName,
accent: candidate.accent,
followSystem: candidate.followSystem,
};
}
export function readCommunityThemePreference(
pubkey: string,
relayUrl: string,
): CommunityThemePreference | null {
try {
const raw = window.localStorage.getItem(
communityThemeStorageKey(pubkey, relayUrl),
);
return raw ? parseCommunityThemePreference(JSON.parse(raw)) : null;
} catch {
return null;
}
}
export function readCommunityThemeOutbox(
pubkey: string,
relayUrl: string,
): CommunityThemePreference | null {
try {
const raw = window.localStorage.getItem(
communityThemeOutboxKey(pubkey, relayUrl),
);
return raw ? parseCommunityThemePreference(JSON.parse(raw)) : null;
} catch {
return null;
}
}
export function writeCommunityThemeOutbox(
pubkey: string,
relayUrl: string,
preference: CommunityThemePreference,
): boolean {
try {
window.localStorage.setItem(
communityThemeOutboxKey(pubkey, relayUrl),
JSON.stringify(preference),
);
return true;
} catch {
return false;
}
}
export function clearCommunityThemeOutbox(
pubkey: string,
relayUrl: string,
acknowledged: CommunityThemePreference,
): void {
const pending = readCommunityThemeOutbox(pubkey, relayUrl);
if (!pending || !sameCommunityThemePreference(pending, acknowledged)) return;
try {
window.localStorage.removeItem(communityThemeOutboxKey(pubkey, relayUrl));
} catch {
// A later retry can safely publish the same replaceable event again.
}
}
export function hasMigratedCommunityTheme(pubkey: string): boolean {
try {
return (
window.localStorage.getItem(`${MIGRATION_KEY_PREFIX}:${pubkey}`) ===
"true"
);
} catch {
return false;
}
}
export function markCommunityThemeMigrated(pubkey: string): void {
try {
window.localStorage.setItem(`${MIGRATION_KEY_PREFIX}:${pubkey}`, "true");
} catch {
// The preference itself remains usable in memory when storage is full.
}
}
export function writeCommunityThemePreference(
pubkey: string,
relayUrl: string,
preference: CommunityThemePreference,
): boolean {
try {
window.localStorage.setItem(
communityThemeStorageKey(pubkey, relayUrl),
JSON.stringify(preference),
);
return true;
} catch {
return false;
}
}
export function cacheAndApplyCommunityTheme(
pubkey: string,
relayUrl: string,
preference: CommunityThemePreference,
apply: (preference: CommunityThemePreference) => void,
): void {
writeCommunityThemePreference(pubkey, relayUrl, preference);
apply(preference);
}
export function sameCommunityThemePreference(
left: CommunityThemePreference,
right: CommunityThemePreference,
): boolean {
return (
left.theme === right.theme &&
left.accent === right.accent &&
left.followSystem === right.followSystem
);
}
export function communityThemeApplyExpectation(
preference: CommunityThemePreference,
current: CommunityThemePreference,
): CommunityThemePreference | null {
return sameCommunityThemePreference(preference, current) ? null : preference;
}
/**
* Decide whether the current context value is safe to persist for this scope.
* Applying a scoped preference updates the outer ThemeProvider asynchronously,
* so renders that still expose the previous scope must be deferred.
*/
export function communityThemePersistenceAction(
expectedApplied: CommunityThemePreference | null,
current: CommunityThemePreference,
): "persist" | "defer" | "acknowledge" {
if (!expectedApplied) return "persist";
return sameCommunityThemePreference(expectedApplied, current)
? "acknowledge"
: "defer";
}

View File

@@ -0,0 +1,445 @@
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import { relayClient } from "@/shared/api/relayClient";
import {
CommunityThemeSyncManager,
isNewerCommunityThemeCoordinate,
shouldSeedCommunityTheme,
} from "./communityThemeSync.ts";
const preference = {
version: 1,
theme: "houston",
accent: "#3b82f6",
followSystem: false,
};
function installFakeTimer() {
globalThis.window ??= {};
let callback = null;
let delay = null;
const originalSet = window.setTimeout;
const originalClear = window.clearTimeout;
window.setTimeout = (fn, requestedDelay) => {
callback = fn;
delay = requestedDelay;
return 1;
};
window.clearTimeout = () => {
callback = null;
delay = null;
};
return {
fire: () => {
const fn = callback;
callback = null;
delay = null;
fn?.();
},
pending: () => callback !== null,
delay: () => delay,
restore: () => {
window.setTimeout = originalSet;
window.clearTimeout = originalClear;
},
};
}
test("destroy cancels a debounced community write before relay teardown", () => {
const timer = installFakeTimer();
const publishes = [];
mock.method(relayClient, "publishEvent", (...args) => {
publishes.push(args);
return Promise.resolve();
});
try {
const manager = new CommunityThemeSyncManager("alice");
manager.publish(preference);
assert.equal(timer.pending(), true);
manager.destroy();
assert.equal(timer.pending(), false);
timer.fire();
assert.equal(publishes.length, 0);
} finally {
timer.restore();
mock.reset();
}
});
test("destroy is safe without a pending community write", () => {
const manager = new CommunityThemeSyncManager("alice");
assert.doesNotThrow(() => manager.destroy());
assert.equal(manager.getPending(), null);
});
function relayEvent(overrides = {}) {
return {
id: "event-id",
pubkey: "alice",
kind: 30078,
content: "not-decryptable",
created_at: 123,
tags: [["d", "community-theme"]],
...overrides,
};
}
test("fetch distinguishes absent remote state from unreadable existing state", async () => {
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
try {
const manager = new CommunityThemeSyncManager("alice");
assert.deepEqual(await manager.fetchRemote(), { status: "absent" });
} finally {
mock.reset();
}
mock.method(relayClient, "fetchEvents", () =>
Promise.resolve([relayEvent()]),
);
try {
const manager = new CommunityThemeSyncManager("alice");
assert.deepEqual(await manager.fetchRemote(), { status: "invalid" });
} finally {
mock.reset();
}
});
test("fetch reports relay failures as unavailable rather than absent", async () => {
mock.method(relayClient, "fetchEvents", () =>
Promise.reject(new Error("offline")),
);
try {
const manager = new CommunityThemeSyncManager("alice");
assert.deepEqual(await manager.fetchRemote(), { status: "unavailable" });
} finally {
mock.reset();
}
});
test("only confirmed absence permits seeding relay state", () => {
assert.equal(shouldSeedCommunityTheme({ status: "absent" }), true);
assert.equal(shouldSeedCommunityTheme({ status: "invalid" }), false);
assert.equal(shouldSeedCommunityTheme({ status: "unavailable" }), false);
});
test("acknowledged coordinates use relay same-second ordering", () => {
const acknowledged = { createdAt: 123, eventId: "published" };
assert.equal(
isNewerCommunityThemeCoordinate(
{ createdAt: 123, eventId: "a-winner" },
acknowledged,
),
true,
);
assert.equal(
isNewerCommunityThemeCoordinate(
{ createdAt: 123, eventId: "z-loser" },
acknowledged,
),
false,
);
});
test("new remote invalidates no-op suppression for A to B to A", async () => {
const timer = installFakeTimer();
const published = [];
const acknowledgements = [];
let signedEventId = "published-z";
globalThis.window.__TAURI_INTERNALS__ = {
invoke(command, args) {
if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher");
if (command === "sign_event") {
return Promise.resolve(
JSON.stringify(
relayEvent({
id: signedEventId,
content: args.content,
created_at: args.createdAt,
}),
),
);
}
throw new Error(`unexpected command: ${command}`);
},
};
mock.method(relayClient, "publishEvent", (event) => {
published.push(event);
return Promise.resolve();
});
try {
const manager = new CommunityThemeSyncManager("alice", (event) => {
acknowledgements.push(event);
});
manager.publish(preference);
timer.fire();
await waitUntil(() => published.length === 1);
manager.acceptRemote({
preference: { ...preference, theme: "dracula" },
createdAt: published[0].created_at,
eventId: "remote-a",
});
signedEventId = "republished-a";
manager.publish(preference);
timer.fire();
await waitUntil(() => published.length === 2);
assert.equal(acknowledgements.length, 2);
assert.equal(acknowledgements[1].eventId, "republished-a");
assert.equal(manager.getPending(), null);
} finally {
delete globalThis.window.__TAURI_INTERNALS__;
timer.restore();
mock.reset();
}
});
test("serializes an in-flight publish before sending the latest edit", async () => {
const timer = installFakeTimer();
const first = Promise.withResolvers();
const published = [];
let signed = 0;
globalThis.window.__TAURI_INTERNALS__ = {
invoke(command, args) {
if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher");
if (command === "sign_event") {
signed += 1;
return Promise.resolve(
JSON.stringify(
relayEvent({
id: `event-${signed}`,
content: args.content,
created_at: args.createdAt,
}),
),
);
}
throw new Error(`unexpected command: ${command}`);
},
};
mock.method(relayClient, "publishEvent", (event) => {
published.push(event);
return published.length === 1 ? first.promise : Promise.resolve();
});
try {
const manager = new CommunityThemeSyncManager("alice");
manager.publish(preference);
timer.fire();
await waitUntil(() => published.length === 1);
const latest = { ...preference, theme: "dracula" };
manager.publish(latest);
timer.fire();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(published.length, 1);
first.resolve();
await waitUntil(() => timer.pending());
assert.equal(timer.delay(), 0);
timer.fire();
await waitUntil(() => published.length === 2);
assert.ok(published[1].created_at > published[0].created_at);
assert.deepEqual(manager.getPending(), null);
} finally {
delete globalThis.window.__TAURI_INTERNALS__;
timer.restore();
mock.reset();
}
});
test("republishes above a newer remote observed while publish is in flight", async () => {
const timer = installFakeTimer();
const first = Promise.withResolvers();
const published = [];
const acknowledgements = [];
let signed = 0;
globalThis.window.__TAURI_INTERNALS__ = {
invoke(command, args) {
if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher");
if (command === "sign_event") {
signed += 1;
return Promise.resolve(
JSON.stringify(
relayEvent({
id: `event-${signed}`,
content: args.content,
created_at: args.createdAt,
}),
),
);
}
throw new Error(`unexpected command: ${command}`);
},
};
mock.method(relayClient, "publishEvent", (event) => {
published.push(event);
return published.length === 1 ? first.promise : Promise.resolve();
});
try {
const manager = new CommunityThemeSyncManager("alice", (event) => {
acknowledgements.push(event);
});
manager.publish(preference);
timer.fire();
await waitUntil(() => published.length === 1);
manager.acceptRemote({
preference: { ...preference, theme: "dracula" },
createdAt: published[0].created_at + 100,
eventId: "remote-winner",
});
first.resolve();
await waitUntil(() => timer.pending());
assert.equal(acknowledgements.length, 0);
assert.deepEqual(manager.getPending(), preference);
timer.fire();
await waitUntil(() => acknowledgements.length === 1);
assert.equal(published.length, 2);
assert.ok(published[1].created_at > published[0].created_at + 100);
assert.deepEqual(manager.getPending(), null);
} finally {
delete globalThis.window.__TAURI_INTERNALS__;
timer.restore();
mock.reset();
}
});
test("delayed live decryption fences publish acknowledgement and preserves local intent", async () => {
const timer = installFakeTimer();
const firstPublish = Promise.withResolvers();
const remotePlaintext = Promise.withResolvers();
const published = [];
const acknowledgements = [];
let liveCallback;
let signed = 0;
globalThis.window.__TAURI_INTERNALS__ = {
invoke(command, args) {
if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher");
if (command === "nip44_decrypt_from_self") return remotePlaintext.promise;
if (command === "sign_event") {
signed += 1;
return Promise.resolve(
JSON.stringify(
relayEvent({
id: `event-${signed}`,
content: args.content,
created_at: args.createdAt,
}),
),
);
}
throw new Error(`unexpected command: ${command}`);
},
};
mock.method(relayClient, "subscribeLive", (_filter, callback) => {
liveCallback = callback;
return Promise.resolve(async () => {});
});
mock.method(relayClient, "publishEvent", (event) => {
published.push(event);
return published.length === 1 ? firstPublish.promise : Promise.resolve();
});
try {
const manager = new CommunityThemeSyncManager("alice", (event) => {
acknowledgements.push(event);
});
await manager.subscribe(() => {});
manager.publish(preference);
timer.fire();
await waitUntil(() => published.length === 1);
liveCallback(
relayEvent({
id: "remote-winner",
content: "delayed-ciphertext",
created_at: published[0].created_at + 100,
}),
);
firstPublish.resolve();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(acknowledgements.length, 0);
assert.deepEqual(manager.getPending(), preference);
remotePlaintext.resolve(
JSON.stringify({ ...preference, theme: "dracula" }),
);
await waitUntil(() => timer.pending());
assert.equal(acknowledgements.length, 0);
assert.deepEqual(manager.getPending(), preference);
timer.fire();
await waitUntil(() => acknowledgements.length === 1);
assert.equal(published.length, 2);
assert.ok(published[1].created_at > published[0].created_at + 100);
assert.deepEqual(manager.getPending(), null);
} finally {
delete globalThis.window.__TAURI_INTERNALS__;
timer.restore();
mock.reset();
}
});
test("transient publish failure retries and acknowledges exact event", async () => {
const timer = installFakeTimer();
const published = [];
const acknowledgements = [];
let attempts = 0;
globalThis.window.__TAURI_INTERNALS__ = {
invoke(command, args) {
if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher");
if (command === "sign_event") {
return Promise.resolve(
JSON.stringify(
relayEvent({
id: "published-event",
content: args.content,
created_at: args.createdAt,
}),
),
);
}
throw new Error(`unexpected command: ${command}`);
},
};
mock.method(relayClient, "publishEvent", (event) => {
attempts += 1;
if (attempts === 1) return Promise.reject(new Error("timeout"));
published.push(event);
return Promise.resolve();
});
try {
const manager = new CommunityThemeSyncManager("alice", (event) => {
acknowledgements.push(event);
});
manager.publish(preference);
timer.fire();
await waitUntil(() => timer.pending());
assert.equal(timer.delay(), 1_000);
assert.deepEqual(manager.getPending(), preference);
timer.fire();
await waitUntil(() => acknowledgements.length === 1);
assert.equal(attempts, 2);
assert.equal(published.length, 1);
assert.equal(manager.getPending(), null);
assert.deepEqual(acknowledgements[0], {
preference,
createdAt: published[0].created_at,
eventId: "published-event",
});
} finally {
delete globalThis.window.__TAURI_INTERNALS__;
timer.restore();
mock.reset();
}
});
async function waitUntil(condition) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (condition()) return;
await new Promise((resolve) => setImmediate(resolve));
}
assert.fail("condition not met");
}

View File

@@ -0,0 +1,321 @@
import { relayClient } from "@/shared/api/relayClient";
import {
nip44DecryptFromSelf,
nip44EncryptToSelf,
signRelayEvent,
} from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import { KIND_COMMUNITY_THEME } from "@/shared/constants/kinds";
import {
parseCommunityThemePreference,
sameCommunityThemePreference,
type CommunityThemePreference,
} from "./communityThemePreference";
const D_TAG = "community-theme";
const DEBOUNCE_MS = 2_000;
const PUBLISH_RETRY_BASE_MS = 1_000;
const PUBLISH_RETRY_MAX_MS = 30_000;
export type PublishedCommunityTheme = {
preference: CommunityThemePreference;
createdAt: number;
eventId: string;
};
export type RemoteCommunityTheme = {
preference: CommunityThemePreference;
createdAt: number;
eventId: string;
};
export type RemoteCommunityThemeResult =
| { status: "valid"; remote: RemoteCommunityTheme }
| { status: "absent" | "invalid" | "unavailable" };
export function isNewerCommunityThemeCoordinate(
candidate: { createdAt: number; eventId: string },
current: { createdAt: number; eventId: string },
): boolean {
return (
candidate.createdAt > current.createdAt ||
(candidate.createdAt === current.createdAt &&
(current.eventId === "" || candidate.eventId < current.eventId))
);
}
export function shouldSeedCommunityTheme(
result: RemoteCommunityThemeResult,
): boolean {
return result.status === "absent";
}
async function decryptAndParse(
event: RelayEvent,
): Promise<RemoteCommunityTheme | null> {
try {
const plaintext = await nip44DecryptFromSelf(event.content);
const preference = parseCommunityThemePreference(JSON.parse(plaintext));
return preference
? { preference, createdAt: event.created_at, eventId: event.id }
: null;
} catch {
return null;
}
}
export class CommunityThemeSyncManager {
private readonly pubkey: string;
private debounceTimer: number | null = null;
private destroyed = false;
private lastRemoteCreatedAt = 0;
private lastRemoteEventId = "";
private lastPublished: PublishedCommunityTheme | null = null;
private pending: CommunityThemePreference | null = null;
private publishInFlight = false;
private publishRetryAttempt = 0;
private readonly remoteProcessing = new Set<Promise<unknown>>();
private readonly onPublished: (published: PublishedCommunityTheme) => void;
constructor(
pubkey: string,
onPublished: (published: PublishedCommunityTheme) => void = () => {},
) {
this.pubkey = pubkey;
this.onPublished = onPublished;
}
async fetchRemote(): Promise<RemoteCommunityThemeResult> {
try {
const events = await relayClient.fetchEvents({
kinds: [KIND_COMMUNITY_THEME],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 1,
});
if (events.length === 0) return { status: "absent" };
if (events[0].pubkey !== this.pubkey) return { status: "invalid" };
const processing = decryptAndParse(events[0]);
this.trackRemoteProcessing(processing);
const remote = await processing;
if (!remote) return { status: "invalid" };
if (
isNewerCommunityThemeCoordinate(remote, {
createdAt: this.lastRemoteCreatedAt,
eventId: this.lastRemoteEventId,
})
) {
this.lastRemoteCreatedAt = remote.createdAt;
this.lastRemoteEventId = remote.eventId;
}
return { status: "valid", remote };
} catch {
return { status: "unavailable" };
}
}
publish(preference: CommunityThemePreference): void {
if (this.destroyed) return;
this.pending = preference;
this.publishRetryAttempt = 0;
this.schedulePublish(DEBOUNCE_MS);
}
private schedulePublish(delayMs: number): void {
if (this.destroyed) return;
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
}
this.debounceTimer = window.setTimeout(() => {
this.debounceTimer = null;
this.startPublish();
}, delayMs);
}
private startPublish(): void {
if (this.destroyed || this.publishInFlight || !this.pending) return;
this.publishInFlight = true;
const preference = this.pending;
void this.doPublish(preference).finally(() => {
this.publishInFlight = false;
if (
!this.destroyed &&
this.pending &&
!sameCommunityThemePreference(this.pending, preference) &&
this.debounceTimer === null
) {
this.schedulePublish(0);
}
});
}
getPending(): CommunityThemePreference | null {
return this.pending;
}
acceptRemote(remote: RemoteCommunityTheme): void {
if (
isNewerCommunityThemeCoordinate(remote, {
createdAt: this.lastRemoteCreatedAt,
eventId: this.lastRemoteEventId,
})
) {
this.lastRemoteCreatedAt = remote.createdAt;
this.lastRemoteEventId = remote.eventId;
}
const lastPublished = this.lastPublished;
if (
lastPublished &&
(lastPublished.createdAt !== remote.createdAt ||
lastPublished.eventId !== remote.eventId)
) {
this.lastPublished = null;
}
}
cancelPendingPublish(): void {
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
this.pending = null;
}
private async doPublish(preference: CommunityThemePreference): Promise<void> {
try {
const lastPublished = this.lastPublished;
if (
this.destroyed ||
(lastPublished &&
sameCommunityThemePreference(lastPublished.preference, preference))
) {
if (
this.pending &&
sameCommunityThemePreference(this.pending, preference)
) {
this.pending = null;
if (lastPublished) this.onPublished(lastPublished);
}
return;
}
const ciphertext = await nip44EncryptToSelf(JSON.stringify(preference));
if (this.destroyed) return;
const event = await signRelayEvent({
kind: KIND_COMMUNITY_THEME,
content: ciphertext,
createdAt: Math.max(
Math.floor(Date.now() / 1_000),
this.lastRemoteCreatedAt + 1,
),
tags: [
["d", D_TAG],
["t", D_TAG],
],
});
if (this.destroyed) return;
await relayClient.publishEvent(
event,
"Timed out publishing community theme.",
"Failed to publish community theme.",
);
await this.waitForDeliveredRemotes();
if (this.destroyed) return;
const published = {
preference,
createdAt: event.created_at,
eventId: event.id,
};
const eventLostToRemote = isNewerCommunityThemeCoordinate(
{
createdAt: this.lastRemoteCreatedAt,
eventId: this.lastRemoteEventId,
},
published,
);
if (eventLostToRemote) {
this.lastPublished = null;
this.publishRetryAttempt = 0;
if (
this.pending &&
sameCommunityThemePreference(this.pending, preference)
) {
this.schedulePublish(0);
}
return;
}
this.lastRemoteCreatedAt = event.created_at;
this.lastRemoteEventId = event.id;
this.lastPublished = published;
this.publishRetryAttempt = 0;
if (
this.pending &&
sameCommunityThemePreference(this.pending, preference)
) {
this.pending = null;
}
this.onPublished(published);
} catch (error) {
console.warn("[communityThemeSync] publish failed:", error);
if (
this.destroyed ||
!this.pending ||
!sameCommunityThemePreference(this.pending, preference)
) {
return;
}
const delay = Math.min(
PUBLISH_RETRY_BASE_MS * 2 ** this.publishRetryAttempt,
PUBLISH_RETRY_MAX_MS,
);
this.publishRetryAttempt += 1;
this.schedulePublish(delay);
}
}
private trackRemoteProcessing(task: Promise<unknown>): void {
this.remoteProcessing.add(task);
void task.then(
() => this.remoteProcessing.delete(task),
() => this.remoteProcessing.delete(task),
);
}
private async waitForDeliveredRemotes(): Promise<void> {
await Promise.allSettled([...this.remoteProcessing]);
}
async subscribe(
onUpdate: (remote: RemoteCommunityTheme) => void,
): Promise<() => Promise<void>> {
return relayClient.subscribeLive(
{
kinds: [KIND_COMMUNITY_THEME],
authors: [this.pubkey],
"#d": [D_TAG],
limit: 0,
},
(event: RelayEvent) => {
if (event.pubkey !== this.pubkey || this.destroyed) return;
const processing = decryptAndParse(event).then((remote) => {
if (!remote || this.destroyed) return;
if (
isNewerCommunityThemeCoordinate(remote, {
createdAt: this.lastRemoteCreatedAt,
eventId: this.lastRemoteEventId,
})
) {
this.lastRemoteCreatedAt = remote.createdAt;
this.lastRemoteEventId = remote.eventId;
}
onUpdate(remote);
});
this.trackRemoteProcessing(processing);
},
);
}
destroy(): void {
this.destroyed = true;
this.cancelPendingPublish();
}
}