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
56 changes: 55 additions & 1 deletion desktop/src/features/sidebar/lib/channelMutesStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,30 @@ export function writeChannelMutesStore(
export function mergeStores(
local: ChannelMuteStore,
remote: ChannelMuteStore,
): ChannelMuteStore {
return mergeStoresWithTie(local, remote, false);
}

/**
* Merge a remote store that has already won the event-level canonical tie-break
* (`created_at DESC, id ASC`) into the local store, resolving a per-entry
* `updatedAt` tie in favour of the *remote* value. Once the comparator has
* chosen this remote event as the stored winner, its per-entry values must
* survive, or a stale value from a superseded larger-id event delivered first
* would win the merge and silently undo the canonical winner. Strictly-newer
* local per-entry edits (`l.updatedAt > r.updatedAt`) still win.
*/
export function mergeApplyingRemote(
local: ChannelMuteStore,
remote: ChannelMuteStore,
): ChannelMuteStore {
return mergeStoresWithTie(local, remote, true);
}

function mergeStoresWithTie(
local: ChannelMuteStore,
remote: ChannelMuteStore,
preferRemoteOnTie: boolean,
): ChannelMuteStore {
const allIds = new Set([
...Object.keys(local.channels),
Expand All @@ -121,14 +145,44 @@ export function mergeStores(
const l = local.channels[id];
const r = remote.channels[id];
if (l && r) {
merged[id] = l.updatedAt >= r.updatedAt ? l : r;
const localWins = preferRemoteOnTie
? l.updatedAt > r.updatedAt
: l.updatedAt >= r.updatedAt;
merged[id] = localWins ? l : r;
} else {
merged[id] = (l ?? r) as ChannelMuteEntry;
}
}
return boundMuteStore({ version: 1, channels: merged });
}

/**
* Apply a canonical lower-id correction (`mergeApplyingRemote`: remote wins a
* per-entry `updatedAt` tie) while preserving entries the user changed locally
* since the superseded head was applied. The correction canonicalises remote
* history, but a same-second local click carries integer-second `updatedAt`
* equal to the remote's, so the plain remote-wins tie would silently clobber
* it. For each `dirtyId` the local entry wins only the tie (`l.updatedAt >=
* r.updatedAt`) — a genuinely newer remote value still wins, so a stale dirty
* id can never override a later correction.
*/
export function mergeCanonicalSupersession(
local: ChannelMuteStore,
remote: ChannelMuteStore,
dirtyIds: ReadonlySet<string>,
): ChannelMuteStore {
const applied = mergeApplyingRemote(local, remote);
if (dirtyIds.size === 0) return applied;
const channels = { ...applied.channels };
for (const id of dirtyIds) {
const l = local.channels[id];
if (!l) continue;
const r = remote.channels[id];
if (!r || l.updatedAt >= r.updatedAt) channels[id] = l;
}
return boundMuteStore({ version: 1, channels });
}

export function mutedChannelIdsFromStore(store: ChannelMuteStore): Set<string> {
return new Set(
Object.entries(store.channels)
Expand Down
54 changes: 54 additions & 0 deletions desktop/src/features/sidebar/lib/channelSectionsStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,57 @@ export function writeChannelSectionsStore(
return false;
}
}

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

function outboxKey(pubkey: string, relayUrl: string): string {
return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}

/**
* Persist an unpublished edit so it survives quit/community-switch within the
* 2s publish debounce. Written synchronously on every edit; cleared once the
* edit is published, superseded by an adopted remote head, or found identical
* to the last published store. Resumed on next mount so a durable intent is
* never silently dropped at teardown.
*/
export function writeChannelSectionsOutbox(
pubkey: string,
store: ChannelSectionStore,
relayUrl: string,
): void {
try {
window.localStorage.setItem(
outboxKey(pubkey, relayUrl),
JSON.stringify(boundChannelSectionsStore(store)),
);
} catch {
// Best-effort durability; the in-memory pendingStore still drives this
// session's publish even if the persisted copy could not be written.
}
}

/** Read a persisted unpublished edit, or null when none/unparseable. */
export function readChannelSectionsOutbox(
pubkey: string,
relayUrl: string,
): ChannelSectionStore | null {
try {
return parseRaw(window.localStorage.getItem(outboxKey(pubkey, relayUrl)));
} catch {
return null;
}
}

/** Clear the persisted outbox (edit published, superseded, or a no-op). */
export function clearChannelSectionsOutbox(
pubkey: string,
relayUrl: string,
): void {
try {
window.localStorage.removeItem(outboxKey(pubkey, relayUrl));
} catch {
// Ignore — a stale outbox entry is re-evaluated (and re-cleared if
// identical to the head) on the next publish attempt.
}
}
Loading
Loading