mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
P1.1: Both whole-blob storage event handlers (sections + sort) replaced React
state unconditionally from the shared scoped cache, bypassing the
hasPendingEdit() guard that applyRemote uses for relay arrivals. A peer window
writing B1 while A has a pending edit (A1) caused the updater for A2 to derive
from B1, stranding A1's outbox record (Carl r8).
Fix: check hasPendingEdit() in both storage handlers; defer the peer cache
write until the pending edit's debounced publish-or-adopt cycle completes.
When nothing is pending the peer write applies immediately, preserving
cross-window state mirroring for the idle case.
P1.2: parseChannelSectionPayload did not check version; relay decryptAndParse
called it bare, so a future {version: 2} relay head parsed as v1 state instead
of triggering the retain/retry path. Sort, stars, and mutes all reject non-v1
in their parsers; sections was the odd one out.
Fix: add version !== 1 guard at the top of parseChannelSectionPayload. The
local-storage parse path (parseRaw) already checked version before calling the
parser, so no local-storage read is affected.
Tests: two new hook regression tests (sections + sort) for P1.1 using
StorageEvent dispatch; one new manager regression test and one new storage unit
test for P1.2; existing parser tests updated to include version: 1 in
previously version-less test fixtures.
Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
Signed-off-by: Will Pfleger <wpfleger96@gmail.com>
324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
import * as React from "react";
|
||
|
||
import { relayClient } from "@/shared/api/relayClient";
|
||
import {
|
||
boundChannelSortStore,
|
||
clearChannelSortOutbox,
|
||
DEFAULT_STORE,
|
||
markChannelSortLegacyConsumed,
|
||
readChannelSortOutbox,
|
||
readChannelSortStore,
|
||
reclaimSupersededSortOutbox,
|
||
sortModeForGroup,
|
||
storageKey,
|
||
stripOrphanedSectionModes,
|
||
writeChannelSortStore,
|
||
type ChannelSortGroupKey,
|
||
type ChannelSortMode,
|
||
type ChannelSortStore,
|
||
} from "./channelSortPreference";
|
||
import { ChannelSortSyncManager } from "./channelSortSync";
|
||
import type { RemoteSortPrefs } from "./channelSortSync";
|
||
|
||
// Reconciliation cadence (mirrors sections). Steady interval re-fetches the head
|
||
// on a healthy socket so divergence self-heals without a reconnect; the retry
|
||
// window backs off from base to max while the fetch keeps failing.
|
||
const RECONCILE_STEADY_MS = 60_000;
|
||
const RECONCILE_RETRY_BASE_MS = 3_000;
|
||
const RECONCILE_RETRY_MAX_MS = 60_000;
|
||
|
||
/**
|
||
* Persistent per-group sidebar sort preferences, scoped by pubkey + relay so
|
||
* they don't bleed across identities or communities (same scoping as channel
|
||
* sections). Each sidebar grouping (starred, channels, forums, dms, and each
|
||
* custom section) carries its own saved Recent/A–Z mode; unset groups default
|
||
* to A–Z. Mirrors changes made in other windows via the storage event.
|
||
*
|
||
* Preferences sync across clients via encrypted NIP-78 app data (kind 30078,
|
||
* d-tag `channel-sort`), following the channel-sections pattern: localStorage
|
||
* stays the instant/offline cache, the relay blob is the cross-client source
|
||
* of truth, and conflicts resolve with whole-blob last-write-wins.
|
||
*
|
||
* When `liveSectionIds` is provided, writes also prune `section:<id>` entries
|
||
* whose custom section no longer exists, so deleted sections don't leave
|
||
* stale keys in localStorage.
|
||
*/
|
||
export function useChannelSortPreference(
|
||
pubkey: string | undefined,
|
||
relayUrl?: string,
|
||
liveSectionIds?: string[],
|
||
): {
|
||
sortModeFor: (group: ChannelSortGroupKey) => ChannelSortMode;
|
||
setSortModeFor: (group: ChannelSortGroupKey, mode: ChannelSortMode) => void;
|
||
} {
|
||
const [store, setStore] = React.useState<ChannelSortStore>(() => {
|
||
if (!pubkey) return DEFAULT_STORE;
|
||
return readChannelSortStore(pubkey, relayUrl);
|
||
});
|
||
|
||
const managerRef = React.useRef<ChannelSortSyncManager | null>(null);
|
||
const lastAppliedRemoteTs = React.useRef(0);
|
||
const lastAppliedEventId = React.useRef("");
|
||
|
||
React.useEffect(() => {
|
||
if (!pubkey || !relayUrl) {
|
||
setStore(DEFAULT_STORE);
|
||
lastAppliedRemoteTs.current = 0;
|
||
lastAppliedEventId.current = "";
|
||
return;
|
||
}
|
||
setStore(readChannelSortStore(pubkey, relayUrl));
|
||
lastAppliedRemoteTs.current = 0;
|
||
lastAppliedEventId.current = "";
|
||
managerRef.current = new ChannelSortSyncManager(pubkey, relayUrl);
|
||
return () => {
|
||
managerRef.current?.destroy();
|
||
managerRef.current = null;
|
||
};
|
||
}, [pubkey, relayUrl]);
|
||
|
||
React.useEffect(() => {
|
||
if (!pubkey) return;
|
||
const key = storageKey(pubkey, relayUrl);
|
||
const handler = (e: StorageEvent) => {
|
||
if (e.key !== key) return;
|
||
// A peer window wrote to the shared cache. Honour the same pending-aware
|
||
// guard that relay arrivals use in `applyRemote`: if a local whole-blob
|
||
// edit is in flight the manager owns convergence (its debounced
|
||
// publish-or-adopt is the single arbitration point). Applying a peer cache
|
||
// write unconditionally would clobber the optimistic edit and, on the next
|
||
// keystroke, strand the outbox record for the old intent (Carl P1).
|
||
// When nothing is pending the peer write applies immediately, preserving
|
||
// cross-window state mirroring for the idle case.
|
||
if (managerRef.current?.hasPendingEdit()) return;
|
||
setStore(readChannelSortStore(pubkey, relayUrl));
|
||
};
|
||
window.addEventListener("storage", handler);
|
||
return () => {
|
||
window.removeEventListener("storage", handler);
|
||
};
|
||
}, [pubkey, relayUrl]);
|
||
|
||
const applyRemote = React.useCallback(
|
||
(
|
||
remote: RemoteSortPrefs,
|
||
): ((prev: ChannelSortStore) => ChannelSortStore) => {
|
||
return (prev) => {
|
||
if (!pubkey) return prev;
|
||
// A pending local edit owns convergence: its debounced publish re-checks
|
||
// the head and either wins (publish) or loses (adopt, which routes back
|
||
// through onRemoteAdopted with pending already cleared). Never let a
|
||
// passive remote arrival clobber the optimistic edit or strand its
|
||
// durable outbox. The adopt path clears pending before calling us, so
|
||
// this guard is false there and the winning remote still writes through.
|
||
if (managerRef.current?.hasPendingEdit()) return prev;
|
||
if (remote.createdAt < lastAppliedRemoteTs.current) return prev;
|
||
// Equal timestamps: the relay/database break ties by `id ASC` — the
|
||
// LOWEST event id is the canonical winner. Apply a strictly-lower id and
|
||
// ignore any id >= the last applied, so the UI converges on the same
|
||
// event the relay stored rather than the largest id seen.
|
||
if (
|
||
remote.createdAt === lastAppliedRemoteTs.current &&
|
||
remote.eventId >= lastAppliedEventId.current
|
||
)
|
||
return prev;
|
||
lastAppliedRemoteTs.current = remote.createdAt;
|
||
lastAppliedEventId.current = remote.eventId;
|
||
if (!writeChannelSortStore(pubkey, remote.store, relayUrl)) return prev;
|
||
return remote.store;
|
||
};
|
||
},
|
||
[pubkey, relayUrl],
|
||
);
|
||
|
||
React.useEffect(() => {
|
||
if (!pubkey || !relayUrl) return;
|
||
const manager = managerRef.current;
|
||
if (!manager) return;
|
||
// When a local edit loses whole-blob LWW (pre-publish head is newer), the
|
||
// manager adopts the winning remote store. Write it through to React state +
|
||
// localStorage so the UI and relay never diverge; applyRemote also advances
|
||
// the applied-ts guard.
|
||
manager.setOnRemoteAdopted((remote) => {
|
||
setStore(applyRemote(remote));
|
||
});
|
||
}, [pubkey, relayUrl, applyRemote]);
|
||
|
||
React.useEffect(() => {
|
||
if (!pubkey || !relayUrl) return;
|
||
let cancelled = false;
|
||
const local = readChannelSortStore(pubkey, relayUrl);
|
||
void managerRef.current?.bootstrap(local).then((result) => {
|
||
if (cancelled) return;
|
||
if (result.action === "apply-remote") {
|
||
setStore(applyRemote(result.data));
|
||
}
|
||
// "hold": seed already performed by bootstrap (if first-sync), or blocked
|
||
// (failed fetch / prior watermark). Resume any edit persisted to the
|
||
// durable outbox before a prior quit/community-switch. Replay runs BEFORE
|
||
// reclamation so a same-second record the head appears to supersede is
|
||
// consumed into pending here and can never be GC'd out.
|
||
const outbox = readChannelSortOutbox(pubkey, relayUrl);
|
||
if (outbox) {
|
||
// publishSortPrefs synchronously copies the intent into this window's
|
||
// own v2 key and returns whether that transfer is durable. Mark the
|
||
// legacy blob consumed ONLY when it is: a `setItem` failure (quota)
|
||
// returns false, so the marker is not written and the legacy record
|
||
// stays replayable on a later boot rather than being silently
|
||
// suppressed (Thufir pass-3 finding). A crash between a durable
|
||
// transfer and the marker write replays the legacy blob once more, a
|
||
// crash after resumes it from the v2 key. The marker is what stops the
|
||
// never-deleted legacy key republishing above the head every boot
|
||
// (Thufir pass-2 resurrection finding).
|
||
const durable = managerRef.current?.publishSortPrefs(outbox.store);
|
||
if (durable && outbox.legacyRawToConsume !== null) {
|
||
markChannelSortLegacyConsumed(
|
||
pubkey,
|
||
relayUrl,
|
||
outbox.legacyRawToConsume,
|
||
);
|
||
}
|
||
} else {
|
||
clearChannelSortOutbox(pubkey, relayUrl);
|
||
}
|
||
if (result.action === "apply-remote") {
|
||
// Head fetch succeeded: reclaim any foreign window's write-once outbox
|
||
// key the head STRICTLY supersedes (`queuedAt` < head `created_at`).
|
||
// Gated on the fetched head; records are immutable so no recheck is
|
||
// needed and a live peer's edit queued at/after the head is kept. A
|
||
// `hold` (absent/failed) reclaims none.
|
||
reclaimSupersededSortOutbox(pubkey, relayUrl, result.data.createdAt);
|
||
}
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [pubkey, relayUrl, applyRemote]);
|
||
|
||
// Reconciliation loop (mirrors sections): a single scheduler that both retries
|
||
// a failed bootstrap with bounded backoff and periodically re-fetches the
|
||
// head, so stale-at-open state converges without waiting for a reconnect event
|
||
// a healthy socket never fires. Also refreshes when the window becomes visible.
|
||
React.useEffect(() => {
|
||
if (!pubkey || !relayUrl) return;
|
||
let cancelled = false;
|
||
let timer: number | null = null;
|
||
let delayMs = RECONCILE_RETRY_BASE_MS;
|
||
|
||
const schedule = (ms: number) => {
|
||
if (cancelled) return;
|
||
if (timer !== null) window.clearTimeout(timer);
|
||
timer = window.setTimeout(tick, ms);
|
||
};
|
||
|
||
const tick = () => {
|
||
void managerRef.current?.fetchRemoteSortPrefs().then((result) => {
|
||
if (cancelled) return;
|
||
if (result.status === "found") {
|
||
// applyRemote defers to a pending local edit (whose own debounced
|
||
// publish converges via publish-or-adopt), so a periodic reconcile
|
||
// can never drop it — no re-queue needed.
|
||
setStore(applyRemote(result.data));
|
||
delayMs = RECONCILE_STEADY_MS; // relay answered → steady cadence
|
||
} else if (result.status === "absent") {
|
||
delayMs = RECONCILE_STEADY_MS; // answered (no blob) → steady cadence
|
||
} else {
|
||
delayMs = Math.min(delayMs * 2, RECONCILE_RETRY_MAX_MS); // fetch failed → back off
|
||
}
|
||
schedule(delayMs);
|
||
});
|
||
};
|
||
|
||
const onVisible = () => {
|
||
if (document.visibilityState === "visible") {
|
||
delayMs = RECONCILE_RETRY_BASE_MS;
|
||
tick();
|
||
}
|
||
};
|
||
document.addEventListener("visibilitychange", onVisible);
|
||
schedule(delayMs);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
if (timer !== null) window.clearTimeout(timer);
|
||
document.removeEventListener("visibilitychange", onVisible);
|
||
};
|
||
}, [pubkey, relayUrl, applyRemote]);
|
||
|
||
React.useEffect(() => {
|
||
if (!pubkey) return;
|
||
let unsub: (() => Promise<void>) | null = null;
|
||
let cancelled = false;
|
||
void managerRef.current
|
||
?.subscribeToSortPrefs((remote) => {
|
||
if (cancelled) return;
|
||
setStore(applyRemote(remote));
|
||
})
|
||
.then((dispose) => {
|
||
if (cancelled) {
|
||
void dispose();
|
||
} else {
|
||
unsub = dispose;
|
||
}
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
if (unsub) void unsub();
|
||
};
|
||
}, [pubkey, applyRemote]);
|
||
|
||
React.useEffect(() => {
|
||
if (!pubkey) return;
|
||
let cancelled = false;
|
||
const unsub = relayClient.subscribeToReconnects(() => {
|
||
void managerRef.current?.fetchRemoteSortPrefs().then((result) => {
|
||
if (cancelled) return;
|
||
if (result.status === "found") {
|
||
setStore(applyRemote(result.data));
|
||
}
|
||
// Wake the existing pending edit's cycle rather than re-queueing it via
|
||
// publishSortPrefs(): a re-queue would bump the generation and reset the
|
||
// frozen publishBaseline to the just-fetched head, so a remote that won
|
||
// LWW while the edit was pending would be published over instead of
|
||
// adopted (Carl P1). retryPendingPublish keeps the baseline, so the
|
||
// pre-publish check still adopts a genuinely-advanced remote.
|
||
managerRef.current?.retryPendingPublish();
|
||
});
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
unsub();
|
||
};
|
||
}, [pubkey, applyRemote]);
|
||
|
||
const sortModeFor = React.useCallback(
|
||
(group: ChannelSortGroupKey) => sortModeForGroup(store, group),
|
||
[store],
|
||
);
|
||
|
||
const setSortModeFor = React.useCallback(
|
||
(group: ChannelSortGroupKey, mode: ChannelSortMode) => {
|
||
if (!pubkey) return;
|
||
setStore((prev) => {
|
||
const withUpdate: ChannelSortStore = {
|
||
...prev,
|
||
groups: { ...prev.groups, [group]: mode },
|
||
};
|
||
// 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 = boundChannelSortStore(
|
||
liveSectionIds
|
||
? stripOrphanedSectionModes(withUpdate, liveSectionIds)
|
||
: withUpdate,
|
||
);
|
||
if (!writeChannelSortStore(pubkey, next, relayUrl)) return prev;
|
||
managerRef.current?.publishSortPrefs(next);
|
||
return next;
|
||
});
|
||
},
|
||
[pubkey, relayUrl, liveSectionIds],
|
||
);
|
||
|
||
return { sortModeFor, setSortModeFor };
|
||
}
|