Skip to content
Open
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
496 changes: 284 additions & 212 deletions desktop/src/features/sidebar/lib/channelMutesStorage.test.mjs

Large diffs are not rendered by default.

221 changes: 198 additions & 23 deletions desktop/src/features/sidebar/lib/channelMutesStorage.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
import {
clearOwnOutbox,
enumerateOutbox,
reclaimOutbox,
writeOwnOutbox,
} from "./sidebarSyncWatermark";

const STORAGE_KEY_PREFIX = "buzz-channel-mutes.v1";
export const MAX_CHANNEL_MUTE_ENTRIES = 500;

export type ChannelMuteEntry = {
muted: boolean;
updatedAt: number;
// Per-channel Lamport revision. Breaks a same-second `updatedAt` tie that the
// integer clock cannot resolve. Absent in blobs from an older build ⇒ read as
// 0 (a valid, mergeable value), so the payload stays `version: 1` and older
// builds still parse our blobs.
rev: number;
};

export type ChannelMuteStore = {
Expand All @@ -29,8 +42,8 @@ export function parseMutePayload(json: unknown): ChannelMuteStore | null {
obj.channels !== null &&
!Array.isArray(obj.channels)
? Object.fromEntries(
Object.entries(obj.channels as Record<string, unknown>).filter(
(entry): entry is [string, ChannelMuteEntry] => {
Object.entries(obj.channels as Record<string, unknown>)
.filter((entry): entry is [string, Record<string, unknown>] => {
const v = entry[1];
return (
typeof v === "object" &&
Expand All @@ -42,8 +55,27 @@ export function parseMutePayload(json: unknown): ChannelMuteStore | null {
) &&
((v as Record<string, unknown>).updatedAt as number) >= 0
);
},
),
})
// Normalize `rev`: accept a non-negative integer, otherwise 0. An
// entry is never dropped solely because `rev` is absent (older
// build) or malformed — absence is a valid mergeable value.
.map(([id, v]) => {
const rawRev = v.rev;
const rev =
typeof rawRev === "number" &&
Number.isInteger(rawRev) &&
rawRev >= 0
? rawRev
: 0;
return [
id,
{
muted: v.muted as boolean,
updatedAt: v.updatedAt as number,
rev,
},
];
}),
)
: {};
return boundMuteStore({ version: 1, channels });
Expand Down Expand Up @@ -93,40 +125,74 @@ export function boundMuteStore(
};
}

/**
* Persist the main store. Writes the passed store as-is (bounded) — no read of
* the shared key, so it is never a shared-key read-modify-write. Callers merge
* peer state into the window's OWN React state (via the storage-event handler
* and applyRemote) before calling here, so the write carries an owned, merged
* value. Returns the bounded store, or `null` on write failure.
*
* Cross-window convergence of the on-disk cache is eventual: a peer's storage
* event folds into this window's state, and the relay reconcile writes the
* merged head back. Durable no-loss of an unpublished click is held by the
* per-window outbox, not this cache.
*/
export function writeChannelMutesStore(
pubkey: string,
store: ChannelMuteStore,
): boolean {
preservedKey?: string,
): ChannelMuteStore | null {
try {
window.localStorage.setItem(
storageKey(pubkey),
JSON.stringify(boundMuteStore(store)),
);
return true;
const bounded = boundMuteStore(store, preservedKey);
window.localStorage.setItem(storageKey(pubkey), JSON.stringify(bounded));
return bounded;
} catch {
return false;
return null;
}
}

/**
* Merge two mute stores by a per-channel total order:
* `updatedAt` DESC → `rev` DESC → `muted === true` wins. This order is
* commutative, associative, and idempotent (before bounding), so every
* observation path (bootstrap, live, reconnect, reconcile, pre-publish,
* cross-window storage) applies it with no ordering or ownership overlay and
* all replicas converge.
*
* `updatedAt` is primary so a strictly-later edit — from any build, whether it
* carries `rev` or (older build) reads `rev: 0` — wins outright. `rev` breaks
* only a same-second `updatedAt` tie: the ambiguous integer-second window the
* clock cannot resolve, where a click that minted `rev = maxSeen + 1` dominates
* any same-second state it observed. On a full tie (equal `updatedAt` AND equal
* `rev`) `true` wins as the deterministic leaf.
*/
export function mergeStores(
local: ChannelMuteStore,
remote: ChannelMuteStore,
a: ChannelMuteStore,
b: ChannelMuteStore,
preservedKey?: string,
): ChannelMuteStore {
const allIds = new Set([
...Object.keys(local.channels),
...Object.keys(remote.channels),
...Object.keys(a.channels),
...Object.keys(b.channels),
]);
const merged: Record<string, ChannelMuteEntry> = {};
for (const id of allIds) {
const l = local.channels[id];
const r = remote.channels[id];
if (l && r) {
merged[id] = l.updatedAt >= r.updatedAt ? l : r;
} else {
merged[id] = (l ?? r) as ChannelMuteEntry;
}
const l = a.channels[id];
const r = b.channels[id];
merged[id] = l && r ? pickMuteEntry(l, r) : ((l ?? r) as ChannelMuteEntry);
}
return boundMuteStore({ version: 1, channels: merged });
return boundMuteStore({ version: 1, channels: merged }, preservedKey);
}

/** The winner of two entries under `updatedAt` → `rev` → `muted` order. */
function pickMuteEntry(
l: ChannelMuteEntry,
r: ChannelMuteEntry,
): ChannelMuteEntry {
if (l.updatedAt !== r.updatedAt) return l.updatedAt > r.updatedAt ? l : r;
if (l.rev !== r.rev) return l.rev > r.rev ? l : r;
if (l.muted !== r.muted) return l.muted ? l : r;
return l;
}

export function mutedChannelIdsFromStore(store: ChannelMuteStore): Set<string> {
Expand All @@ -136,3 +202,112 @@ export function mutedChannelIdsFromStore(store: ChannelMuteStore): Set<string> {
.map(([id]) => id),
);
}

const OUTBOX_KEY_PREFIX = "buzz-channel-mutes-outbox.v1";

// The single shared key written by builds before the outbox was keyed
// per-window. Enumerated as one more record so an edit persisted by a prior
// build still resumes, and reclaimed by the same relay-gated rule.
function legacyOutboxKey(pubkey: string, relayUrl: string): string {
return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}

/**
* Persist this window's unpublished edit under its own outbox key. Written
* synchronously on every click as a single unconditional `setItem` (no shared-
* key read-modify-write); resumed by merging every window's record on next
* mount so a click made <2s before quit/community-switch is never dropped.
*/
export function writeChannelMutesOutbox(
pubkey: string,
store: ChannelMuteStore,
relayUrl: string,
): void {
writeOwnOutbox(OUTBOX_KEY_PREFIX, pubkey, relayUrl, boundMuteStore(store));
}

/**
* Merge every window's persisted unpublished edit into one store for resume, or
* null when none exists. Per-entry `mergeStores` is order-independent, so two
* windows' concurrent clicks on different channels both survive.
*/
export function readChannelMutesOutbox(
pubkey: string,
relayUrl: string,
): ChannelMuteStore | null {
const records = enumerateOutbox(
OUTBOX_KEY_PREFIX,
legacyOutboxKey(pubkey, relayUrl),
pubkey,
relayUrl,
parseMutePayload,
);
if (records.length === 0) return null;
return records.reduce<ChannelMuteStore>(
(acc, r) => mergeStores(acc, r.store),
DEFAULT_STORE,
);
}

/** Clear this window's own outbox key (its edit published or is a no-op). */
export function clearChannelMutesOutbox(
pubkey: string,
relayUrl: string,
): void {
clearOwnOutbox(OUTBOX_KEY_PREFIX, pubkey, relayUrl);
}

/**
* True when the fetched relay `head` already reflects every entry in
* `candidate` — merging the candidate into the head leaves it unchanged. Used
* both to reclaim a subsumed foreign key and to skip a redundant boot-time
* replay publish of a fold the head already carries (e.g. only the
* never-deleted legacy key lingers).
*/
export function isMutesStoreSubsumedBy(
candidate: ChannelMuteStore,
head: ChannelMuteStore,
): boolean {
return muteStoresEqual(mergeStores(head, candidate), head);
}

/**
* Reclaim foreign outbox keys the fetched relay head already subsumes: a record
* is redundant when merging it into `head` yields `head` unchanged (the head
* carries an entry at least as new for every channel). Never touches this
* window's own key; a still-unpublished peer edit the head does not yet reflect
* is kept. Call only after a successful head fetch.
*/
export function reclaimSubsumedMutesOutbox(
pubkey: string,
relayUrl: string,
head: ChannelMuteStore,
): void {
reclaimOutbox(
OUTBOX_KEY_PREFIX,
legacyOutboxKey(pubkey, relayUrl),
pubkey,
relayUrl,
parseMutePayload,
(record) => isMutesStoreSubsumedBy(record.store, head),
);
}

/** Deep per-channel equality of two mute stores (order-independent). */
function muteStoresEqual(a: ChannelMuteStore, b: ChannelMuteStore): boolean {
const aKeys = Object.keys(a.channels);
const bKeys = Object.keys(b.channels);
if (aKeys.length !== bKeys.length) return false;
for (const id of aKeys) {
const l = a.channels[id];
const r = b.channels[id];
if (
!r ||
l.muted !== r.muted ||
l.updatedAt !== r.updatedAt ||
l.rev !== r.rev
)
return false;
}
return true;
}
Loading
Loading