Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions desktop/src/features/channels/forcedUnreadStore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import test from "node:test";

import {
addForcedUnreadSource,
boundForcedUnreadMap,
forcedUnreadMarker,
MAX_FORCED_UNREAD_ENTRIES,
removeForcedUnreadSource,
} from "./forcedUnreadStore.ts";

Expand Down Expand Up @@ -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);
Expand Down
17 changes: 15 additions & 2 deletions desktop/src/features/channels/forcedUnreadStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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).
}
Expand Down Expand Up @@ -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],
Expand Down
42 changes: 42 additions & 0 deletions desktop/src/features/communities/communityIconCache.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
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", () => {
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");
});

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);
});
25 changes: 22 additions & 3 deletions desktop/src/features/communities/communityIconCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,28 @@
*/

const ICON_CACHE_KEY = "buzz-community-icons";
export const MAX_CACHED_COMMUNITY_ICONS = 32;
// 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<string, string>,
): Record<string, string> {
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<string, string> {
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<string, string>;
return boundCommunityIconCache(parsed as Record<string, string>);
}
} catch {
// Corrupt cache — fall through to empty.
Expand All @@ -28,13 +43,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.
}
Expand Down
115 changes: 113 additions & 2 deletions desktop/src/features/messages/lib/persistentAgentAudience.test.mjs
Original file line number Diff line number Diff line change
@@ -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));
},
};
}

Expand Down Expand Up @@ -207,6 +210,114 @@ 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("an unchanged touch refreshes LRU without revision or emit", async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM(
"<!doctype html><html><body><div id='root'></div></body></html>",
{
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,
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;
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,
);
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(
Expand Down
31 changes: 27 additions & 4 deletions desktop/src/features/messages/lib/persistentAgentAudience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
Expand Down Expand Up @@ -39,6 +40,15 @@ function readEnabled(): boolean {
}
}

function boundAudiences(
value: Record<string, string[]>,
): Record<string, string[]> {
const entries = Object.entries(value);
return entries.length <= MAX_PERSISTENT_AGENT_AUDIENCES
? value
: Object.fromEntries(entries.slice(-MAX_PERSISTENT_AGENT_AUDIENCES));
}

function readAudiences(): Record<string, string[]> {
if (typeof window === "undefined") return {};
try {
Expand All @@ -56,7 +66,7 @@ function readAudiences(): Record<string, string[]> {
);
}
}
return result;
return boundAudiences(result);
} catch {
return {};
}
Expand Down Expand Up @@ -129,8 +139,11 @@ export function initializePersistentAgentAudience(
scope: string,
pubkeys: Iterable<string>,
): 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(
Expand All @@ -145,10 +158,20 @@ 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 });
persistAudiences();
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();
Expand Down
Loading
Loading