···105105 const rpc = new Client({ handler: oauthAgent });
106106 const did = oauthAgent.sub;
107107108108- // Fall back to the last-known handle (if any) so offline restores don't
109109- // show a raw DID in the header. Gets overwritten once Slingshot responds.
108108+ // Cached handle covers offline restores; overwritten when Slingshot responds.
110109 let handle = localStorage.getItem(CURRENT_HANDLE_KEY) ?? did;
111110 let pdsUrl = "";
112111 try {
···115114 pdsUrl = doc.pds ?? "";
116115 localStorage.setItem(CURRENT_HANDLE_KEY, handle);
117116 } catch {
118118- // Network may be unavailable (e.g., just resumed from suspend). We'll
119119- // retry on the next tab focus via the visibilitychange listener below.
117117+ // Offline; the visibilitychange listener below will retry on next focus.
120118 }
121119122120 currentAgent = rpc;
+2-7
web/src/lib/bbsModeration.ts
···66import { parseAtUri } from "./util";
77import { isBanRecord, isHideRecord } from "./recordGuards";
8899+// Fields must be JSON-safe — this shape is persisted via localStorage.
910export interface BBSModeration {
1010- bannedDids: Set<string>;
1111- hiddenUris: Set<string>;
1211 /** DID → rkey of that user's ban record on the sysop's PDS. */
1312 banRkeys: Record<string, string>;
1413 /** Post URI → rkey of its hide record on the sysop's PDS. */
···2423 listRecords(pdsUrl, did, HIDE).catch(() => []),
2524 ]);
26252727- const bannedDids = new Set<string>();
2826 const banRkeys: Record<string, string> = {};
2927 for (const record of banRecs) {
3028 if (!isBanRecord(record)) continue;
3131- bannedDids.add(record.value.did);
3229 banRkeys[record.value.did] = parseAtUri(record.uri).rkey;
3330 }
34313535- const hiddenUris = new Set<string>();
3632 const hideRkeys: Record<string, string> = {};
3733 for (const record of hideRecs) {
3834 if (!isHideRecord(record)) continue;
3939- hiddenUris.add(record.value.uri);
4035 hideRkeys[record.value.uri] = parseAtUri(record.uri).rkey;
4136 }
42374343- return { bannedDids, hiddenUris, banRkeys, hideRkeys };
3838+ return { banRkeys, hideRkeys };
4439}
+4-8
web/src/lib/queryPersister.ts
···11import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
2233-// Bump when cache shapes change (lexicon edits, query-key restructures) so
44-// older clients discard incompatible cached data on next load instead of
55-// deserializing into crashes.
66-const BUSTER = "atbbs-v1";
33+// Bump on breaking cache-shape changes to invalidate older clients.
44+const BUSTER = "atbbs-v2";
75const MAX_AGE = 24 * 60 * 60 * 1000;
8697const persister = createSyncStoragePersister({
···1614 buster: BUSTER,
1715 maxAge: MAX_AGE,
1816 dehydrateOptions: {
1919- // Skip fingerprinted thread-page entries. Their keys churn whenever a
2020- // reply is added or deleted, so persisting them just bloats localStorage
2121- // with old-fingerprint garbage. thread-refs is persisted and drives the
2222- // page rebuild on load.
1717+ // thread-page keys are fingerprinted by reply rkeys, so persisting them
1818+ // would accumulate stale entries. thread-refs drives page rebuild on load.
2319 shouldDehydrateQuery: (query: { queryKey: readonly unknown[] }) =>
2420 query.queryKey[0] !== "thread-page",
2521 },
+2-4
web/src/lib/recordGuards.ts
···11-// Type guards for narrowing raw ATRecord.value into typed lexicon records.
22-// Each guard runs the schema's runtime check and, if it passes, narrows the
33-// record so downstream code can access typed fields without `as unknown as ...`
44-// casts.
11+// Type guards that run the lexicon schema's runtime check and narrow the
22+// record so callers can access typed fields directly.
5364import { is } from "@atcute/lexicons/validations";
75import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post";
+2-3
web/src/lib/routes.ts
···11-// Typed path builders for every internal URL. Centralizes encoding so handle
22-// and slug (user-authored) always round-trip safely through the router.
33-// DID and rkey are AT Proto formats with URL-safe character sets.
11+// Internal URL builders. handle/slug are user-authored and encoded;
22+// did/rkey are AT Proto formats with URL-safe character sets.
4354export const bbsUrl = (handle: string) =>
65 `/bbs/${encodeURIComponent(handle)}`;
+2-3
web/src/lib/threadCache.ts
···2525 return refs.slice(start, start + REPLIES_PER_PAGE);
2626}
27272828-// threadPageQuery's key is fingerprinted by reply rkeys, so adding or removing
2929-// a reply changes the cache key. We read the pre-change page data from the old
3030-// key and seed the new key explicitly.
2828+// threadPageQuery's key is fingerprinted by rkeys, so the key changes on
2929+// add/delete — seed the new key from the old one rather than using `prev`.
3130export function appendRefAndReply(
3231 threadUri: string,
3332 newRef: BacklinkRef,
+1-4
web/src/lib/writes.ts
···6363 }
6464}
65656666-// Seed the per-record cache used by getRecord so immediate re-reads (e.g. a
6767-// refetch of profileQuery after putProfile) see the new value instead of the
6868-// pre-write cached entry.
6666+// Sync the per-record cache so re-reads via getRecord return the new value.
6967function syncRecordCache<V extends object>(
7068 did: string,
7169 collection: string,
···143141 },
144142 });
145143 assertOk(resp, "deleteRecord");
146146- // Drop the per-record cache entry from the cache
147144 queryClient.removeQueries({
148145 queryKey: ["record", did, collection, rkey],
149146 exact: true,