1+ import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl" ;
2+
13const STORAGE_KEY_PREFIX = "buzz-channel-mutes.v1" ;
24export const MAX_CHANNEL_MUTE_ENTRIES = 500 ;
35
46export type ChannelMuteEntry = {
57 muted : boolean ;
68 updatedAt : number ;
9+ // Per-channel Lamport revision. Breaks a same-second `updatedAt` tie that the
10+ // integer clock cannot resolve. Absent in blobs from an older build ⇒ read as
11+ // 0 (a valid, mergeable value), so the payload stays `version: 1` and older
12+ // builds still parse our blobs.
13+ rev : number ;
714} ;
815
916export type ChannelMuteStore = {
@@ -29,8 +36,8 @@ export function parseMutePayload(json: unknown): ChannelMuteStore | null {
2936 obj . channels !== null &&
3037 ! Array . isArray ( obj . channels )
3138 ? Object . fromEntries (
32- Object . entries ( obj . channels as Record < string , unknown > ) . filter (
33- ( entry ) : entry is [ string , ChannelMuteEntry ] => {
39+ Object . entries ( obj . channels as Record < string , unknown > )
40+ . filter ( ( entry ) : entry is [ string , Record < string , unknown > ] => {
3441 const v = entry [ 1 ] ;
3542 return (
3643 typeof v === "object" &&
@@ -42,8 +49,27 @@ export function parseMutePayload(json: unknown): ChannelMuteStore | null {
4249 ) &&
4350 ( ( v as Record < string , unknown > ) . updatedAt as number ) >= 0
4451 ) ;
45- } ,
46- ) ,
52+ } )
53+ // Normalize `rev`: accept a non-negative integer, otherwise 0. An
54+ // entry is never dropped solely because `rev` is absent (older
55+ // build) or malformed — absence is a valid mergeable value.
56+ . map ( ( [ id , v ] ) => {
57+ const rawRev = v . rev ;
58+ const rev =
59+ typeof rawRev === "number" &&
60+ Number . isInteger ( rawRev ) &&
61+ rawRev >= 0
62+ ? rawRev
63+ : 0 ;
64+ return [
65+ id ,
66+ {
67+ muted : v . muted as boolean ,
68+ updatedAt : v . updatedAt as number ,
69+ rev,
70+ } ,
71+ ] ;
72+ } ) ,
4773 )
4874 : { } ;
4975 return boundMuteStore ( { version : 1 , channels } ) ;
@@ -108,79 +134,47 @@ export function writeChannelMutesStore(
108134 }
109135}
110136
111- export function mergeStores (
112- local : ChannelMuteStore ,
113- remote : ChannelMuteStore ,
114- ) : ChannelMuteStore {
115- return mergeStoresWithTie ( local , remote , false ) ;
116- }
117-
118137/**
119- * Merge a remote store that has already won the event-level canonical tie-break
120- * (`created_at DESC, id ASC`) into the local store, resolving a per-entry
121- * `updatedAt` tie in favour of the *remote* value. Once the comparator has
122- * chosen this remote event as the stored winner, its per-entry values must
123- * survive, or a stale value from a superseded larger-id event delivered first
124- * would win the merge and silently undo the canonical winner. Strictly-newer
125- * local per-entry edits (`l.updatedAt > r.updatedAt`) still win.
138+ * Merge two mute stores by a per-channel total order:
139+ * `updatedAt` DESC → `rev` DESC → `muted === true` wins. This order is
140+ * commutative, associative, and idempotent (before bounding), so every
141+ * observation path (bootstrap, live, reconnect, reconcile, pre-publish,
142+ * cross-window storage) applies it with no ordering or ownership overlay and
143+ * all replicas converge.
144+ *
145+ * `updatedAt` is primary so a strictly-later edit — from any build, whether it
146+ * carries `rev` or (older build) reads `rev: 0` — wins outright. `rev` breaks
147+ * only a same-second `updatedAt` tie: the ambiguous integer-second window the
148+ * clock cannot resolve, where a click that minted `rev = maxSeen + 1` dominates
149+ * any same-second state it observed. On a full tie (equal `updatedAt` AND equal
150+ * `rev`) `true` wins as the deterministic leaf.
126151 */
127- export function mergeApplyingRemote (
128- local : ChannelMuteStore ,
129- remote : ChannelMuteStore ,
130- ) : ChannelMuteStore {
131- return mergeStoresWithTie ( local , remote , true ) ;
132- }
133-
134- function mergeStoresWithTie (
135- local : ChannelMuteStore ,
136- remote : ChannelMuteStore ,
137- preferRemoteOnTie : boolean ,
152+ export function mergeStores (
153+ a : ChannelMuteStore ,
154+ b : ChannelMuteStore ,
138155) : ChannelMuteStore {
139156 const allIds = new Set ( [
140- ...Object . keys ( local . channels ) ,
141- ...Object . keys ( remote . channels ) ,
157+ ...Object . keys ( a . channels ) ,
158+ ...Object . keys ( b . channels ) ,
142159 ] ) ;
143160 const merged : Record < string , ChannelMuteEntry > = { } ;
144161 for ( const id of allIds ) {
145- const l = local . channels [ id ] ;
146- const r = remote . channels [ id ] ;
147- if ( l && r ) {
148- const localWins = preferRemoteOnTie
149- ? l . updatedAt > r . updatedAt
150- : l . updatedAt >= r . updatedAt ;
151- merged [ id ] = localWins ? l : r ;
152- } else {
153- merged [ id ] = ( l ?? r ) as ChannelMuteEntry ;
154- }
162+ const l = a . channels [ id ] ;
163+ const r = b . channels [ id ] ;
164+ merged [ id ] = l && r ? pickMuteEntry ( l , r ) : ( ( l ?? r ) as ChannelMuteEntry ) ;
155165 }
156166 return boundMuteStore ( { version : 1 , channels : merged } ) ;
157167}
158168
159- /**
160- * Apply a canonical lower-id correction (`mergeApplyingRemote`: remote wins a
161- * per-entry `updatedAt` tie) while preserving entries the user changed locally
162- * since the superseded head was applied. The correction canonicalises remote
163- * history, but a same-second local click carries integer-second `updatedAt`
164- * equal to the remote's, so the plain remote-wins tie would silently clobber
165- * it. For each `dirtyId` the local entry wins only the tie (`l.updatedAt >=
166- * r.updatedAt`) — a genuinely newer remote value still wins, so a stale dirty
167- * id can never override a later correction.
168- */
169- export function mergeCanonicalSupersession (
170- local : ChannelMuteStore ,
171- remote : ChannelMuteStore ,
172- dirtyIds : ReadonlySet < string > ,
173- ) : ChannelMuteStore {
174- const applied = mergeApplyingRemote ( local , remote ) ;
175- if ( dirtyIds . size === 0 ) return applied ;
176- const channels = { ...applied . channels } ;
177- for ( const id of dirtyIds ) {
178- const l = local . channels [ id ] ;
179- if ( ! l ) continue ;
180- const r = remote . channels [ id ] ;
181- if ( ! r || l . updatedAt >= r . updatedAt ) channels [ id ] = l ;
182- }
183- return boundMuteStore ( { version : 1 , channels } ) ;
169+ /** The winner of two entries under `updatedAt` → `rev` → `muted` order. */
170+ function pickMuteEntry (
171+ l : ChannelMuteEntry ,
172+ r : ChannelMuteEntry ,
173+ ) : ChannelMuteEntry {
174+ if ( l . updatedAt !== r . updatedAt ) return l . updatedAt > r . updatedAt ? l : r ;
175+ if ( l . rev !== r . rev ) return l . rev > r . rev ? l : r ;
176+ if ( l . muted !== r . muted ) return l . muted ? l : r ;
177+ return l ;
184178}
185179
186180export function mutedChannelIdsFromStore ( store : ChannelMuteStore ) : Set < string > {
@@ -190,3 +184,62 @@ export function mutedChannelIdsFromStore(store: ChannelMuteStore): Set<string> {
190184 . map ( ( [ id ] ) => id ) ,
191185 ) ;
192186}
187+
188+ const OUTBOX_KEY_PREFIX = "buzz-channel-mutes-outbox.v1" ;
189+
190+ // The outbox is a per-relay sync-lane structure (like the watermark), so it is
191+ // relay-scoped even though the main store stays pubkey-only: an edit made
192+ // against relay A must never resume-publish onto relay B after a community
193+ // switch.
194+ function outboxKey ( pubkey : string , relayUrl : string ) : string {
195+ return `${ OUTBOX_KEY_PREFIX } :${ pubkey } :${ encodeURIComponent ( normalizeRelayUrl ( relayUrl ) ) } ` ;
196+ }
197+
198+ /**
199+ * Persist an unpublished edit so it survives quit/community-switch within the
200+ * 2s publish debounce. Written synchronously on every click; cleared once the
201+ * edit is published or found identical to the last published store. Resumed on
202+ * next mount so a durable intent is never silently dropped at teardown.
203+ */
204+ export function writeChannelMutesOutbox (
205+ pubkey : string ,
206+ store : ChannelMuteStore ,
207+ relayUrl : string ,
208+ ) : void {
209+ try {
210+ window . localStorage . setItem (
211+ outboxKey ( pubkey , relayUrl ) ,
212+ JSON . stringify ( boundMuteStore ( store ) ) ,
213+ ) ;
214+ } catch {
215+ // Best-effort durability; the in-memory pendingStore still drives this
216+ // session's publish even if the persisted copy could not be written.
217+ }
218+ }
219+
220+ /** Read a persisted unpublished edit, or null when none/unparseable. */
221+ export function readChannelMutesOutbox (
222+ pubkey : string ,
223+ relayUrl : string ,
224+ ) : ChannelMuteStore | null {
225+ try {
226+ const raw = window . localStorage . getItem ( outboxKey ( pubkey , relayUrl ) ) ;
227+ if ( ! raw ) return null ;
228+ return parseMutePayload ( JSON . parse ( raw ) ) ;
229+ } catch {
230+ return null ;
231+ }
232+ }
233+
234+ /** Clear the persisted outbox (edit published or a no-op). */
235+ export function clearChannelMutesOutbox (
236+ pubkey : string ,
237+ relayUrl : string ,
238+ ) : void {
239+ try {
240+ window . localStorage . removeItem ( outboxKey ( pubkey , relayUrl ) ) ;
241+ } catch {
242+ // Ignore — a stale outbox entry is re-evaluated (and re-cleared if
243+ // identical to the head) on the next publish attempt.
244+ }
245+ }
0 commit comments