From 2d8093057a5a1b2b86bd5804d3f5b4ddf268b009 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 9 Aug 2026 19:52:53 -0600 Subject: [PATCH 1/5] Bound local storage collections Co-authored-by: Meeseeks <2e96988f190ed1bd3c568760103aa4cadb2bc6195b832e252c984392c89039bd@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../channels/forcedUnreadStore.test.mjs | 18 +++++++ .../features/channels/forcedUnreadStore.ts | 17 +++++- .../communities/communityIconCache.test.mjs | 25 +++++++++ .../communities/communityIconCache.ts | 23 ++++++-- .../lib/persistentAgentAudience.test.mjs | 24 +++++++++ .../messages/lib/persistentAgentAudience.ts | 19 ++++++- .../profile/lib/selfProfileStorage.test.mjs | 51 ++++++++++++++++++ .../profile/lib/selfProfileStorage.ts | 45 ++++++++++++++++ .../sidebar/lib/channelMutesStorage.test.mjs | 47 ++++++++++++++++ .../sidebar/lib/channelMutesStorage.ts | 23 ++++++-- .../lib/channelSectionsStorage.test.mjs | 31 +++++++++++ .../sidebar/lib/channelSectionsStorage.ts | 34 ++++++++++-- .../lib/channelSortPreference.test.mjs | 21 ++++++++ .../sidebar/lib/channelSortPreference.ts | 22 +++++++- .../sidebar/lib/channelStarsStorage.test.mjs | 53 +++++++++++++++++++ .../sidebar/lib/channelStarsStorage.ts | 23 ++++++-- .../features/sidebar/lib/useChannelMutes.ts | 5 +- .../sidebar/lib/useChannelSections.ts | 9 ++-- .../sidebar/lib/useChannelSortPreference.ts | 9 ++-- .../features/sidebar/lib/useChannelStars.ts | 5 +- desktop/src/shared/features/store.test.mjs | 28 ++++++++++ desktop/src/shared/features/store.ts | 16 +++++- 22 files changed, 517 insertions(+), 31 deletions(-) create mode 100644 desktop/src/features/communities/communityIconCache.test.mjs create mode 100644 desktop/src/shared/features/store.test.mjs diff --git a/desktop/src/features/channels/forcedUnreadStore.test.mjs b/desktop/src/features/channels/forcedUnreadStore.test.mjs index cea1c3babe..1741efbd7e 100644 --- a/desktop/src/features/channels/forcedUnreadStore.test.mjs +++ b/desktop/src/features/channels/forcedUnreadStore.test.mjs @@ -3,7 +3,9 @@ import test from "node:test"; import { addForcedUnreadSource, + boundForcedUnreadMap, forcedUnreadMarker, + MAX_FORCED_UNREAD_ENTRIES, removeForcedUnreadSource, } from "./forcedUnreadStore.ts"; @@ -49,6 +51,22 @@ test("clearing the only force owner removes the entry", () => { assert.equal(removeForcedUnreadSource(entry, "inbox"), undefined); }); +test("forced unread map keeps the newest 500 insertion-ordered entries", () => { + const map = Object.fromEntries( + Array.from({ length: MAX_FORCED_UNREAD_ENTRIES + 2 }, (_, index) => [ + `channel-${index}`, + index, + ]), + ); + + const bounded = boundForcedUnreadMap(map); + + assert.equal(Object.keys(bounded).length, MAX_FORCED_UNREAD_ENTRIES); + assert.equal(bounded["channel-0"], undefined); + assert.equal(bounded["channel-1"], undefined); + assert.equal(bounded[`channel-${MAX_FORCED_UNREAD_ENTRIES + 1}`], 501); +}); + test("legacy persisted entries retain their read-marker baseline", () => { assert.equal(forcedUnreadMarker(120), 120); assert.equal(forcedUnreadMarker(null), null); diff --git a/desktop/src/features/channels/forcedUnreadStore.ts b/desktop/src/features/channels/forcedUnreadStore.ts index c4c25dcecb..e2b626042a 100644 --- a/desktop/src/features/channels/forcedUnreadStore.ts +++ b/desktop/src/features/channels/forcedUnreadStore.ts @@ -74,8 +74,16 @@ export function removeForcedUnreadSource( } const STORAGE_PREFIX = "buzz-forced-unread.v1"; +export const MAX_FORCED_UNREAD_ENTRIES = 500; const storageKey = (pubkey: string) => `${STORAGE_PREFIX}:${pubkey}`; +export function boundForcedUnreadMap(map: ForcedUnreadMap): ForcedUnreadMap { + const entries = Object.entries(map); + return entries.length <= MAX_FORCED_UNREAD_ENTRIES + ? map + : Object.fromEntries(entries.slice(-MAX_FORCED_UNREAD_ENTRIES)); +} + export const forcedUnreadStore = { read(pubkey: string): ForcedUnreadMap { try { @@ -113,14 +121,17 @@ export const forcedUnreadStore = { } } } - return result; + return boundForcedUnreadMap(result); } catch { return {}; } }, write(pubkey: string, map: ForcedUnreadMap): void { try { - window.localStorage.setItem(storageKey(pubkey), JSON.stringify(map)); + window.localStorage.setItem( + storageKey(pubkey), + JSON.stringify(boundForcedUnreadMap(map)), + ); } catch { // Ignore storage errors (private browsing, quota exceeded). } @@ -148,7 +159,9 @@ export function useForcedUnreadActions( source, ); if (next === current) return; + delete forcedUnreadRef.current[channelId]; forcedUnreadRef.current[channelId] = next; + forcedUnreadRef.current = boundForcedUnreadMap(forcedUnreadRef.current); persist(); }, [forcedUnreadRef, getOwnTimestamp, persist], diff --git a/desktop/src/features/communities/communityIconCache.test.mjs b/desktop/src/features/communities/communityIconCache.test.mjs new file mode 100644 index 0000000000..ef7e78ca03 --- /dev/null +++ b/desktop/src/features/communities/communityIconCache.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + boundCommunityIconCache, + MAX_CACHED_COMMUNITY_ICON_LENGTH, + MAX_CACHED_COMMUNITY_ICONS, +} from "./communityIconCache.ts"; + +test("community icon cache caps entries and rejects oversized icons", () => { + const cache = Object.fromEntries( + Array.from({ length: MAX_CACHED_COMMUNITY_ICONS + 1 }, (_, index) => [ + `relay-${index}`, + `icon-${index}`, + ]), + ); + cache.oversized = "x".repeat(MAX_CACHED_COMMUNITY_ICON_LENGTH + 1); + + const bounded = boundCommunityIconCache(cache); + + assert.equal(Object.keys(bounded).length, MAX_CACHED_COMMUNITY_ICONS); + assert.equal(bounded["relay-0"], undefined); + assert.equal(bounded.oversized, undefined); + assert.equal(bounded[`relay-${MAX_CACHED_COMMUNITY_ICONS}`], "icon-32"); +}); diff --git a/desktop/src/features/communities/communityIconCache.ts b/desktop/src/features/communities/communityIconCache.ts index 704b89ed27..5e55c0552e 100644 --- a/desktop/src/features/communities/communityIconCache.ts +++ b/desktop/src/features/communities/communityIconCache.ts @@ -5,13 +5,26 @@ */ const ICON_CACHE_KEY = "buzz-community-icons"; +export const MAX_CACHED_COMMUNITY_ICONS = 32; +export const MAX_CACHED_COMMUNITY_ICON_LENGTH = 64 * 1024; + +export function boundCommunityIconCache( + cache: Record, +): Record { + const entries = Object.entries(cache).filter( + ([, icon]) => + typeof icon === "string" && + icon.length <= MAX_CACHED_COMMUNITY_ICON_LENGTH, + ); + return Object.fromEntries(entries.slice(-MAX_CACHED_COMMUNITY_ICONS)); +} function loadCache(): Record { try { const raw = localStorage.getItem(ICON_CACHE_KEY); const parsed: unknown = raw ? JSON.parse(raw) : null; if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; + return boundCommunityIconCache(parsed as Record); } } catch { // Corrupt cache — fall through to empty. @@ -28,13 +41,17 @@ export function saveCachedCommunityIcon( icon: string | null, ): void { const cache = loadCache(); - if (icon) { + if (icon && icon.length <= MAX_CACHED_COMMUNITY_ICON_LENGTH) { + delete cache[relayUrl]; cache[relayUrl] = icon; } else { delete cache[relayUrl]; } try { - localStorage.setItem(ICON_CACHE_KEY, JSON.stringify(cache)); + localStorage.setItem( + ICON_CACHE_KEY, + JSON.stringify(boundCommunityIconCache(cache)), + ); } catch { // Quota exceeded — the icon still renders from the in-memory query. } diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs index eae840a40e..a45c02290b 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs +++ b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs @@ -207,6 +207,30 @@ test("new recipients retain explicit mention order", async () => { assert.deepEqual(savedAudiences(), { [scope]: [agentB, agentA] }); }); +test("persistent audiences retain only the 200 most recently touched scopes", async () => { + const store = await loadStore(11); + for ( + let index = 0; + index < store.MAX_PERSISTENT_AGENT_AUDIENCES + 2; + index++ + ) { + store.setPersistentAgentAudience(`scope-${index}`, [agentA]); + } + + const saved = savedAudiences(); + assert.equal(Object.keys(saved).length, store.MAX_PERSISTENT_AGENT_AUDIENCES); + assert.equal(saved["scope-0"], undefined); + assert.equal(saved["scope-1"], undefined); + assert.deepEqual(saved["scope-201"], [agentA]); + + store.setPersistentAgentAudience("scope-2", [agentB]); + store.setPersistentAgentAudience("scope-new", [agentC]); + const retouched = savedAudiences(); + assert.equal(retouched["scope-3"], undefined); + assert.deepEqual(retouched["scope-2"], [agentB]); + assert.deepEqual(retouched["scope-new"], [agentC]); +}); + test("timeline scope is intentionally unsupported", async () => { const store = await loadStore(7); assert.equal( diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.ts b/desktop/src/features/messages/lib/persistentAgentAudience.ts index d57b485422..9493226cd7 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.ts +++ b/desktop/src/features/messages/lib/persistentAgentAudience.ts @@ -2,6 +2,7 @@ import * as React from "react"; const ENABLED_STORAGE_KEY = "buzz:keep-addressed-agents-active"; const AUDIENCES_STORAGE_KEY = "buzz:persistent-agent-audiences:v2"; +export const MAX_PERSISTENT_AGENT_AUDIENCES = 200; const listeners = new Set<() => void>(); const revisions = new Map(); @@ -39,6 +40,15 @@ function readEnabled(): boolean { } } +function boundAudiences( + value: Record, +): Record { + const entries = Object.entries(value); + return entries.length <= MAX_PERSISTENT_AGENT_AUDIENCES + ? value + : Object.fromEntries(entries.slice(-MAX_PERSISTENT_AGENT_AUDIENCES)); +} + function readAudiences(): Record { if (typeof window === "undefined") return {}; try { @@ -56,7 +66,7 @@ function readAudiences(): Record { ); } } - return result; + return boundAudiences(result); } catch { return {}; } @@ -148,7 +158,12 @@ export function setPersistentAgentAudience( return; } - audiences = { ...audiences, [scope]: normalized }; + const nextAudiences = { ...audiences }; + delete nextAudiences[scope]; + audiences = boundAudiences({ ...nextAudiences, [scope]: normalized }); + for (const revisedScope of revisions.keys()) { + if (!Object.hasOwn(audiences, revisedScope)) revisions.delete(revisedScope); + } advanceRevision(scope); persistAudiences(); emit(); diff --git a/desktop/src/features/profile/lib/selfProfileStorage.test.mjs b/desktop/src/features/profile/lib/selfProfileStorage.test.mjs index 6df7a5f976..1e6b6ee114 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.test.mjs +++ b/desktop/src/features/profile/lib/selfProfileStorage.test.mjs @@ -3,9 +3,12 @@ import test from "node:test"; import { parseSelfProfileCache, + MAX_SELF_PROFILE_CACHES, + MAX_SELF_PROFILE_CACHES_PER_RELAY, resolveAvatarDataUrl, shouldFetchAvatar, storageKey, + writeSelfProfileCache, } from "./selfProfileStorage.ts"; test("storageKey: includes pubkey in result", () => { @@ -48,6 +51,54 @@ test("storageKey: different pubkeys produce different keys", () => { assert.notEqual(a, b); }); +function installStorage() { + const values = new Map(); + globalThis.window = { + dispatchEvent: () => true, + localStorage: { + get length() { + return values.size; + }, + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, String(value)), + }, + }; + globalThis.CustomEvent ??= class CustomEvent {}; + return values; +} + +test("writeSelfProfileCache caps each relay and the global cache by updatedAt", () => { + const values = installStorage(); + const relayA = "wss://relay-a.example"; + for (let index = 0; index < MAX_SELF_PROFILE_CACHES_PER_RELAY + 1; index++) { + assert.equal( + writeSelfProfileCache( + relayA, + `pubkey-${index}`, + makeCache({ updatedAt: index }), + ), + true, + ); + } + assert.equal(values.has(storageKey(relayA, "pubkey-0")), false); + assert.equal(values.has(storageKey(relayA, "pubkey-8")), true); + + for (let index = 0; index < MAX_SELF_PROFILE_CACHES + 1; index++) { + writeSelfProfileCache( + `wss://relay-${index}.example`, + `global-${index}`, + makeCache({ updatedAt: index + 100 }), + ); + } + const profileKeys = [...values.keys()].filter((key) => + key.startsWith("buzz-self-profile.v1:"), + ); + assert.equal(profileKeys.length, MAX_SELF_PROFILE_CACHES); + assert.equal(values.has(storageKey(relayA, "pubkey-1")), false); +}); + test("parseSelfProfileCache: valid v1 payload round-trips", () => { const payload = { version: 1, diff --git a/desktop/src/features/profile/lib/selfProfileStorage.ts b/desktop/src/features/profile/lib/selfProfileStorage.ts index 02e083ae1d..a871cb0f20 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.ts +++ b/desktop/src/features/profile/lib/selfProfileStorage.ts @@ -15,6 +15,8 @@ export { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; const STORAGE_KEY_PREFIX = "buzz-self-profile.v1"; +export const MAX_SELF_PROFILE_CACHES_PER_RELAY = 8; +export const MAX_SELF_PROFILE_CACHES = 32; /** * Dispatched on window after a successful writeSelfProfileCache so that any @@ -127,6 +129,48 @@ export function readSelfProfileCache( } } +function trimSelfProfileCaches(relayUrl: string, preservedKey: string): void { + const relayPrefix = `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:`; + const entries: Array<{ key: string; updatedAt: number }> = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (!key?.startsWith(`${STORAGE_KEY_PREFIX}:`)) continue; + const raw = window.localStorage.getItem(key); + if (!raw) continue; + try { + entries.push({ + key, + updatedAt: parseSelfProfileCache(JSON.parse(raw))?.updatedAt ?? 0, + }); + } catch { + entries.push({ key, updatedAt: 0 }); + } + } + const relayEntries = entries.filter((entry) => + entry.key.startsWith(relayPrefix), + ); + const keysToRemove = new Set(); + for (const candidates of [relayEntries, entries]) { + const maxEntries = + candidates === relayEntries + ? MAX_SELF_PROFILE_CACHES_PER_RELAY + : MAX_SELF_PROFILE_CACHES; + const removable = candidates + .filter((entry) => entry.key !== preservedKey) + .sort((left, right) => left.updatedAt - right.updatedAt); + let removeCount = + candidates.filter((entry) => !keysToRemove.has(entry.key)).length - + maxEntries; + for (const entry of removable) { + if (removeCount <= 0) break; + if (keysToRemove.has(entry.key)) continue; + keysToRemove.add(entry.key); + removeCount -= 1; + } + } + for (const key of keysToRemove) window.localStorage.removeItem(key); +} + /** * Writes the cache to localStorage and fires SELF_PROFILE_CACHE_EVENT so * mounted components can re-read without polling. @@ -146,6 +190,7 @@ export function writeSelfProfileCache( // when nothing changed. Skip the write and event entirely when identical. if (window.localStorage.getItem(key) === serialized) return true; window.localStorage.setItem(key, serialized); + trimSelfProfileCaches(relayUrl, key); // localStorage is not reactive — dispatch a custom event so any mounted // listeners (e.g. useEffect with addEventListener) can re-read the cache // without a polling interval. diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs index df5ea855f9..f509a35798 100644 --- a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + boundMuteStore, + MAX_CHANNEL_MUTE_ENTRIES, parseMutePayload, mergeStores, mutedChannelIdsFromStore, @@ -208,6 +210,51 @@ test("mergeStores: both empty returns empty", () => { assert.deepEqual(result, { version: 1, channels: {} }); }); +test("boundMuteStore: caps entries and evicts false tombstones before active mutes", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ + `active-${index}`, + { muted: true, updatedAt: index }, + ]), + ); + channels["old-false"] = { muted: false, updatedAt: -1 }; + channels["new-false"] = { muted: false, updatedAt: 9999 }; + + const result = boundMuteStore({ version: 1, channels }); + + assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_MUTE_ENTRIES); + assert.equal(result.channels["old-false"], undefined); + assert.equal(result.channels["new-false"], undefined); + assert.deepEqual(result.channels["active-0"], { muted: true, updatedAt: 0 }); +}); + +test("mergeStores: evicted remote ID re-enters LWW merge and a tombstone is re-trimmed", () => { + const localChannels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ + `active-${index}`, + { muted: true, updatedAt: index + 10 }, + ]), + ); + const result = mergeStores( + { version: 1, channels: localChannels }, + { + version: 1, + channels: { + "evicted-id": { muted: true, updatedAt: 9999 }, + "active-0": { muted: false, updatedAt: 9998 }, + }, + }, + ); + + assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_MUTE_ENTRIES); + assert.deepEqual(result.channels["evicted-id"], { + muted: true, + updatedAt: 9999, + }); + assert.equal(result.channels["active-0"], undefined); + assert.deepEqual(result.channels["active-1"], { muted: true, updatedAt: 11 }); +}); + // ── mutedChannelIdsFromStore ────────────────────────────────────────────────── test("mutedChannelIdsFromStore: returns set of IDs where muted=true", () => { diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.ts b/desktop/src/features/sidebar/lib/channelMutesStorage.ts index 098a34c2f8..37090f9b1b 100644 --- a/desktop/src/features/sidebar/lib/channelMutesStorage.ts +++ b/desktop/src/features/sidebar/lib/channelMutesStorage.ts @@ -1,4 +1,5 @@ const STORAGE_KEY_PREFIX = "buzz-channel-mutes.v1"; +export const MAX_CHANNEL_MUTE_ENTRIES = 500; export type ChannelMuteEntry = { muted: boolean; @@ -45,7 +46,7 @@ export function parseMutePayload(json: unknown): ChannelMuteStore | null { ), ) : {}; - return { version: 1, channels }; + return boundMuteStore({ version: 1, channels }); } export function readChannelMutesStore(pubkey: string): ChannelMuteStore { @@ -64,12 +65,28 @@ export function readChannelMutesStore(pubkey: string): ChannelMuteStore { } } +export function boundMuteStore(store: ChannelMuteStore): ChannelMuteStore { + const entries = Object.entries(store.channels); + if (entries.length <= MAX_CHANNEL_MUTE_ENTRIES) return store; + entries.sort(([, left], [, right]) => { + if (left.muted !== right.muted) return left.muted ? 1 : -1; + return left.updatedAt - right.updatedAt; + }); + return { + ...store, + channels: Object.fromEntries(entries.slice(-MAX_CHANNEL_MUTE_ENTRIES)), + }; +} + export function writeChannelMutesStore( pubkey: string, store: ChannelMuteStore, ): boolean { try { - window.localStorage.setItem(storageKey(pubkey), JSON.stringify(store)); + window.localStorage.setItem( + storageKey(pubkey), + JSON.stringify(boundMuteStore(store)), + ); return true; } catch { return false; @@ -94,7 +111,7 @@ export function mergeStores( merged[id] = (l ?? r) as ChannelMuteEntry; } } - return { version: 1, channels: merged }; + return boundMuteStore({ version: 1, channels: merged }); } export function mutedChannelIdsFromStore(store: ChannelMuteStore): Set { diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs index 70a72e4b9c..f28108e5b5 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + boundChannelSectionsStore, DEFAULT_STORE, + MAX_CHANNEL_SECTION_ASSIGNMENTS, + MAX_CHANNEL_SECTIONS, parseChannelSectionPayload, readChannelSectionsStore, storageKey, @@ -152,6 +155,34 @@ test("stripOrphanedAssignments: empty store returns same reference", () => { assert.equal(stripOrphanedAssignments(store), store); }); +test("boundChannelSectionsStore caps sections and assignments", () => { + const sections = Array.from( + { length: MAX_CHANNEL_SECTIONS + 1 }, + (_, index) => makeSection({ id: `section-${index}`, order: index }), + ); + const assignments = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS + 1 }, (_, index) => [ + `channel-${index}`, + "section-100", + ]), + ); + + const bounded = boundChannelSectionsStore( + makeStore({ sections, assignments }), + ); + + assert.equal(bounded.sections.length, MAX_CHANNEL_SECTIONS); + assert.equal( + bounded.sections.some((section) => section.id === "section-0"), + false, + ); + assert.equal( + Object.keys(bounded.assignments).length, + MAX_CHANNEL_SECTION_ASSIGNMENTS, + ); + assert.equal(bounded.assignments["channel-0"], undefined); +}); + test("writeChannelSectionsStore + readChannelSectionsStore: write then read returns same data", () => { const pubkey = "pk-roundtrip"; const store = makeStore({ diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index 3900c40c18..f01154751b 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -1,6 +1,8 @@ import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1"; +export const MAX_CHANNEL_SECTIONS = 100; +export const MAX_CHANNEL_SECTION_ASSIGNMENTS = 1_000; export type ChannelSection = { id: string; @@ -37,6 +39,28 @@ export function storageKey(pubkey: string, relayUrl?: string): string { return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalized)}`; } +export function boundChannelSectionsStore( + store: ChannelSectionStore, +): ChannelSectionStore { + const sections = store.sections + .slice() + .sort((left, right) => left.order - right.order) + .slice(-MAX_CHANNEL_SECTIONS); + const sectionIds = new Set(sections.map((section) => section.id)); + const assignments = Object.fromEntries( + Object.entries(store.assignments) + .filter(([, sectionId]) => sectionIds.has(sectionId)) + .slice(-MAX_CHANNEL_SECTION_ASSIGNMENTS), + ); + if ( + sections.length === store.sections.length && + Object.keys(assignments).length === Object.keys(store.assignments).length + ) { + return store; + } + return { ...store, sections, assignments }; +} + export function stripOrphanedAssignments( store: ChannelSectionStore, ): ChannelSectionStore { @@ -44,9 +68,11 @@ export function stripOrphanedAssignments( const cleaned = Object.fromEntries( Object.entries(store.assignments).filter(([, sid]) => sectionIds.has(sid)), ); - if (Object.keys(cleaned).length === Object.keys(store.assignments).length) - return store; - return { ...store, assignments: cleaned }; + const stripped = + Object.keys(cleaned).length === Object.keys(store.assignments).length + ? store + : { ...store, assignments: cleaned }; + return boundChannelSectionsStore(stripped); } export function parseChannelSectionPayload( @@ -161,7 +187,7 @@ export function writeChannelSectionsStore( try { window.localStorage.setItem( storageKey(pubkey, relayUrl), - JSON.stringify(store), + JSON.stringify(boundChannelSectionsStore(store)), ); return true; } catch { diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs index 7f2b94656e..a7422d3a21 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs @@ -2,8 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + boundChannelSortStore, DEFAULT_SORT_MODE, DEFAULT_STORE, + MAX_CHANNEL_SORT_GROUPS, parseChannelSortPayload, sectionSortGroupKey, sortChannelsForSidebar, @@ -168,6 +170,25 @@ test("stripOrphanedSectionModes: does not mutate the input store", () => { assert.deepEqual(store.groups, { [sectionSortGroupKey("gone")]: "recent" }); }); +test("boundChannelSortStore caps custom sections while preserving fixed groups", () => { + const groups = { + channels: "recent", + ...Object.fromEntries( + Array.from({ length: MAX_CHANNEL_SORT_GROUPS }, (_, index) => [ + sectionSortGroupKey(String(index)), + "alpha", + ]), + ), + }; + + const bounded = boundChannelSortStore({ version: 1, groups }); + + assert.equal(Object.keys(bounded.groups).length, MAX_CHANNEL_SORT_GROUPS); + assert.equal(bounded.groups.channels, "recent"); + assert.equal(bounded.groups[sectionSortGroupKey("0")], undefined); + assert.equal(bounded.groups[sectionSortGroupKey("99")], "alpha"); +}); + // ── sortChannelsForSidebar ─────────────────────────────────────────────────── test("alpha: sorts case-insensitively with deterministic code-unit collation", () => { diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts index aa67ca3fb1..4de9768d84 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -2,6 +2,7 @@ import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; import type { Channel } from "@/shared/api/types"; const STORAGE_KEY_PREFIX = "buzz-channel-sort.v1"; +export const MAX_CHANNEL_SORT_GROUPS = 104; export type ChannelSortMode = "alpha" | "recent"; @@ -66,6 +67,23 @@ export function stripOrphanedSectionModes( return { ...store, groups: Object.fromEntries(kept) }; } +export function boundChannelSortStore( + store: ChannelSortStore, +): ChannelSortStore { + const entries = Object.entries(store.groups); + if (entries.length <= MAX_CHANNEL_SORT_GROUPS) return store; + const isFixedGroup = (key: string) => + key === "starred" || + key === "channels" || + key === "forums" || + key === "dms"; + const fixed = entries.filter(([key]) => isFixedGroup(key)); + const custom = entries + .filter(([key]) => !isFixedGroup(key)) + .slice(-(MAX_CHANNEL_SORT_GROUPS - fixed.length)); + return { ...store, groups: Object.fromEntries([...fixed, ...custom]) }; +} + export function parseChannelSortPayload( json: unknown, ): ChannelSortStore | null { @@ -83,7 +101,7 @@ export function parseChannelSortPayload( ), ) : {}; - return { version: 1, groups }; + return boundChannelSortStore({ version: 1, groups }); } export function readChannelSortStore( @@ -107,7 +125,7 @@ export function writeChannelSortStore( try { window.localStorage.setItem( storageKey(pubkey, relayUrl), - JSON.stringify(store), + JSON.stringify(boundChannelSortStore(store)), ); return true; } catch { diff --git a/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs index ea2368b218..c577fa8fc1 100644 --- a/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + boundStarStore, + MAX_CHANNEL_STAR_ENTRIES, parseStarPayload, mergeStores, starredChannelIdsFromStore, @@ -222,6 +224,57 @@ test("mergeStores: both empty returns empty", () => { assert.deepEqual(result, { version: 1, channels: {} }); }); +test("boundStarStore: caps entries and evicts false tombstones before active stars", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ + `active-${index}`, + { starred: true, updatedAt: index }, + ]), + ); + channels["old-false"] = { starred: false, updatedAt: -1 }; + channels["new-false"] = { starred: false, updatedAt: 9999 }; + + const result = boundStarStore({ version: 1, channels }); + + assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_STAR_ENTRIES); + assert.equal(result.channels["old-false"], undefined); + assert.equal(result.channels["new-false"], undefined); + assert.deepEqual(result.channels["active-0"], { + starred: true, + updatedAt: 0, + }); +}); + +test("mergeStores: evicted remote ID re-enters LWW merge and a tombstone is re-trimmed", () => { + const localChannels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ + `active-${index}`, + { starred: true, updatedAt: index + 10 }, + ]), + ); + const result = mergeStores( + { version: 1, channels: localChannels }, + { + version: 1, + channels: { + "evicted-id": { starred: true, updatedAt: 9999 }, + "active-0": { starred: false, updatedAt: 9998 }, + }, + }, + ); + + assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_STAR_ENTRIES); + assert.deepEqual(result.channels["evicted-id"], { + starred: true, + updatedAt: 9999, + }); + assert.equal(result.channels["active-0"], undefined); + assert.deepEqual(result.channels["active-1"], { + starred: true, + updatedAt: 11, + }); +}); + // ── starredChannelIdsFromStore ──────────────────────────────────────────────── test("starredChannelIdsFromStore: returns set of IDs where starred=true", () => { diff --git a/desktop/src/features/sidebar/lib/channelStarsStorage.ts b/desktop/src/features/sidebar/lib/channelStarsStorage.ts index 997919c6e9..ea44b9b0ce 100644 --- a/desktop/src/features/sidebar/lib/channelStarsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelStarsStorage.ts @@ -1,4 +1,5 @@ const STORAGE_KEY_PREFIX = "buzz-channel-stars.v1"; +export const MAX_CHANNEL_STAR_ENTRIES = 500; export type ChannelStarEntry = { starred: boolean; @@ -45,7 +46,7 @@ export function parseStarPayload(json: unknown): ChannelStarStore | null { ), ) : {}; - return { version: 1, channels }; + return boundStarStore({ version: 1, channels }); } export function readChannelStarsStore(pubkey: string): ChannelStarStore { @@ -64,12 +65,28 @@ export function readChannelStarsStore(pubkey: string): ChannelStarStore { } } +export function boundStarStore(store: ChannelStarStore): ChannelStarStore { + const entries = Object.entries(store.channels); + if (entries.length <= MAX_CHANNEL_STAR_ENTRIES) return store; + entries.sort(([, left], [, right]) => { + if (left.starred !== right.starred) return left.starred ? 1 : -1; + return left.updatedAt - right.updatedAt; + }); + return { + ...store, + channels: Object.fromEntries(entries.slice(-MAX_CHANNEL_STAR_ENTRIES)), + }; +} + export function writeChannelStarsStore( pubkey: string, store: ChannelStarStore, ): boolean { try { - window.localStorage.setItem(storageKey(pubkey), JSON.stringify(store)); + window.localStorage.setItem( + storageKey(pubkey), + JSON.stringify(boundStarStore(store)), + ); return true; } catch { return false; @@ -94,7 +111,7 @@ export function mergeStores( merged[id] = (l ?? r) as ChannelStarEntry; } } - return { version: 1, channels: merged }; + return boundStarStore({ version: 1, channels: merged }); } export function starredChannelIdsFromStore( diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts index cab913834d..ebb6afd3c8 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.ts +++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { relayClient } from "@/shared/api/relayClient"; import { + boundMuteStore, DEFAULT_STORE, mergeStores, mutedChannelIdsFromStore, @@ -163,10 +164,10 @@ export function useChannelMutes( updatedAt: Math.floor(Date.now() / 1000), }; setStore((prev) => { - const next: ChannelMuteStore = { + const next = boundMuteStore({ version: 1, channels: { ...prev.channels, [channelId]: entry }, - }; + }); if (!writeChannelMutesStore(pubkey, next)) return prev; managerRef.current?.publishMutes(next); return next; diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 3d8aa73608..a04bd47e32 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { relayClient } from "@/shared/api/relayClient"; import { + boundChannelSectionsStore, DEFAULT_STORE, readChannelSectionsStore, storageKey, @@ -181,10 +182,10 @@ export function useChannelSections( order: maxOrder + 1, }; setStore((current) => { - const next: ChannelSectionStore = { + const next = boundChannelSectionsStore({ ...current, sections: [...current.sections, section], - }; + }); if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return current; managerRef.current?.publishSections(next); return next; @@ -301,10 +302,10 @@ export function useChannelSections( return; } setStore((prev) => { - const next: ChannelSectionStore = { + const next = boundChannelSectionsStore({ ...prev, assignments: { ...prev.assignments, [channelId]: sectionId }, - }; + }); if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { return prev; } diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index a7963a11e4..85c07b1c39 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { relayClient } from "@/shared/api/relayClient"; import { + boundChannelSortStore, DEFAULT_STORE, readChannelSortStore, sortModeForGroup, @@ -174,9 +175,11 @@ export function useChannelSortPreference( }; // Prune sort modes left behind by deleted custom sections on write so // the stored map can't grow unboundedly with stale `section:` keys. - const next = liveSectionIds - ? stripOrphanedSectionModes(withUpdate, liveSectionIds) - : withUpdate; + const next = boundChannelSortStore( + liveSectionIds + ? stripOrphanedSectionModes(withUpdate, liveSectionIds) + : withUpdate, + ); if (!writeChannelSortStore(pubkey, next, relayUrl)) return prev; managerRef.current?.publishSortPrefs(next); return next; diff --git a/desktop/src/features/sidebar/lib/useChannelStars.ts b/desktop/src/features/sidebar/lib/useChannelStars.ts index b19b18a864..9371e55eb8 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.ts +++ b/desktop/src/features/sidebar/lib/useChannelStars.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { relayClient } from "@/shared/api/relayClient"; import { + boundStarStore, DEFAULT_STORE, mergeStores, readChannelStarsStore, @@ -163,10 +164,10 @@ export function useChannelStars( updatedAt: Math.floor(Date.now() / 1000), }; setStore((prev) => { - const next: ChannelStarStore = { + const next = boundStarStore({ version: 1, channels: { ...prev.channels, [channelId]: entry }, - }; + }); if (!writeChannelStarsStore(pubkey, next)) return prev; managerRef.current?.publishStars(next); return next; diff --git a/desktop/src/shared/features/store.test.mjs b/desktop/src/shared/features/store.test.mjs new file mode 100644 index 0000000000..7484ed8161 --- /dev/null +++ b/desktop/src/shared/features/store.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getOverrides, OVERRIDES_KEY } from "./store.ts"; + +function installStorage(value) { + const values = new Map([[OVERRIDES_KEY, JSON.stringify(value)]]); + globalThis.window = { + localStorage: { + getItem: (key) => values.get(key) ?? null, + setItem: (key, next) => values.set(key, String(next)), + }, + }; + return values; +} + +test("getOverrides drops unknown feature IDs and compacts persisted storage", () => { + const values = installStorage({ workflows: true, removedFeature: false }); + + assert.deepEqual(getOverrides(), { workflows: true }); + assert.equal(values.get(OVERRIDES_KEY), JSON.stringify({ workflows: true })); +}); + +test("getOverrides drops non-boolean values", () => { + installStorage({ workflows: "yes", projects: false }); + + assert.deepEqual(getOverrides(), { projects: false }); +}); diff --git a/desktop/src/shared/features/store.ts b/desktop/src/shared/features/store.ts index 113fe27cb1..81ac27b659 100644 --- a/desktop/src/shared/features/store.ts +++ b/desktop/src/shared/features/store.ts @@ -17,7 +17,21 @@ export type FeatureOverrides = Record; export function getOverrides(): FeatureOverrides { try { const raw = window.localStorage.getItem(OVERRIDES_KEY); - return raw ? (JSON.parse(raw) as FeatureOverrides) : {}; + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return {}; + const featureIds = new Set(manifest.features.map((feature) => feature.id)); + const overrides = Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, boolean] => + featureIds.has(entry[0]) && typeof entry[1] === "boolean", + ), + ); + const serialized = JSON.stringify(overrides); + if (serialized !== raw) + window.localStorage.setItem(OVERRIDES_KEY, serialized); + return overrides; } catch { return {}; } From 1ac1daf1187e5116791ead96b383461040c2c3eb Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 9 Aug 2026 22:02:25 -0600 Subject: [PATCH 2/5] Refine local storage bounds Co-authored-by: Meeseeks <2e96988f190ed1bd3c568760103aa4cadb2bc6195b832e252c984392c89039bd@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../communities/communityIconCache.test.mjs | 17 +++++++++ .../communities/communityIconCache.ts | 4 +- .../profile/lib/selfProfileStorage.test.mjs | 25 ++++++++++++- .../profile/lib/selfProfileStorage.ts | 15 ++++++++ desktop/src/shared/features/store.test.mjs | 37 ++++++++++++++++--- desktop/src/shared/features/store.ts | 6 +-- 6 files changed, 90 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/communities/communityIconCache.test.mjs b/desktop/src/features/communities/communityIconCache.test.mjs index ef7e78ca03..bc25e4b407 100644 --- a/desktop/src/features/communities/communityIconCache.test.mjs +++ b/desktop/src/features/communities/communityIconCache.test.mjs @@ -3,8 +3,10 @@ import test from "node:test"; import { boundCommunityIconCache, + loadCachedCommunityIcon, MAX_CACHED_COMMUNITY_ICON_LENGTH, MAX_CACHED_COMMUNITY_ICONS, + saveCachedCommunityIcon, } from "./communityIconCache.ts"; test("community icon cache caps entries and rejects oversized icons", () => { @@ -23,3 +25,18 @@ test("community icon cache caps entries and rejects oversized icons", () => { assert.equal(bounded.oversized, undefined); assert.equal(bounded[`relay-${MAX_CACHED_COMMUNITY_ICONS}`], "icon-32"); }); + +test("community icon cache accepts relay-sized icons above 64 KiB", () => { + const values = new Map([ + ["buzz-community-icons", JSON.stringify({ relay: "prior-icon" })], + ]); + globalThis.localStorage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + }; + const acceptedIcon = "x".repeat(80 * 1024); + + saveCachedCommunityIcon("relay", acceptedIcon); + + assert.equal(loadCachedCommunityIcon("relay"), acceptedIcon); +}); diff --git a/desktop/src/features/communities/communityIconCache.ts b/desktop/src/features/communities/communityIconCache.ts index 5e55c0552e..2c391f8ddc 100644 --- a/desktop/src/features/communities/communityIconCache.ts +++ b/desktop/src/features/communities/communityIconCache.ts @@ -6,7 +6,9 @@ const ICON_CACHE_KEY = "buzz-community-icons"; export const MAX_CACHED_COMMUNITY_ICONS = 32; -export const MAX_CACHED_COMMUNITY_ICON_LENGTH = 64 * 1024; +// Keep aligned with MAX_WORKSPACE_ICON_DATA_URL_LEN in +// crates/buzz-relay/src/handlers/relay_admin.rs. +export const MAX_CACHED_COMMUNITY_ICON_LENGTH = 98_304; export function boundCommunityIconCache( cache: Record, diff --git a/desktop/src/features/profile/lib/selfProfileStorage.test.mjs b/desktop/src/features/profile/lib/selfProfileStorage.test.mjs index 1e6b6ee114..e962eb438f 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.test.mjs +++ b/desktop/src/features/profile/lib/selfProfileStorage.test.mjs @@ -51,7 +51,7 @@ test("storageKey: different pubkeys produce different keys", () => { assert.notEqual(a, b); }); -function installStorage() { +function installStorage(onGetItem = () => {}) { const values = new Map(); globalThis.window = { dispatchEvent: () => true, @@ -59,7 +59,10 @@ function installStorage() { get length() { return values.size; }, - getItem: (key) => values.get(key) ?? null, + getItem: (key) => { + onGetItem(key); + return values.get(key) ?? null; + }, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, String(value)), @@ -99,6 +102,24 @@ test("writeSelfProfileCache caps each relay and the global cache by updatedAt", assert.equal(values.has(storageKey(relayA, "pubkey-1")), false); }); +test("writeSelfProfileCache does not read existing payloads below both caps", () => { + const readKeys = []; + const values = installStorage((key) => readKeys.push(key)); + const relay = "wss://relay.example"; + const existingKey = storageKey(relay, "existing"); + const writtenKey = storageKey(relay, "written"); + values.set(existingKey, JSON.stringify(makeCache({ updatedAt: 1 }))); + + assert.equal( + writeSelfProfileCache(relay, "written", makeCache({ updatedAt: 2 })), + true, + ); + + assert.deepEqual(readKeys, [writtenKey]); + assert.equal(values.has(existingKey), true); + assert.equal(values.has(writtenKey), true); +}); + test("parseSelfProfileCache: valid v1 payload round-trips", () => { const payload = { version: 1, diff --git a/desktop/src/features/profile/lib/selfProfileStorage.ts b/desktop/src/features/profile/lib/selfProfileStorage.ts index a871cb0f20..ea8b28fc62 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.ts +++ b/desktop/src/features/profile/lib/selfProfileStorage.ts @@ -131,6 +131,21 @@ export function readSelfProfileCache( function trimSelfProfileCaches(relayUrl: string, preservedKey: string): void { const relayPrefix = `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:`; + let totalEntryCount = 0; + let relayEntryCount = 0; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (!key?.startsWith(`${STORAGE_KEY_PREFIX}:`)) continue; + totalEntryCount += 1; + if (key.startsWith(relayPrefix)) relayEntryCount += 1; + } + if ( + totalEntryCount <= MAX_SELF_PROFILE_CACHES && + relayEntryCount <= MAX_SELF_PROFILE_CACHES_PER_RELAY + ) { + return; + } + const entries: Array<{ key: string; updatedAt: number }> = []; for (let i = 0; i < window.localStorage.length; i++) { const key = window.localStorage.key(i); diff --git a/desktop/src/shared/features/store.test.mjs b/desktop/src/shared/features/store.test.mjs index 7484ed8161..ae7d0c0ad1 100644 --- a/desktop/src/shared/features/store.test.mjs +++ b/desktop/src/shared/features/store.test.mjs @@ -1,24 +1,49 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getOverrides, OVERRIDES_KEY } from "./store.ts"; +import { getOverrides, OVERRIDES_KEY, setOverride } from "./store.ts"; function installStorage(value) { const values = new Map([[OVERRIDES_KEY, JSON.stringify(value)]]); + const writes = []; globalThis.window = { localStorage: { getItem: (key) => values.get(key) ?? null, - setItem: (key, next) => values.set(key, String(next)), + setItem: (key, next) => { + writes.push([key, String(next)]); + values.set(key, String(next)); + }, }, }; - return values; + return { values, writes }; } -test("getOverrides drops unknown feature IDs and compacts persisted storage", () => { - const values = installStorage({ workflows: true, removedFeature: false }); +test("getOverrides drops unknown feature IDs without writing to storage", () => { + const { values, writes } = installStorage({ + workflows: true, + removedFeature: false, + }); assert.deepEqual(getOverrides(), { workflows: true }); - assert.equal(values.get(OVERRIDES_KEY), JSON.stringify({ workflows: true })); + assert.deepEqual(writes, []); + assert.equal( + values.get(OVERRIDES_KEY), + JSON.stringify({ workflows: true, removedFeature: false }), + ); +}); + +test("setOverride persists filtered overrides", () => { + const { values } = installStorage({ + workflows: true, + removedFeature: false, + }); + + setOverride("projects", true); + + assert.equal( + values.get(OVERRIDES_KEY), + JSON.stringify({ workflows: true, projects: true }), + ); }); test("getOverrides drops non-boolean values", () => { diff --git a/desktop/src/shared/features/store.ts b/desktop/src/shared/features/store.ts index 81ac27b659..daea7104d0 100644 --- a/desktop/src/shared/features/store.ts +++ b/desktop/src/shared/features/store.ts @@ -22,16 +22,12 @@ export function getOverrides(): FeatureOverrides { if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; const featureIds = new Set(manifest.features.map((feature) => feature.id)); - const overrides = Object.fromEntries( + return Object.fromEntries( Object.entries(parsed).filter( (entry): entry is [string, boolean] => featureIds.has(entry[0]) && typeof entry[1] === "boolean", ), ); - const serialized = JSON.stringify(overrides); - if (serialized !== raw) - window.localStorage.setItem(OVERRIDES_KEY, serialized); - return overrides; } catch { return {}; } From ac832aa244344e34a4c71fa37badfd703be42d2d Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 10 Aug 2026 08:26:34 -0600 Subject: [PATCH 3/5] Preserve recent synced state Co-authored-by: Meeseeks <2e96988f190ed1bd3c568760103aa4cadb2bc6195b832e252c984392c89039bd@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../lib/persistentAgentAudience.test.mjs | 63 ++++++++++++++++++ .../messages/lib/persistentAgentAudience.ts | 11 +++- .../sidebar/lib/channelMutesStorage.test.mjs | 62 ++++++++++++++--- .../sidebar/lib/channelMutesStorage.ts | 7 +- .../sidebar/lib/channelStarsStorage.test.mjs | 66 ++++++++++++++++--- .../sidebar/lib/channelStarsStorage.ts | 7 +- 6 files changed, 190 insertions(+), 26 deletions(-) diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs index a45c02290b..39abaec084 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs +++ b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs @@ -231,6 +231,69 @@ test("persistent audiences retain only the 200 most recently touched scopes", as assert.deepEqual(retouched["scope-new"], [agentC]); }); +test("an unchanged touch refreshes LRU without revision or emit", async () => { + const { JSDOM } = await import("jsdom"); + const dom = new JSDOM( + "
", + { + url: "http://localhost", + }, + ); + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + loadSequence += 1; + const store = await import( + `./persistentAgentAudience.ts?test=${Date.now()}-touch-${loadSequence}` + ); + const touchedScope = "scope-0"; + store.setPersistentAgentAudience(touchedScope, [agentA]); + for (let index = 1; index < store.MAX_PERSISTENT_AGENT_AUDIENCES; index++) { + store.setPersistentAgentAudience(`scope-${index}`, [agentA]); + } + + const React = await import("react"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.getElementById("root")); + let renderCount = 0; + function Probe() { + store.usePersistentAgentAudience(touchedScope); + renderCount += 1; + return null; + } + await React.act(async () => root.render(React.createElement(Probe))); + const revision = store.getPersistentAgentAudienceRevision(touchedScope); + const renderCountBeforeTouch = renderCount; + + await React.act(async () => { + store.setPersistentAgentAudience(touchedScope, [agentA]); + }); + + assert.equal( + store.getPersistentAgentAudienceRevision(touchedScope), + revision, + ); + assert.equal(renderCount, renderCountBeforeTouch); + + await React.act(async () => { + store.setPersistentAgentAudience("scope-new", [agentB]); + }); + const saved = savedAudiences(); + assert.deepEqual(saved[touchedScope], [agentA]); + assert.equal(saved["scope-1"], undefined); + assert.deepEqual(saved["scope-new"], [agentB]); + assert.equal( + store.getPersistentAgentAudienceRevision(touchedScope), + revision, + ); + + await React.act(async () => root.unmount()); + dom.window.close(); +}); + test("timeline scope is intentionally unsupported", async () => { const store = await loadStore(7); assert.equal( diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.ts b/desktop/src/features/messages/lib/persistentAgentAudience.ts index 9493226cd7..32847693c7 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.ts +++ b/desktop/src/features/messages/lib/persistentAgentAudience.ts @@ -139,8 +139,11 @@ export function initializePersistentAgentAudience( scope: string, pubkeys: Iterable, ): void { - if (!enabled || !scope || Object.hasOwn(audiences, scope)) return; - setPersistentAgentAudience(scope, pubkeys); + if (!enabled || !scope) return; + setPersistentAgentAudience( + scope, + Object.hasOwn(audiences, scope) ? audiences[scope] : pubkeys, + ); } export function setPersistentAgentAudience( @@ -155,6 +158,10 @@ export function setPersistentAgentAudience( current.length === normalized.length && current.every((pubkey, index) => pubkey === normalized[index]) ) { + const nextAudiences = { ...audiences }; + delete nextAudiences[scope]; + audiences = boundAudiences({ ...nextAudiences, [scope]: current }); + persistAudiences(); return; } diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs index f509a35798..8e97a8dc3a 100644 --- a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs @@ -210,25 +210,67 @@ test("mergeStores: both empty returns empty", () => { assert.deepEqual(result, { version: 1, channels: {} }); }); -test("boundMuteStore: caps entries and evicts false tombstones before active mutes", () => { +test("boundMuteStore: retains newest entries regardless of muted value", () => { const channels = Object.fromEntries( Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ `active-${index}`, - { muted: true, updatedAt: index }, + { muted: true, updatedAt: index + 1 }, ]), ); - channels["old-false"] = { muted: false, updatedAt: -1 }; + channels["old-false"] = { muted: false, updatedAt: 0 }; channels["new-false"] = { muted: false, updatedAt: 9999 }; const result = boundMuteStore({ version: 1, channels }); assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_MUTE_ENTRIES); assert.equal(result.channels["old-false"], undefined); - assert.equal(result.channels["new-false"], undefined); - assert.deepEqual(result.channels["active-0"], { muted: true, updatedAt: 0 }); + assert.deepEqual(result.channels["new-false"], { + muted: false, + updatedAt: 9999, + }); + assert.equal(result.channels["active-0"], undefined); + assert.deepEqual(result.channels["active-1"], { muted: true, updatedAt: 2 }); +}); + +test("boundMuteStore: uses channel ID as an updatedAt tie-breaker", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES + 1 }, (_, index) => [ + `channel-${String(MAX_CHANNEL_MUTE_ENTRIES - index).padStart(3, "0")}`, + { muted: true, updatedAt: 1 }, + ]), + ); + + const result = boundMuteStore({ version: 1, channels }); + + assert.equal(result.channels["channel-000"], undefined); + assert.deepEqual(result.channels["channel-500"], { + muted: true, + updatedAt: 1, + }); +}); + +test("mergeStores: a fresh at-capacity unmute defeats an older remote mute", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ + `active-${index}`, + { muted: true, updatedAt: index + 1 }, + ]), + ); + channels["unmuted"] = { muted: false, updatedAt: 9999 }; + const bounded = boundMuteStore({ version: 1, channels }); + + const result = mergeStores(bounded, { + version: 1, + channels: { unmuted: { muted: true, updatedAt: 9998 } }, + }); + + assert.deepEqual(result.channels.unmuted, { + muted: false, + updatedAt: 9999, + }); }); -test("mergeStores: evicted remote ID re-enters LWW merge and a tombstone is re-trimmed", () => { +test("mergeStores: evicted remote ID re-enters and the oldest state is re-trimmed", () => { const localChannels = Object.fromEntries( Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ `active-${index}`, @@ -251,8 +293,12 @@ test("mergeStores: evicted remote ID re-enters LWW merge and a tombstone is re-t muted: true, updatedAt: 9999, }); - assert.equal(result.channels["active-0"], undefined); - assert.deepEqual(result.channels["active-1"], { muted: true, updatedAt: 11 }); + assert.deepEqual(result.channels["active-0"], { + muted: false, + updatedAt: 9998, + }); + assert.equal(result.channels["active-1"], undefined); + assert.deepEqual(result.channels["active-2"], { muted: true, updatedAt: 12 }); }); // ── mutedChannelIdsFromStore ────────────────────────────────────────────────── diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.ts b/desktop/src/features/sidebar/lib/channelMutesStorage.ts index 37090f9b1b..d45c92eabc 100644 --- a/desktop/src/features/sidebar/lib/channelMutesStorage.ts +++ b/desktop/src/features/sidebar/lib/channelMutesStorage.ts @@ -68,9 +68,10 @@ export function readChannelMutesStore(pubkey: string): ChannelMuteStore { export function boundMuteStore(store: ChannelMuteStore): ChannelMuteStore { const entries = Object.entries(store.channels); if (entries.length <= MAX_CHANNEL_MUTE_ENTRIES) return store; - entries.sort(([, left], [, right]) => { - if (left.muted !== right.muted) return left.muted ? 1 : -1; - return left.updatedAt - right.updatedAt; + entries.sort(([leftId, left], [rightId, right]) => { + if (left.updatedAt !== right.updatedAt) + return left.updatedAt - right.updatedAt; + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; }); return { ...store, diff --git a/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs index c577fa8fc1..90d5fa4e45 100644 --- a/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs @@ -224,28 +224,70 @@ test("mergeStores: both empty returns empty", () => { assert.deepEqual(result, { version: 1, channels: {} }); }); -test("boundStarStore: caps entries and evicts false tombstones before active stars", () => { +test("boundStarStore: retains newest entries regardless of starred value", () => { const channels = Object.fromEntries( Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ `active-${index}`, - { starred: true, updatedAt: index }, + { starred: true, updatedAt: index + 1 }, ]), ); - channels["old-false"] = { starred: false, updatedAt: -1 }; + channels["old-false"] = { starred: false, updatedAt: 0 }; channels["new-false"] = { starred: false, updatedAt: 9999 }; const result = boundStarStore({ version: 1, channels }); assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_STAR_ENTRIES); assert.equal(result.channels["old-false"], undefined); - assert.equal(result.channels["new-false"], undefined); - assert.deepEqual(result.channels["active-0"], { + assert.deepEqual(result.channels["new-false"], { + starred: false, + updatedAt: 9999, + }); + assert.equal(result.channels["active-0"], undefined); + assert.deepEqual(result.channels["active-1"], { + starred: true, + updatedAt: 2, + }); +}); + +test("boundStarStore: uses channel ID as an updatedAt tie-breaker", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_STAR_ENTRIES + 1 }, (_, index) => [ + `channel-${String(MAX_CHANNEL_STAR_ENTRIES - index).padStart(3, "0")}`, + { starred: true, updatedAt: 1 }, + ]), + ); + + const result = boundStarStore({ version: 1, channels }); + + assert.equal(result.channels["channel-000"], undefined); + assert.deepEqual(result.channels["channel-500"], { starred: true, - updatedAt: 0, + updatedAt: 1, }); }); -test("mergeStores: evicted remote ID re-enters LWW merge and a tombstone is re-trimmed", () => { +test("mergeStores: a fresh at-capacity unstar defeats an older remote star", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ + `active-${index}`, + { starred: true, updatedAt: index + 1 }, + ]), + ); + channels["unstarred"] = { starred: false, updatedAt: 9999 }; + const bounded = boundStarStore({ version: 1, channels }); + + const result = mergeStores(bounded, { + version: 1, + channels: { unstarred: { starred: true, updatedAt: 9998 } }, + }); + + assert.deepEqual(result.channels.unstarred, { + starred: false, + updatedAt: 9999, + }); +}); + +test("mergeStores: evicted remote ID re-enters and the oldest state is re-trimmed", () => { const localChannels = Object.fromEntries( Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ `active-${index}`, @@ -268,10 +310,14 @@ test("mergeStores: evicted remote ID re-enters LWW merge and a tombstone is re-t starred: true, updatedAt: 9999, }); - assert.equal(result.channels["active-0"], undefined); - assert.deepEqual(result.channels["active-1"], { + assert.deepEqual(result.channels["active-0"], { + starred: false, + updatedAt: 9998, + }); + assert.equal(result.channels["active-1"], undefined); + assert.deepEqual(result.channels["active-2"], { starred: true, - updatedAt: 11, + updatedAt: 12, }); }); diff --git a/desktop/src/features/sidebar/lib/channelStarsStorage.ts b/desktop/src/features/sidebar/lib/channelStarsStorage.ts index ea44b9b0ce..ad4d8394df 100644 --- a/desktop/src/features/sidebar/lib/channelStarsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelStarsStorage.ts @@ -68,9 +68,10 @@ export function readChannelStarsStore(pubkey: string): ChannelStarStore { export function boundStarStore(store: ChannelStarStore): ChannelStarStore { const entries = Object.entries(store.channels); if (entries.length <= MAX_CHANNEL_STAR_ENTRIES) return store; - entries.sort(([, left], [, right]) => { - if (left.starred !== right.starred) return left.starred ? 1 : -1; - return left.updatedAt - right.updatedAt; + entries.sort(([leftId, left], [rightId, right]) => { + if (left.updatedAt !== right.updatedAt) + return left.updatedAt - right.updatedAt; + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; }); return { ...store, From 0192f8605ec06aee416422072153c2503d1c662e Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 10 Aug 2026 09:24:39 -0600 Subject: [PATCH 4/5] Skip redundant audience persistence Co-authored-by: Meeseeks <2e96988f190ed1bd3c568760103aa4cadb2bc6195b832e252c984392c89039bd@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../lib/persistentAgentAudience.test.mjs | 28 +++++++++++++++++-- .../messages/lib/persistentAgentAudience.ts | 1 + 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs index 39abaec084..ac3cc5b0d1 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs +++ b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs @@ -1,11 +1,14 @@ import assert from "node:assert/strict"; import test from "node:test"; -function createStorage() { +function createStorage(onSetItem = () => {}) { const values = new Map(); return { getItem: (key) => values.get(key) ?? null, - setItem: (key, value) => values.set(key, String(value)), + setItem: (key, value) => { + onSetItem(key, value); + values.set(key, String(value)); + }, }; } @@ -239,6 +242,11 @@ test("an unchanged touch refreshes LRU without revision or emit", async () => { url: "http://localhost", }, ); + const writes = []; + Object.defineProperty(dom.window, "localStorage", { + configurable: true, + value: createStorage((key, value) => writes.push([key, String(value)])), + }); Object.assign(globalThis, { document: dom.window.document, HTMLElement: dom.window.HTMLElement, @@ -267,11 +275,27 @@ test("an unchanged touch refreshes LRU without revision or emit", async () => { await React.act(async () => root.render(React.createElement(Probe))); const revision = store.getPersistentAgentAudienceRevision(touchedScope); const renderCountBeforeTouch = renderCount; + writes.length = 0; await React.act(async () => { store.setPersistentAgentAudience(touchedScope, [agentA]); }); + assert.equal(writes.length, 1); + assert.equal(writes[0][0], storageKey); + assert.deepEqual(JSON.parse(writes[0][1])[touchedScope], [agentA]); + assert.equal(Object.keys(JSON.parse(writes[0][1])).at(-1), touchedScope); + assert.equal( + store.getPersistentAgentAudienceRevision(touchedScope), + revision, + ); + assert.equal(renderCount, renderCountBeforeTouch); + + writes.length = 0; + await React.act(async () => { + store.setPersistentAgentAudience(touchedScope, [agentA]); + }); + assert.equal(writes.length, 0); assert.equal( store.getPersistentAgentAudienceRevision(touchedScope), revision, diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.ts b/desktop/src/features/messages/lib/persistentAgentAudience.ts index 32847693c7..a16163ed1f 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.ts +++ b/desktop/src/features/messages/lib/persistentAgentAudience.ts @@ -158,6 +158,7 @@ export function setPersistentAgentAudience( current.length === normalized.length && current.every((pubkey, index) => pubkey === normalized[index]) ) { + if (Object.keys(audiences).at(-1) === scope) return; const nextAudiences = { ...audiences }; delete nextAudiences[scope]; audiences = boundAudiences({ ...nextAudiences, [scope]: current }); From 005a772dd138871b2f760222e610b5e8ac07758f Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 10 Aug 2026 10:12:07 -0600 Subject: [PATCH 5/5] Preserve fresh bounded sidebar mutations Co-authored-by: Meeseeks <2e96988f190ed1bd3c568760103aa4cadb2bc6195b832e252c984392c89039bd@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../sidebar/lib/channelMutesStorage.test.mjs | 38 +++++++++ .../sidebar/lib/channelMutesStorage.ts | 22 +++++- .../sidebar/lib/channelStarsStorage.test.mjs | 38 +++++++++ .../sidebar/lib/channelStarsStorage.ts | 22 +++++- .../sidebar/lib/useChannelMutes.test.mjs | 79 +++++++++++++++++++ .../features/sidebar/lib/useChannelMutes.ts | 11 ++- .../sidebar/lib/useChannelSections.test.mjs | 78 ++++++++++++++++++ .../sidebar/lib/useChannelSections.ts | 5 +- .../sidebar/lib/useChannelStars.test.mjs | 79 +++++++++++++++++++ .../features/sidebar/lib/useChannelStars.ts | 11 ++- 10 files changed, 366 insertions(+), 17 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/useChannelMutes.test.mjs create mode 100644 desktop/src/features/sidebar/lib/useChannelSections.test.mjs create mode 100644 desktop/src/features/sidebar/lib/useChannelStars.test.mjs diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs index 8e97a8dc3a..f506eb93f1 100644 --- a/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs @@ -249,6 +249,44 @@ test("boundMuteStore: uses channel ID as an updatedAt tie-breaker", () => { }); }); +test("boundMuteStore: preserves a same-second mute mutation by key", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ + `z-channel-${String(index).padStart(3, "0")}`, + { muted: true, updatedAt: 1 }, + ]), + ); + channels["a-target"] = { muted: true, updatedAt: 1 }; + + const result = boundMuteStore({ version: 1, channels }, "a-target"); + + assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_MUTE_ENTRIES); + assert.deepEqual(result.channels["a-target"], { + muted: true, + updatedAt: 1, + }); + assert.equal(result.channels["z-channel-000"], undefined); +}); + +test("boundMuteStore: preserves a same-second unmute mutation by key", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ + `z-channel-${String(index).padStart(3, "0")}`, + { muted: true, updatedAt: 1 }, + ]), + ); + channels["a-target"] = { muted: false, updatedAt: 1 }; + + const result = boundMuteStore({ version: 1, channels }, "a-target"); + + assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_MUTE_ENTRIES); + assert.deepEqual(result.channels["a-target"], { + muted: false, + updatedAt: 1, + }); + assert.equal(result.channels["z-channel-000"], undefined); +}); + test("mergeStores: a fresh at-capacity unmute defeats an older remote mute", () => { const channels = Object.fromEntries( Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ diff --git a/desktop/src/features/sidebar/lib/channelMutesStorage.ts b/desktop/src/features/sidebar/lib/channelMutesStorage.ts index d45c92eabc..1bf315d268 100644 --- a/desktop/src/features/sidebar/lib/channelMutesStorage.ts +++ b/desktop/src/features/sidebar/lib/channelMutesStorage.ts @@ -65,17 +65,31 @@ export function readChannelMutesStore(pubkey: string): ChannelMuteStore { } } -export function boundMuteStore(store: ChannelMuteStore): ChannelMuteStore { - const entries = Object.entries(store.channels); - if (entries.length <= MAX_CHANNEL_MUTE_ENTRIES) return store; +export function boundMuteStore( + store: ChannelMuteStore, + preservedKey?: string, +): ChannelMuteStore { + const preservedEntry = + preservedKey === undefined ? undefined : store.channels[preservedKey]; + const entries = Object.entries(store.channels).filter( + ([channelId]) => channelId !== preservedKey, + ); + if (entries.length + (preservedEntry ? 1 : 0) <= MAX_CHANNEL_MUTE_ENTRIES) + return store; entries.sort(([leftId, left], [rightId, right]) => { if (left.updatedAt !== right.updatedAt) return left.updatedAt - right.updatedAt; return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; }); + const retainedEntries = entries.slice( + -(MAX_CHANNEL_MUTE_ENTRIES - (preservedEntry ? 1 : 0)), + ); + if (preservedEntry && preservedKey !== undefined) { + retainedEntries.push([preservedKey, preservedEntry]); + } return { ...store, - channels: Object.fromEntries(entries.slice(-MAX_CHANNEL_MUTE_ENTRIES)), + channels: Object.fromEntries(retainedEntries), }; } diff --git a/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs index 90d5fa4e45..1585a42d47 100644 --- a/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelStarsStorage.test.mjs @@ -266,6 +266,44 @@ test("boundStarStore: uses channel ID as an updatedAt tie-breaker", () => { }); }); +test("boundStarStore: preserves a same-second star mutation by key", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ + `z-channel-${String(index).padStart(3, "0")}`, + { starred: true, updatedAt: 1 }, + ]), + ); + channels["a-target"] = { starred: true, updatedAt: 1 }; + + const result = boundStarStore({ version: 1, channels }, "a-target"); + + assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_STAR_ENTRIES); + assert.deepEqual(result.channels["a-target"], { + starred: true, + updatedAt: 1, + }); + assert.equal(result.channels["z-channel-000"], undefined); +}); + +test("boundStarStore: preserves a same-second unstar mutation by key", () => { + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ + `z-channel-${String(index).padStart(3, "0")}`, + { starred: true, updatedAt: 1 }, + ]), + ); + channels["a-target"] = { starred: false, updatedAt: 1 }; + + const result = boundStarStore({ version: 1, channels }, "a-target"); + + assert.equal(Object.keys(result.channels).length, MAX_CHANNEL_STAR_ENTRIES); + assert.deepEqual(result.channels["a-target"], { + starred: false, + updatedAt: 1, + }); + assert.equal(result.channels["z-channel-000"], undefined); +}); + test("mergeStores: a fresh at-capacity unstar defeats an older remote star", () => { const channels = Object.fromEntries( Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ diff --git a/desktop/src/features/sidebar/lib/channelStarsStorage.ts b/desktop/src/features/sidebar/lib/channelStarsStorage.ts index ad4d8394df..43c845cb3b 100644 --- a/desktop/src/features/sidebar/lib/channelStarsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelStarsStorage.ts @@ -65,17 +65,31 @@ export function readChannelStarsStore(pubkey: string): ChannelStarStore { } } -export function boundStarStore(store: ChannelStarStore): ChannelStarStore { - const entries = Object.entries(store.channels); - if (entries.length <= MAX_CHANNEL_STAR_ENTRIES) return store; +export function boundStarStore( + store: ChannelStarStore, + preservedKey?: string, +): ChannelStarStore { + const preservedEntry = + preservedKey === undefined ? undefined : store.channels[preservedKey]; + const entries = Object.entries(store.channels).filter( + ([channelId]) => channelId !== preservedKey, + ); + if (entries.length + (preservedEntry ? 1 : 0) <= MAX_CHANNEL_STAR_ENTRIES) + return store; entries.sort(([leftId, left], [rightId, right]) => { if (left.updatedAt !== right.updatedAt) return left.updatedAt - right.updatedAt; return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; }); + const retainedEntries = entries.slice( + -(MAX_CHANNEL_STAR_ENTRIES - (preservedEntry ? 1 : 0)), + ); + if (preservedEntry && preservedKey !== undefined) { + retainedEntries.push([preservedKey, preservedEntry]); + } return { ...store, - channels: Object.fromEntries(entries.slice(-MAX_CHANNEL_STAR_ENTRIES)), + channels: Object.fromEntries(retainedEntries), }; } diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.test.mjs b/desktop/src/features/sidebar/lib/useChannelMutes.test.mjs new file mode 100644 index 0000000000..df41f40311 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useChannelMutes.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +test("same-second mute and unmute mutations survive at capacity", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { MAX_CHANNEL_MUTE_ENTRIES, readChannelMutesStore, storageKey } = + await import("./channelMutesStorage.ts"); + const { useChannelMutes } = await import("./useChannelMutes.ts"); + + const originalFetchEvents = relayClient.fetchEvents; + const originalSubscribeLive = relayClient.subscribeLive; + const originalSubscribeToReconnects = relayClient.subscribeToReconnects; + const originalDateNow = Date.now; + const updatedAt = 1_234_567; + Date.now = () => updatedAt * 1_000; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async () => async () => {}; + relayClient.subscribeToReconnects = () => () => {}; + + const relayUrl = "wss://relay.example"; + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_MUTE_ENTRIES }, (_, index) => [ + `z-channel-${String(index).padStart(3, "0")}`, + { muted: true, updatedAt }, + ]), + ); + + try { + for (const [pubkey, action, expectedMuted] of [ + ["pk-mute", "muteChannel", true], + ["pk-unmute", "unmuteChannel", false], + ]) { + window.localStorage.setItem( + storageKey(pubkey), + JSON.stringify({ version: 1, channels }), + ); + const { result, unmount } = renderHook(() => + useChannelMutes(pubkey, relayUrl), + ); + + act(() => result.current[action]("a-target")); + + const persisted = readChannelMutesStore(pubkey); + assert.equal( + Object.keys(persisted.channels).length, + MAX_CHANNEL_MUTE_ENTRIES, + ); + assert.deepEqual(persisted.channels["a-target"], { + muted: expectedMuted, + updatedAt, + }); + unmount(); + } + } finally { + cleanup(); + Date.now = originalDateNow; + relayClient.fetchEvents = originalFetchEvents; + relayClient.subscribeLive = originalSubscribeLive; + relayClient.subscribeToReconnects = originalSubscribeToReconnects; + } +}); diff --git a/desktop/src/features/sidebar/lib/useChannelMutes.ts b/desktop/src/features/sidebar/lib/useChannelMutes.ts index ebb6afd3c8..20b5745325 100644 --- a/desktop/src/features/sidebar/lib/useChannelMutes.ts +++ b/desktop/src/features/sidebar/lib/useChannelMutes.ts @@ -164,10 +164,13 @@ export function useChannelMutes( updatedAt: Math.floor(Date.now() / 1000), }; setStore((prev) => { - const next = boundMuteStore({ - version: 1, - channels: { ...prev.channels, [channelId]: entry }, - }); + const next = boundMuteStore( + { + version: 1, + channels: { ...prev.channels, [channelId]: entry }, + }, + channelId, + ); if (!writeChannelMutesStore(pubkey, next)) return prev; managerRef.current?.publishMutes(next); return next; diff --git a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs new file mode 100644 index 0000000000..401b59d9c1 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +test("assignChannel refreshes an existing assignment before the next eviction", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { MAX_CHANNEL_SECTION_ASSIGNMENTS, storageKey } = await import( + "./channelSectionsStorage.ts" + ); + const { useChannelSections } = await import("./useChannelSections.ts"); + + const originalFetchEvents = relayClient.fetchEvents; + const originalSubscribeLive = relayClient.subscribeLive; + const originalSubscribeToReconnects = relayClient.subscribeToReconnects; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async () => async () => {}; + relayClient.subscribeToReconnects = () => () => {}; + + const pubkey = "pk-at-capacity"; + const relayUrl = "wss://relay.example"; + const assignments = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_SECTION_ASSIGNMENTS }, (_, index) => [ + `chan-${String(index).padStart(4, "0")}`, + "section-1", + ]), + ); + window.localStorage.setItem( + storageKey(pubkey, relayUrl), + JSON.stringify({ + version: 1, + sections: [ + { id: "section-1", name: "One", order: 0 }, + { id: "section-2", name: "Two", order: 1 }, + ], + assignments, + }), + ); + + try { + const { result, unmount } = renderHook(() => + useChannelSections(pubkey, relayUrl), + ); + + act(() => result.current.assignChannel("chan-0000", "section-2")); + act(() => result.current.assignChannel("chan-new", "section-1")); + + assert.equal(result.current.assignments["chan-0000"], "section-2"); + assert.equal(result.current.assignments["chan-new"], "section-1"); + assert.equal(result.current.assignments["chan-0001"], undefined); + assert.equal( + Object.keys(result.current.assignments).length, + MAX_CHANNEL_SECTION_ASSIGNMENTS, + ); + unmount(); + } finally { + cleanup(); + relayClient.fetchEvents = originalFetchEvents; + relayClient.subscribeLive = originalSubscribeLive; + relayClient.subscribeToReconnects = originalSubscribeToReconnects; + } +}); diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index a04bd47e32..5a544e82bc 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -302,9 +302,12 @@ export function useChannelSections( return; } setStore((prev) => { + const assignments = { ...prev.assignments }; + delete assignments[channelId]; + assignments[channelId] = sectionId; const next = boundChannelSectionsStore({ ...prev, - assignments: { ...prev.assignments, [channelId]: sectionId }, + assignments, }); if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { return prev; diff --git a/desktop/src/features/sidebar/lib/useChannelStars.test.mjs b/desktop/src/features/sidebar/lib/useChannelStars.test.mjs new file mode 100644 index 0000000000..a8e1b4b4ae --- /dev/null +++ b/desktop/src/features/sidebar/lib/useChannelStars.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +test("same-second star and unstar mutations survive at capacity", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { relayClient } = await import("@/shared/api/relayClient"); + const { MAX_CHANNEL_STAR_ENTRIES, readChannelStarsStore, storageKey } = + await import("./channelStarsStorage.ts"); + const { useChannelStars } = await import("./useChannelStars.ts"); + + const originalFetchEvents = relayClient.fetchEvents; + const originalSubscribeLive = relayClient.subscribeLive; + const originalSubscribeToReconnects = relayClient.subscribeToReconnects; + const originalDateNow = Date.now; + const updatedAt = 1_234_567; + Date.now = () => updatedAt * 1_000; + relayClient.fetchEvents = async () => []; + relayClient.subscribeLive = async () => async () => {}; + relayClient.subscribeToReconnects = () => () => {}; + + const relayUrl = "wss://relay.example"; + const channels = Object.fromEntries( + Array.from({ length: MAX_CHANNEL_STAR_ENTRIES }, (_, index) => [ + `z-channel-${String(index).padStart(3, "0")}`, + { starred: true, updatedAt }, + ]), + ); + + try { + for (const [pubkey, action, expectedStarred] of [ + ["pk-star", "starChannel", true], + ["pk-unstar", "unstarChannel", false], + ]) { + window.localStorage.setItem( + storageKey(pubkey), + JSON.stringify({ version: 1, channels }), + ); + const { result, unmount } = renderHook(() => + useChannelStars(pubkey, relayUrl), + ); + + act(() => result.current[action]("a-target")); + + const persisted = readChannelStarsStore(pubkey); + assert.equal( + Object.keys(persisted.channels).length, + MAX_CHANNEL_STAR_ENTRIES, + ); + assert.deepEqual(persisted.channels["a-target"], { + starred: expectedStarred, + updatedAt, + }); + unmount(); + } + } finally { + cleanup(); + Date.now = originalDateNow; + relayClient.fetchEvents = originalFetchEvents; + relayClient.subscribeLive = originalSubscribeLive; + relayClient.subscribeToReconnects = originalSubscribeToReconnects; + } +}); diff --git a/desktop/src/features/sidebar/lib/useChannelStars.ts b/desktop/src/features/sidebar/lib/useChannelStars.ts index 9371e55eb8..855c8de858 100644 --- a/desktop/src/features/sidebar/lib/useChannelStars.ts +++ b/desktop/src/features/sidebar/lib/useChannelStars.ts @@ -164,10 +164,13 @@ export function useChannelStars( updatedAt: Math.floor(Date.now() / 1000), }; setStore((prev) => { - const next = boundStarStore({ - version: 1, - channels: { ...prev.channels, [channelId]: entry }, - }); + const next = boundStarStore( + { + version: 1, + channels: { ...prev.channels, [channelId]: entry }, + }, + channelId, + ); if (!writeChannelStarsStore(pubkey, next)) return prev; managerRef.current?.publishStars(next); return next;