···2233import { fetchAndHydrate, listRecords } from "./atproto";
44import { POST } from "./lexicon";
55-import { is } from "@atcute/lexicons/validations";
66-import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post";
77-import type { XyzAtbbsPost } from "../lexicons";
55+import { isPostRecord } from "./recordGuards";
8697export interface ActivityItem {
108 type: "reply" | "parent_reply";
···4947): Promise<ActivityItem[]> {
5048 const SCAN_LIMIT = 50;
5149 const allPosts = await listRecords(pdsUrl, did, POST, SCAN_LIMIT);
5252- const validPosts = allPosts.filter((record) => is(postSchema, record.value));
5050+ const validPosts = allPosts.filter(isPostRecord);
53515454- const rootPosts = validPosts.filter(
5555- (record) => !(record.value as Record<string, unknown>).root,
5656- );
5757- const replyPosts = validPosts.filter(
5858- (record) => !!(record.value as Record<string, unknown>).root,
5959- );
5252+ const rootPosts = validPosts.filter((record) => !record.value.root);
5353+ const replyPosts = validPosts.filter((record) => !!record.value.root);
60546155 const results = await Promise.all([
6262- ...rootPosts.map((post) => {
6363- const value = post.value as unknown as XyzAtbbsPost.Main;
6464- return fetchBacklinkItems(
5656+ ...rootPosts.map((post) =>
5757+ fetchBacklinkItems(
6558 post.uri,
6659 `${POST}:root`,
6760 did,
6861 "reply",
6969- value.title ?? "",
6262+ post.value.title ?? "",
7063 post.uri,
7171- );
7272- }),
7373- ...replyPosts.map((reply) => {
7474- const value = reply.value as unknown as XyzAtbbsPost.Main;
7575- return fetchBacklinkItems(
6464+ ),
6565+ ),
6666+ ...replyPosts.map((reply) =>
6767+ fetchBacklinkItems(
7668 reply.uri,
7769 `${POST}:parent`,
7870 did,
7971 "parent_reply",
8072 "",
8181- value.root ?? "",
8282- );
8383- }),
7373+ reply.value.root ?? "",
7474+ ),
7575+ ),
8476 ]);
85778678 // Deduplicate — prefer "parent-reply" type when the same reply appears as both.
+5-8
web/src/lib/bbs.ts
···99import { queryClient } from "./queryClient";
1010import { SITE } from "./lexicon";
1111import { parseAtUri } from "./util";
1212-import { is } from "@atcute/lexicons/validations";
1313-import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site";
1414-import { mainSchema as boardSchema } from "../lexicons/types/xyz/atbbs/board";
1515-import type { XyzAtbbsSite, XyzAtbbsBoard } from "../lexicons";
1212+import { isBoardRecord, isSiteRecord } from "./recordGuards";
16131714export class BBSNotFoundError extends Error {}
1815export class NoBBSError extends Error {}
···7774 throw new NoBBSError(`${handle} isn't running a BBS.`);
7875 }
79768080- if (!is(siteSchema, siteRecord.value)) {
7777+ if (!isSiteRecord(siteRecord)) {
8178 throw new NoBBSError(`${handle} has an invalid site record.`);
8279 }
8383- const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main;
8080+ const siteValue = siteRecord.value;
8481 const boardUris: string[] = siteValue.boards ?? [];
85828683 const boardResults = await Promise.allSettled(
···9390 const boards: Board[] = [];
9491 boardResults.forEach((result, index) => {
9592 if (result.status !== "fulfilled") return;
9696- if (!is(boardSchema, result.value.value)) return;
9797- const board = result.value.value as unknown as XyzAtbbsBoard.Main;
9393+ if (!isBoardRecord(result.value)) return;
9494+ const board = result.value.value;
9895 const parsed = parseAtUri(boardUris[index]);
9996 boards.push({
10097 slug: parsed.rkey,
+7-12
web/src/lib/bbsModeration.ts
···44import { listRecords } from "./atproto";
55import { BAN, HIDE } from "./lexicon";
66import { parseAtUri } from "./util";
77-import { is } from "@atcute/lexicons/validations";
88-import { mainSchema as banSchema } from "../lexicons/types/xyz/atbbs/ban";
99-import { mainSchema as hideSchema } from "../lexicons/types/xyz/atbbs/hide";
1010-import type { XyzAtbbsBan, XyzAtbbsHide } from "../lexicons";
77+import { isBanRecord, isHideRecord } from "./recordGuards";
118129export interface BBSModeration {
1310 bannedDids: Set<string>;
···3027 const bannedDids = new Set<string>();
3128 const banRkeys: Record<string, string> = {};
3229 for (const record of banRecs) {
3333- if (!is(banSchema, record.value)) continue;
3434- const value = record.value as unknown as XyzAtbbsBan.Main;
3535- bannedDids.add(value.did);
3636- banRkeys[value.did] = parseAtUri(record.uri).rkey;
3030+ if (!isBanRecord(record)) continue;
3131+ bannedDids.add(record.value.did);
3232+ banRkeys[record.value.did] = parseAtUri(record.uri).rkey;
3733 }
38343935 const hiddenUris = new Set<string>();
4036 const hideRkeys: Record<string, string> = {};
4137 for (const record of hideRecs) {
4242- if (!is(hideSchema, record.value)) continue;
4343- const value = record.value as unknown as XyzAtbbsHide.Main;
4444- hiddenUris.add(value.uri);
4545- hideRkeys[value.uri] = parseAtUri(record.uri).rkey;
3838+ if (!isHideRecord(record)) continue;
3939+ hiddenUris.add(record.value.uri);
4040+ hideRkeys[record.value.uri] = parseAtUri(record.uri).rkey;
4641 }
47424843 return { bannedDids, hiddenUris, banRkeys, hideRkeys };
+11-17
web/src/lib/boardThreads.ts
···1515} from "./atproto";
1616import { POST, BOARD } from "./lexicon";
1717import { makeAtUri, parseAtUri } from "./util";
1818-import { is } from "@atcute/lexicons/validations";
1919-import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post";
2020-import type { XyzAtbbsPost } from "../lexicons";
1818+import { isPostRecord } from "./recordGuards";
21192220export interface Participant {
2321 did: string;
···70687169 const records = await getRecordsBatch(backlinks.records);
7270 for (const record of records) {
7373- if (!is(postSchema, record.value)) continue;
7474- const value = record.value as unknown as XyzAtbbsPost.Main;
7575- const threadUri = value.root ?? record.uri;
7171+ if (!isPostRecord(record)) continue;
7272+ const threadUri = record.value.root ?? record.uri;
7673 if (!lastActivity.has(threadUri)) {
7777- lastActivity.set(threadUri, value.createdAt);
7474+ lastActivity.set(threadUri, record.value.createdAt);
7875 }
7976 let posters = postersByThread.get(threadUri);
8077 if (!posters) {
···9188 const threadUris = [...lastActivity.keys()].slice(0, PAGE_SIZE);
9289 const rootRecords = await getRecordsByUri(threadUris);
93909494- const validRoots = rootRecords.filter((record) => {
9595- if (!is(postSchema, record.value)) return false;
9696- const value = record.value as unknown as XyzAtbbsPost.Main;
9797- return value.title && !value.root;
9898- });
9191+ const validRoots = rootRecords
9292+ .filter(isPostRecord)
9393+ .filter((record) => record.value.title && !record.value.root);
999410095 const allDids = new Set<string>();
10196 for (const record of validRoots) {
···117112 .filter((record) => parseAtUri(record.uri).did in identities)
118113 .map((record) => {
119114 const { did, rkey } = parseAtUri(record.uri);
120120- const value = record.value as unknown as XyzAtbbsPost.Main;
121115 const posterDids = postersByThread.get(record.uri) ?? new Set([did]);
122116 const participants: Participant[] = [...posterDids]
123117 .filter((posterDid) => posterDid in identities)
···131125 did,
132126 rkey,
133127 handle: identities[did].handle,
134134- title: value.title ?? "",
135135- body: value.body,
136136- createdAt: value.createdAt,
137137- lastActivityAt: lastActivity.get(record.uri) ?? value.createdAt,
128128+ title: record.value.title ?? "",
129129+ body: record.value.body,
130130+ createdAt: record.value.createdAt,
131131+ lastActivityAt: lastActivity.get(record.uri) ?? record.value.createdAt,
138132 replyCount: replyCounts[record.uri] ?? 0,
139133 participants,
140134 };
+4-7
web/src/lib/discovery.ts
···33import { getAvatars, getRecord, resolveIdentitiesBatch } from "./atproto";
44import { SITE } from "./lexicon";
55import { SERVICES } from "./shared";
66-import { is } from "@atcute/lexicons/validations";
77-import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site";
88-import type { XyzAtbbsSite } from "../lexicons";
66+import { isSiteRecord } from "./recordGuards";
97108export interface DiscoveredBBS {
119 did: string;
···4240 if (!(repo.did in identities)) continue;
4341 try {
4442 const siteRecord = await getRecord(repo.did, SITE, "self");
4545- if (!is(siteSchema, siteRecord.value)) continue;
4646- const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main;
4343+ if (!isSiteRecord(siteRecord)) continue;
4744 items.push({
4845 did: repo.did,
4946 handle: identities[repo.did].handle,
5050- name: siteValue.name || identities[repo.did].handle,
5151- description: siteValue.description || "",
4747+ name: siteRecord.value.name || identities[repo.did].handle,
4848+ description: siteRecord.value.description || "",
5249 });
5350 } catch {
5451 continue;
+8-17
web/src/lib/mythreads.ts
···33import { listRecords, resolveIdentitiesBatch } from "./atproto";
44import { POST } from "./lexicon";
55import { parseAtUri } from "./util";
66-import { is } from "@atcute/lexicons/validations";
77-import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post";
88-import type { XyzAtbbsPost } from "../lexicons";
66+import { isPostRecord } from "./recordGuards";
97108export interface MyThread {
119 uri: string;
···2321): Promise<MyThread[]> {
2422 const records = await listRecords(pdsUrl, did, POST);
2523 const rootPosts = records
2626- .filter((record) => is(postSchema, record.value))
2727- .filter((record) => {
2828- const value = record.value as Record<string, unknown>;
2929- return !value.root && value.title; // root posts with titles = threads
3030- });
2424+ .filter(isPostRecord)
2525+ .filter((record) => !record.value.root && record.value.title);
3126 if (!rootPosts.length) return [];
32273328 const bbsDids = new Set(
3434- rootPosts.map((record) => {
3535- const value = record.value as unknown as XyzAtbbsPost.Main;
3636- return parseAtUri(value.scope).did;
3737- }),
2929+ rootPosts.map((record) => parseAtUri(record.value.scope).did),
3830 );
3931 const identities = await resolveIdentitiesBatch([...bbsDids]);
40324133 const results: MyThread[] = [];
4234 for (const record of rootPosts) {
4343- const value = record.value as unknown as XyzAtbbsPost.Main;
4444- const bbsDid = parseAtUri(value.scope).did;
3535+ const bbsDid = parseAtUri(record.value.scope).did;
4536 const identity = identities[bbsDid];
4637 if (!identity) continue;
4738 results.push({
4839 uri: record.uri,
4940 rkey: parseAtUri(record.uri).rkey,
5050- title: value.title ?? "",
5151- body: value.body,
5252- createdAt: value.createdAt,
4141+ title: record.value.title ?? "",
4242+ body: record.value.body,
4343+ createdAt: record.value.createdAt,
5344 bbsDid,
5445 bbsHandle: identity.handle,
5546 });
+11-19
web/src/lib/news.ts
···33import { getBacklinks, getRecordsBatch } from "./atproto";
44import { POST, SITE } from "./lexicon";
55import { makeAtUri, parseAtUri } from "./util";
66-import { is } from "@atcute/lexicons/validations";
77-import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post";
88-import type { XyzAtbbsPost } from "../lexicons";
66+import { isPostRecord } from "./recordGuards";
97import type { NewsPost } from "./bbs";
108119export async function fetchNews(bbsDid: string): Promise<NewsPost[]> {
···1917 const records = await getRecordsBatch(sysopRefs);
20182119 const news: NewsPost[] = records
2222- .filter((record) => is(postSchema, record.value))
2323- .filter((record) => {
2424- const value = record.value as unknown as XyzAtbbsPost.Main;
2525- return value.title && !value.root;
2626- })
2727- .map((record) => {
2828- const value = record.value as unknown as XyzAtbbsPost.Main;
2929- return {
3030- uri: record.uri,
3131- rkey: parseAtUri(record.uri).rkey,
3232- title: value.title ?? "",
3333- body: value.body,
3434- createdAt: value.createdAt,
3535- attachments: value.attachments as NewsPost["attachments"],
3636- };
3737- });
2020+ .filter(isPostRecord)
2121+ .filter((record) => record.value.title && !record.value.root)
2222+ .map((record) => ({
2323+ uri: record.uri,
2424+ rkey: parseAtUri(record.uri).rkey,
2525+ title: record.value.title ?? "",
2626+ body: record.value.body,
2727+ createdAt: record.value.createdAt,
2828+ attachments: record.value.attachments as NewsPost["attachments"],
2929+ }));
38303931 news.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
4032 return news;
+10-17
web/src/lib/pins.ts
···77 resolveIdentitiesBatch,
88} from "./atproto";
99import { PIN, SITE } from "./lexicon";
1010-import { is } from "@atcute/lexicons/validations";
1111-import { mainSchema as pinSchema } from "../lexicons/types/xyz/atbbs/pin";
1212-import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site";
1313-import type { XyzAtbbsPin, XyzAtbbsSite } from "../lexicons";
1010+import { isPinRecord, isSiteRecord } from "./recordGuards";
1411import { parseAtUri } from "./util";
15121613export interface PinnedBBS {
···2724 did: string,
2825): Promise<PinnedBBS[]> {
2926 const records = await listRecords(pdsUrl, did, PIN);
3030- const pinRecords = records.filter((record) => is(pinSchema, record.value));
2727+ const pinRecords = records.filter(isPinRecord);
31283232- const pinnedDids = pinRecords.map(
3333- (record) => (record.value as unknown as XyzAtbbsPin.Main).did,
3434- );
2929+ const pinnedDids = pinRecords.map((record) => record.value.did);
3530 if (!pinnedDids.length) return [];
36313732 const [identities, siteResults, avatars] = await Promise.all([
···4540 const siteNames: Record<string, string> = {};
4641 siteResults.forEach((result, index) => {
4742 if (result.status !== "fulfilled") return;
4848- if (!is(siteSchema, result.value.value)) return;
4949- const siteValue = result.value.value as unknown as XyzAtbbsSite.Main;
5050- siteNames[pinnedDids[index]] = siteValue.name;
4343+ if (!isSiteRecord(result.value)) return;
4444+ siteNames[pinnedDids[index]] = result.value.value.name;
5145 });
52465347 const results: PinnedBBS[] = [];
5448 for (const record of pinRecords) {
5555- const value = record.value as unknown as XyzAtbbsPin.Main;
5656- const identity = identities[value.did];
4949+ const identity = identities[record.value.did];
5750 if (!identity) continue;
5851 results.push({
5959- did: value.did,
5252+ did: record.value.did,
6053 rkey: parseAtUri(record.uri).rkey,
6154 handle: identity.handle,
6262- name: siteNames[value.did] ?? identity.handle,
6363- createdAt: value.createdAt,
6464- avatar: avatars[value.did],
5555+ name: siteNames[record.value.did] ?? identity.handle,
5656+ createdAt: record.value.createdAt,
5757+ avatar: avatars[record.value.did],
6558 });
6659 }
6760 results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+5-8
web/src/lib/profile.ts
···2233import { getAvatar, getRecord, resolveIdentity } from "./atproto";
44import { PROFILE, SITE } from "./lexicon";
55-import { is } from "@atcute/lexicons/validations";
66-import { mainSchema as profileSchema } from "../lexicons/types/xyz/atbbs/profile";
77-import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site";
88-import type { XyzAtbbsProfile, XyzAtbbsSite } from "../lexicons";
55+import { isProfileRecord, isSiteRecord } from "./recordGuards";
96107export interface Profile {
118 did: string;
···45424643 if (
4744 profileResult.status === "fulfilled" &&
4848- is(profileSchema, profileResult.value.value)
4545+ isProfileRecord(profileResult.value)
4946 ) {
5050- const value = profileResult.value.value as unknown as XyzAtbbsProfile.Main;
4747+ const value = profileResult.value.value;
5148 profile.name = value.name;
5249 profile.pronouns = value.pronouns;
5350 profile.bio = value.bio;
···56535754 if (
5855 siteResult.status === "fulfilled" &&
5959- is(siteSchema, siteResult.value.value)
5656+ isSiteRecord(siteResult.value)
6057 ) {
6161- const value = siteResult.value.value as unknown as XyzAtbbsSite.Main;
5858+ const value = siteResult.value.value;
6259 profile.bbsName = value.name;
6360 profile.bbsDescription = value.description;
6461 }
+61
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.
55+66+import { is } from "@atcute/lexicons/validations";
77+import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post";
88+import { mainSchema as banSchema } from "../lexicons/types/xyz/atbbs/ban";
99+import { mainSchema as hideSchema } from "../lexicons/types/xyz/atbbs/hide";
1010+import { mainSchema as pinSchema } from "../lexicons/types/xyz/atbbs/pin";
1111+import { mainSchema as profileSchema } from "../lexicons/types/xyz/atbbs/profile";
1212+import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site";
1313+import { mainSchema as boardSchema } from "../lexicons/types/xyz/atbbs/board";
1414+import type {
1515+ XyzAtbbsBan,
1616+ XyzAtbbsBoard,
1717+ XyzAtbbsHide,
1818+ XyzAtbbsPin,
1919+ XyzAtbbsPost,
2020+ XyzAtbbsProfile,
2121+ XyzAtbbsSite,
2222+} from "../lexicons";
2323+import type { ATRecord } from "./atproto";
2424+2525+export type TypedRecord<T> = Omit<ATRecord, "value"> & { value: T };
2626+2727+export type PostRecord = TypedRecord<XyzAtbbsPost.Main>;
2828+export type BanRecord = TypedRecord<XyzAtbbsBan.Main>;
2929+export type HideRecord = TypedRecord<XyzAtbbsHide.Main>;
3030+export type PinRecord = TypedRecord<XyzAtbbsPin.Main>;
3131+export type ProfileRecord = TypedRecord<XyzAtbbsProfile.Main>;
3232+export type SiteRecord = TypedRecord<XyzAtbbsSite.Main>;
3333+export type BoardRecord = TypedRecord<XyzAtbbsBoard.Main>;
3434+3535+export function isPostRecord(record: ATRecord): record is PostRecord {
3636+ return is(postSchema, record.value);
3737+}
3838+3939+export function isBanRecord(record: ATRecord): record is BanRecord {
4040+ return is(banSchema, record.value);
4141+}
4242+4343+export function isHideRecord(record: ATRecord): record is HideRecord {
4444+ return is(hideSchema, record.value);
4545+}
4646+4747+export function isPinRecord(record: ATRecord): record is PinRecord {
4848+ return is(pinSchema, record.value);
4949+}
5050+5151+export function isProfileRecord(record: ATRecord): record is ProfileRecord {
5252+ return is(profileSchema, record.value);
5353+}
5454+5555+export function isSiteRecord(record: ATRecord): record is SiteRecord {
5656+ return is(siteSchema, record.value);
5757+}
5858+5959+export function isBoardRecord(record: ATRecord): record is BoardRecord {
6060+ return is(boardSchema, record.value);
6161+}
+8-11
web/src/lib/replies.ts
···11/** Pure helpers for reply pagination and hydration. */
2233-import { type BacklinkRef } from "./atproto";
33+import { type ATRecord, type BacklinkRef } from "./atproto";
44import { parseAtUri } from "./util";
55-import { is } from "@atcute/lexicons/validations";
66-import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post";
77-import type { XyzAtbbsPost } from "../lexicons";
55+import { isPostRecord } from "./recordGuards";
86import type { Reply } from "../components/post/ReplyCard";
97108export type { BacklinkRef };
···4442}
45434644export function recordToReply(
4747- record: { uri: string; value: Record<string, unknown> },
4545+ record: ATRecord,
4846 authors: Record<string, { handle: string; pds?: string }>,
4947): Reply | null {
5048 const { did, rkey } = parseAtUri(record.uri);
5149 if (!(did in authors)) return null;
5252- if (!is(postSchema, record.value)) return null;
5353- const value = record.value as unknown as XyzAtbbsPost.Main;
5050+ if (!isPostRecord(record)) return null;
5451 return {
5552 uri: record.uri,
5653 did,
5754 rkey,
5855 handle: authors[did].handle,
5956 pds: authors[did].pds ?? "",
6060- body: value.body,
6161- createdAt: value.createdAt,
6262- parent: value.parent ?? null,
6363- attachments: (value.attachments ?? []) as Reply["attachments"],
5757+ body: record.value.body,
5858+ createdAt: record.value.createdAt,
5959+ parent: record.value.parent ?? null,
6060+ attachments: (record.value.attachments ?? []) as Reply["attachments"],
6461 };
6562}
+3-5
web/src/lib/thread.ts
···1212import { POST } from "./lexicon";
1313import { makeAtUri, parseAtUri } from "./util";
1414import { recordToReply } from "./replies";
1515+import { isPostRecord } from "./recordGuards";
1516import type { Reply } from "../components/post/ReplyCard";
1616-import { is } from "@atcute/lexicons/validations";
1717-import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post";
1818-import type { XyzAtbbsPost } from "../lexicons";
19172018export interface ThreadRoot {
2119 uri: string;
···5856 tid: string,
5957): Promise<ThreadRoot> {
6058 const threadRecord = await getRecord(did, POST, tid);
6161- if (!is(postSchema, threadRecord.value)) {
5959+ if (!isPostRecord(threadRecord)) {
6260 throw new Error("Invalid post record");
6361 }
6462 const author = await resolveIdentity(did);
6565- const postValue = threadRecord.value as unknown as XyzAtbbsPost.Main;
6363+ const postValue = threadRecord.value;
6664 const boardSlug = parseAtUri(postValue.scope).rkey;
6765 return {
6866 uri: threadRecord.uri,