mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Kenny Lopez
parent
19b476bae5
commit
be34016c7d
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user