fix(theme): isolate migration state from user intent

Keep the immutable pre-migration appearance snapshot separate from the latest explicit glass choice used to seed genuinely absent communities. Legacy records can no longer inherit another community’s edited appearance.

Store automatic legacy-record upgrades in a migration-only outbox. A newer complete relay coordinate cancels that upgrade, while genuine user edits remain durable and authoritative.

Co-authored-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
This commit is contained in:
Princess Donut
2026-08-14 18:49:46 +01:00
co-authored by Kenny Lopez
parent 19b476bae5
commit be34016c7d
3 changed files with 206 additions and 51 deletions
@@ -5,6 +5,7 @@ import { useIdentityQuery } from "@/shared/api/hooks";
import {
cacheAndApplyCommunityTheme,
captureCommunityThemeAppearanceSnapshot,
clearCommunityThemeMigrationOutbox,
clearCommunityThemeOutbox,
communityThemeAppearanceFallback,
communityThemeApplyExpectation,
@@ -12,10 +13,13 @@ import {
communityThemeScopeFallback,
hasMigratedCommunityTheme,
markCommunityThemeMigrated,
readCommunityThemeCurrentAppearance,
readCommunityThemeMigrationOutbox,
readCommunityThemeOutbox,
readCommunityThemePreference,
refreshCommunityThemeAppearanceSnapshot,
refreshCommunityThemeCurrentAppearance,
sameCommunityThemePreference,
writeCommunityThemeMigrationOutbox,
writeCommunityThemeOutbox,
writeCommunityThemePreference,
type CommunityThemeAppearance,
@@ -92,13 +96,17 @@ export function CommunityThemeController() {
initialPreferenceRef.current,
);
appearanceSnapshotRef.current = snapshot;
const currentAppearance = readCommunityThemeCurrentAppearance(
pubkey,
snapshot,
);
const appearanceFallback = communityThemeAppearanceFallback(snapshot);
const scopeFallback: CommunityThemePreference = {
...communityThemeScopeFallback(
hasMigratedCommunityTheme(pubkey),
initialPreferenceRef.current,
),
...snapshot,
...currentAppearance,
};
const local = readCommunityThemePreference(
pubkey,
@@ -135,6 +143,10 @@ export function CommunityThemeController() {
// the correct in-session value.
const snapshot = appearanceSnapshotRef.current;
const appearanceFallback = communityThemeAppearanceFallback(snapshot);
const currentAppearance = readCommunityThemeCurrentAppearance(
pubkey,
snapshot ?? appearanceFallback,
);
const local = readCommunityThemePreference(
pubkey,
relayUrl,
@@ -146,15 +158,15 @@ export function CommunityThemeController() {
appearanceFallback,
);
// A value already cached for this relay is the safest fallback for a later
// incomplete event. Without one, use the durable appearance snapshot, which
// holds the profile's pre-migration glass and tab choices for every scope.
// incomplete event. Without one, use the immutable pre-migration snapshot,
// never the mutable current-choice fallback used only for absent scopes.
const legacyFallback = durablePending ?? local ?? appearanceFallback;
const scopeFallback: CommunityThemePreference = {
...communityThemeScopeFallback(
hasMigratedCommunityTheme(pubkey),
initialPreferenceRef.current,
),
...snapshot,
...currentAppearance,
};
scopeRef.current = scope;
lastRemoteRef.current = { createdAt: 0, eventId: "" };
@@ -174,11 +186,21 @@ export function CommunityThemeController() {
published.preference,
legacyFallback,
);
clearCommunityThemeMigrationOutbox(pubkey, relayUrl);
},
legacyFallback,
);
managerRef.current = manager;
if (durablePending) manager.publish(durablePending);
if (durablePending) {
manager.publish(durablePending);
} else {
const migrationPending = readCommunityThemeMigrationOutbox(
pubkey,
relayUrl,
legacyFallback,
);
if (migrationPending) manager.publish(migrationPending);
}
const applyRemote = (remote: RemoteCommunityTheme) => {
if (scopeRef.current !== scope) return;
@@ -196,6 +218,10 @@ export function CommunityThemeController() {
manager.publish(dirty);
return;
}
// Migration-only upgrades never outrank relay state. A newer complete
// coordinate cancels the pending upgrade rather than being overwritten by
// a legacy-derived republish two seconds later.
clearCommunityThemeMigrationOutbox(pubkey, relayUrl);
scopedPreferenceRef.current = remote.preference;
manager.cancelPendingPublish();
cacheAndApplyCommunityTheme(
@@ -206,7 +232,7 @@ export function CommunityThemeController() {
);
if (
remote.needsUpgrade &&
writeCommunityThemeOutbox(pubkey, relayUrl, remote.preference)
writeCommunityThemeMigrationOutbox(pubkey, relayUrl, remote.preference)
) {
manager.publish(remote.preference);
}
@@ -290,13 +316,11 @@ export function CommunityThemeController() {
communityThemeAppearanceFallback(appearanceSnapshotRef.current),
);
if (stored && sameCommunityThemePreference(stored, preference)) return;
// A genuine user edit that changes glass/opacity/prominent-tab is the
// user's current profile-wide appearance intent, so refresh the snapshot
// that seeds no-record communities. Without this the snapshot stays frozen
// at the pre-migration value and a later empty community resurrects it over
// the user's current choice. Only a changed appearance refreshes it, so an
// accent- or theme-only edit never leaks this community's glass into the
// profile-wide seed.
// A genuine user edit that changes glass/opacity/prominent-tab updates the
// profile-wide fallback used only for a genuinely absent community. Keep
// that value separate from the immutable pre-migration snapshot used to
// hydrate older three-field records, or one community's explicit choice
// contaminates every still-legacy community.
const priorScoped = scopedPreferenceRef.current;
if (
!priorScoped ||
@@ -304,10 +328,7 @@ export function CommunityThemeController() {
priorScoped.glassOpacity !== preference.glassOpacity ||
priorScoped.prominentActiveTab !== preference.prominentActiveTab
) {
appearanceSnapshotRef.current = refreshCommunityThemeAppearanceSnapshot(
pubkey,
preference,
);
refreshCommunityThemeCurrentAppearance(pubkey, preference);
}
scopedPreferenceRef.current = preference;
if (!writeCommunityThemePreference(pubkey, relayUrl, preference)) return;
@@ -8,16 +8,20 @@ import {
clearCommunityThemeOutbox,
communityThemeAppearanceFallback,
communityThemeApplyExpectation,
communityThemeMigrationOutboxKey,
communityThemeOutboxKey,
communityThemePersistenceAction,
communityThemeScopeFallback,
communityThemeStorageKey,
parseCommunityThemePreference,
readCommunityThemeAppearanceSnapshot,
readCommunityThemeCurrentAppearance,
readCommunityThemeMigrationOutbox,
readCommunityThemeOutbox,
readCommunityThemePreference,
refreshCommunityThemeAppearanceSnapshot,
refreshCommunityThemeCurrentAppearance,
sameCommunityThemePreference,
writeCommunityThemeMigrationOutbox,
writeCommunityThemeOutbox,
writeCommunityThemePreference,
} from "./communityThemePreference.ts";
@@ -283,11 +287,9 @@ test("a full store pins the snapshot across a controller remount", () => {
);
});
test("a user glass edit refreshes the snapshot for no-record communities", () => {
// The initial snapshot is frozen at the pre-migration appearance. Once the
// user changes glass, that former value is stale: a no-record community must
// inherit the new choice, not resurrect the old one. refresh overwrites the
// durable snapshot so the empty-scope fallback tracks the current choice.
test("a user glass edit updates absent scopes without changing the legacy snapshot", () => {
// Empty communities track the latest explicit choice, but a legacy record
// must always inherit the immutable pre-migration appearance.
globalThis.window = { localStorage: localStorageStub() };
const preMigration = {
...DEFAULT_COMMUNITY_THEME,
@@ -299,11 +301,6 @@ test("a user glass edit refreshes the snapshot for no-record communities", () =>
"edit-pk",
preMigration,
);
assert.deepEqual(pinned, {
glassBackground: true,
glassOpacity: 80,
prominentActiveTab: true,
});
const edited = {
...DEFAULT_COMMUNITY_THEME,
@@ -311,17 +308,34 @@ test("a user glass edit refreshes the snapshot for no-record communities", () =>
glassOpacity: 40,
prominentActiveTab: false,
};
const refreshed = refreshCommunityThemeAppearanceSnapshot("edit-pk", edited);
assert.deepEqual(refreshed, {
const current = refreshCommunityThemeCurrentAppearance("edit-pk", edited);
assert.deepEqual(current, {
glassBackground: false,
glassOpacity: 40,
prominentActiveTab: false,
});
// A subsequent capture (a later community mount) sees the refreshed value,
// so the no-record fallback carries the user's current choice.
assert.deepEqual(
captureCommunityThemeAppearanceSnapshot("edit-pk", preMigration),
refreshed,
readCommunityThemeCurrentAppearance("edit-pk", pinned),
current,
);
// The next mount still sees the original snapshot for an older payload.
assert.deepEqual(
captureCommunityThemeAppearanceSnapshot("edit-pk", edited),
pinned,
);
const legacy = {
version: 1,
theme: "houston",
accent: "#a855f7",
followSystem: false,
};
assert.deepEqual(
parseCommunityThemePreference(
legacy,
communityThemeAppearanceFallback(pinned),
),
{ ...legacy, ...pinned },
);
});
@@ -436,6 +450,40 @@ test("dirty outbox survives restart and clears only its exact revision", () => {
);
});
test("migration upgrades are isolated from user edits", () => {
globalThis.window = { localStorage: localStorageStub() };
const migration = { ...DEFAULT_COMMUNITY_THEME, theme: "houston" };
const userEdit = { ...DEFAULT_COMMUNITY_THEME, accent: "#ef4444" };
assert.equal(
writeCommunityThemeMigrationOutbox("alice", "wss://a.example", migration),
true,
);
assert.deepEqual(
readCommunityThemeMigrationOutbox("alice", "wss://a.example"),
migration,
);
assert.equal(readCommunityThemeOutbox("alice", "wss://a.example"), null);
// A genuine edit supersedes and clears the migration-only upgrade.
assert.equal(
writeCommunityThemeOutbox("alice", "wss://a.example", userEdit),
true,
);
assert.equal(
readCommunityThemeMigrationOutbox("alice", "wss://a.example"),
null,
);
assert.deepEqual(
readCommunityThemeOutbox("alice", "wss://a.example"),
userEdit,
);
assert.notEqual(
communityThemeMigrationOutboxKey("alice", "wss://a.example"),
communityThemeOutboxKey("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");
@@ -11,9 +11,12 @@ 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_OUTBOX_KEY_PREFIX = "buzz-community-theme-migration-outbox.v1";
const MIGRATION_KEY_PREFIX = "buzz-community-theme-migrated.v1";
const APPEARANCE_SNAPSHOT_KEY_PREFIX =
"buzz-community-theme-appearance-snapshot.v1";
const CURRENT_APPEARANCE_KEY_PREFIX =
"buzz-community-theme-current-appearance.v1";
export type CommunityThemePreference = {
version: 1;
@@ -59,6 +62,13 @@ export function communityThemeOutboxKey(
return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
export function communityThemeMigrationOutboxKey(
pubkey: string,
relayUrl: string,
): string {
return `${MIGRATION_OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
export function parseCommunityThemePreference(
value: unknown,
legacyFallback: CommunityThemePreference = DEFAULT_COMMUNITY_THEME,
@@ -150,12 +160,71 @@ export function writeCommunityThemeOutbox(
communityThemeOutboxKey(pubkey, relayUrl),
JSON.stringify(preference),
);
clearCommunityThemeMigrationOutbox(pubkey, relayUrl);
return true;
} catch {
return false;
}
}
export function readCommunityThemeMigrationOutbox(
pubkey: string,
relayUrl: string,
legacyFallback: CommunityThemePreference = DEFAULT_COMMUNITY_THEME,
): CommunityThemePreference | null {
try {
const raw = window.localStorage.getItem(
communityThemeMigrationOutboxKey(pubkey, relayUrl),
);
return raw
? parseCommunityThemePreference(JSON.parse(raw), legacyFallback)
: null;
} catch {
return null;
}
}
export function writeCommunityThemeMigrationOutbox(
pubkey: string,
relayUrl: string,
preference: CommunityThemePreference,
): boolean {
try {
window.localStorage.setItem(
communityThemeMigrationOutboxKey(pubkey, relayUrl),
JSON.stringify(preference),
);
return true;
} catch {
return false;
}
}
export function clearCommunityThemeMigrationOutbox(
pubkey: string,
relayUrl: string,
acknowledged?: CommunityThemePreference,
legacyFallback: CommunityThemePreference = DEFAULT_COMMUNITY_THEME,
): void {
if (acknowledged) {
const pending = readCommunityThemeMigrationOutbox(
pubkey,
relayUrl,
legacyFallback,
);
if (!pending || !sameCommunityThemePreference(pending, acknowledged)) {
return;
}
}
try {
window.localStorage.removeItem(
communityThemeMigrationOutboxKey(pubkey, relayUrl),
);
} catch {
// A later remote can safely cancel the migration-only upgrade again.
}
}
export function clearCommunityThemeOutbox(
pubkey: string,
relayUrl: string,
@@ -194,6 +263,10 @@ export function communityThemeAppearanceSnapshotKey(pubkey: string): string {
return `${APPEARANCE_SNAPSHOT_KEY_PREFIX}:${pubkey}`;
}
export function communityThemeCurrentAppearanceKey(pubkey: string): string {
return `${CURRENT_APPEARANCE_KEY_PREFIX}:${pubkey}`;
}
function parseCommunityThemeAppearance(
value: unknown,
): CommunityThemeAppearance | null {
@@ -229,6 +302,7 @@ export function readCommunityThemeAppearanceSnapshot(
}
const inMemoryAppearanceSnapshots = new Map<string, CommunityThemeAppearance>();
const inMemoryCurrentAppearances = new Map<string, CommunityThemeAppearance>();
/**
* Persist the profile's pre-migration appearance the first time it is seen and
@@ -270,35 +344,47 @@ export function captureCommunityThemeAppearanceSnapshot(
}
/**
* Overwrite the profile's appearance snapshot with a later explicit user glass
* choice. The initial snapshot is frozen at the pre-migration appearance, but
* once the user changes glass/opacity/prominent-tab that former value is stale:
* a no-record community would otherwise resurrect it and republish it over the
* user's current choice. Callers refresh only on a genuine user edit, never on
* a programmatic per-community apply, so a community's own record can never
* leak into the profile-wide seed.
* Persist the latest explicit user glass choice separately from the immutable
* migration snapshot. Empty communities may inherit this current choice, while
* legacy records must continue to inherit the original pre-migration value.
*/
export function refreshCommunityThemeAppearanceSnapshot(
export function refreshCommunityThemeCurrentAppearance(
pubkey: string,
appearance: CommunityThemeAppearance,
): CommunityThemeAppearance {
const snapshot: CommunityThemeAppearance = {
const current: CommunityThemeAppearance = {
glassBackground: appearance.glassBackground,
glassOpacity: appearance.glassOpacity,
prominentActiveTab: appearance.prominentActiveTab,
};
try {
window.localStorage.setItem(
communityThemeAppearanceSnapshotKey(pubkey),
JSON.stringify(snapshot),
communityThemeCurrentAppearanceKey(pubkey),
JSON.stringify(current),
);
inMemoryAppearanceSnapshots.delete(pubkey);
inMemoryCurrentAppearances.delete(pubkey);
} catch {
// Retain the refreshed value in memory when the store is full, mirroring
// the capture path so the current choice still survives a remount.
inMemoryAppearanceSnapshots.set(pubkey, snapshot);
inMemoryCurrentAppearances.set(pubkey, current);
}
return snapshot;
return current;
}
export function readCommunityThemeCurrentAppearance(
pubkey: string,
fallback: CommunityThemeAppearance,
): CommunityThemeAppearance {
try {
const raw = window.localStorage.getItem(
communityThemeCurrentAppearanceKey(pubkey),
);
if (raw) {
const current = parseCommunityThemeAppearance(JSON.parse(raw));
if (current) return current;
}
} catch {
// Fall through to the in-memory current choice or immutable snapshot.
}
return inMemoryCurrentAppearances.get(pubkey) ?? fallback;
}
export function writeCommunityThemePreference(