Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

web: create reusable record guards

+147 -143
+14 -22
web/src/lib/activity.ts
··· 2 2 3 3 import { fetchAndHydrate, listRecords } from "./atproto"; 4 4 import { POST } from "./lexicon"; 5 - import { is } from "@atcute/lexicons/validations"; 6 - import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 7 - import type { XyzAtbbsPost } from "../lexicons"; 5 + import { isPostRecord } from "./recordGuards"; 8 6 9 7 export interface ActivityItem { 10 8 type: "reply" | "parent_reply"; ··· 49 47 ): Promise<ActivityItem[]> { 50 48 const SCAN_LIMIT = 50; 51 49 const allPosts = await listRecords(pdsUrl, did, POST, SCAN_LIMIT); 52 - const validPosts = allPosts.filter((record) => is(postSchema, record.value)); 50 + const validPosts = allPosts.filter(isPostRecord); 53 51 54 - const rootPosts = validPosts.filter( 55 - (record) => !(record.value as Record<string, unknown>).root, 56 - ); 57 - const replyPosts = validPosts.filter( 58 - (record) => !!(record.value as Record<string, unknown>).root, 59 - ); 52 + const rootPosts = validPosts.filter((record) => !record.value.root); 53 + const replyPosts = validPosts.filter((record) => !!record.value.root); 60 54 61 55 const results = await Promise.all([ 62 - ...rootPosts.map((post) => { 63 - const value = post.value as unknown as XyzAtbbsPost.Main; 64 - return fetchBacklinkItems( 56 + ...rootPosts.map((post) => 57 + fetchBacklinkItems( 65 58 post.uri, 66 59 `${POST}:root`, 67 60 did, 68 61 "reply", 69 - value.title ?? "", 62 + post.value.title ?? "", 70 63 post.uri, 71 - ); 72 - }), 73 - ...replyPosts.map((reply) => { 74 - const value = reply.value as unknown as XyzAtbbsPost.Main; 75 - return fetchBacklinkItems( 64 + ), 65 + ), 66 + ...replyPosts.map((reply) => 67 + fetchBacklinkItems( 76 68 reply.uri, 77 69 `${POST}:parent`, 78 70 did, 79 71 "parent_reply", 80 72 "", 81 - value.root ?? "", 82 - ); 83 - }), 73 + reply.value.root ?? "", 74 + ), 75 + ), 84 76 ]); 85 77 86 78 // Deduplicate — prefer "parent-reply" type when the same reply appears as both.
+5 -8
web/src/lib/bbs.ts
··· 9 9 import { queryClient } from "./queryClient"; 10 10 import { SITE } from "./lexicon"; 11 11 import { parseAtUri } from "./util"; 12 - import { is } from "@atcute/lexicons/validations"; 13 - import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; 14 - import { mainSchema as boardSchema } from "../lexicons/types/xyz/atbbs/board"; 15 - import type { XyzAtbbsSite, XyzAtbbsBoard } from "../lexicons"; 12 + import { isBoardRecord, isSiteRecord } from "./recordGuards"; 16 13 17 14 export class BBSNotFoundError extends Error {} 18 15 export class NoBBSError extends Error {} ··· 77 74 throw new NoBBSError(`${handle} isn't running a BBS.`); 78 75 } 79 76 80 - if (!is(siteSchema, siteRecord.value)) { 77 + if (!isSiteRecord(siteRecord)) { 81 78 throw new NoBBSError(`${handle} has an invalid site record.`); 82 79 } 83 - const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main; 80 + const siteValue = siteRecord.value; 84 81 const boardUris: string[] = siteValue.boards ?? []; 85 82 86 83 const boardResults = await Promise.allSettled( ··· 93 90 const boards: Board[] = []; 94 91 boardResults.forEach((result, index) => { 95 92 if (result.status !== "fulfilled") return; 96 - if (!is(boardSchema, result.value.value)) return; 97 - const board = result.value.value as unknown as XyzAtbbsBoard.Main; 93 + if (!isBoardRecord(result.value)) return; 94 + const board = result.value.value; 98 95 const parsed = parseAtUri(boardUris[index]); 99 96 boards.push({ 100 97 slug: parsed.rkey,
+7 -12
web/src/lib/bbsModeration.ts
··· 4 4 import { listRecords } from "./atproto"; 5 5 import { BAN, HIDE } from "./lexicon"; 6 6 import { parseAtUri } from "./util"; 7 - import { is } from "@atcute/lexicons/validations"; 8 - import { mainSchema as banSchema } from "../lexicons/types/xyz/atbbs/ban"; 9 - import { mainSchema as hideSchema } from "../lexicons/types/xyz/atbbs/hide"; 10 - import type { XyzAtbbsBan, XyzAtbbsHide } from "../lexicons"; 7 + import { isBanRecord, isHideRecord } from "./recordGuards"; 11 8 12 9 export interface BBSModeration { 13 10 bannedDids: Set<string>; ··· 30 27 const bannedDids = new Set<string>(); 31 28 const banRkeys: Record<string, string> = {}; 32 29 for (const record of banRecs) { 33 - if (!is(banSchema, record.value)) continue; 34 - const value = record.value as unknown as XyzAtbbsBan.Main; 35 - bannedDids.add(value.did); 36 - banRkeys[value.did] = parseAtUri(record.uri).rkey; 30 + if (!isBanRecord(record)) continue; 31 + bannedDids.add(record.value.did); 32 + banRkeys[record.value.did] = parseAtUri(record.uri).rkey; 37 33 } 38 34 39 35 const hiddenUris = new Set<string>(); 40 36 const hideRkeys: Record<string, string> = {}; 41 37 for (const record of hideRecs) { 42 - if (!is(hideSchema, record.value)) continue; 43 - const value = record.value as unknown as XyzAtbbsHide.Main; 44 - hiddenUris.add(value.uri); 45 - hideRkeys[value.uri] = parseAtUri(record.uri).rkey; 38 + if (!isHideRecord(record)) continue; 39 + hiddenUris.add(record.value.uri); 40 + hideRkeys[record.value.uri] = parseAtUri(record.uri).rkey; 46 41 } 47 42 48 43 return { bannedDids, hiddenUris, banRkeys, hideRkeys };
+11 -17
web/src/lib/boardThreads.ts
··· 15 15 } from "./atproto"; 16 16 import { POST, BOARD } from "./lexicon"; 17 17 import { makeAtUri, parseAtUri } from "./util"; 18 - import { is } from "@atcute/lexicons/validations"; 19 - import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 20 - import type { XyzAtbbsPost } from "../lexicons"; 18 + import { isPostRecord } from "./recordGuards"; 21 19 22 20 export interface Participant { 23 21 did: string; ··· 70 68 71 69 const records = await getRecordsBatch(backlinks.records); 72 70 for (const record of records) { 73 - if (!is(postSchema, record.value)) continue; 74 - const value = record.value as unknown as XyzAtbbsPost.Main; 75 - const threadUri = value.root ?? record.uri; 71 + if (!isPostRecord(record)) continue; 72 + const threadUri = record.value.root ?? record.uri; 76 73 if (!lastActivity.has(threadUri)) { 77 - lastActivity.set(threadUri, value.createdAt); 74 + lastActivity.set(threadUri, record.value.createdAt); 78 75 } 79 76 let posters = postersByThread.get(threadUri); 80 77 if (!posters) { ··· 91 88 const threadUris = [...lastActivity.keys()].slice(0, PAGE_SIZE); 92 89 const rootRecords = await getRecordsByUri(threadUris); 93 90 94 - const validRoots = rootRecords.filter((record) => { 95 - if (!is(postSchema, record.value)) return false; 96 - const value = record.value as unknown as XyzAtbbsPost.Main; 97 - return value.title && !value.root; 98 - }); 91 + const validRoots = rootRecords 92 + .filter(isPostRecord) 93 + .filter((record) => record.value.title && !record.value.root); 99 94 100 95 const allDids = new Set<string>(); 101 96 for (const record of validRoots) { ··· 117 112 .filter((record) => parseAtUri(record.uri).did in identities) 118 113 .map((record) => { 119 114 const { did, rkey } = parseAtUri(record.uri); 120 - const value = record.value as unknown as XyzAtbbsPost.Main; 121 115 const posterDids = postersByThread.get(record.uri) ?? new Set([did]); 122 116 const participants: Participant[] = [...posterDids] 123 117 .filter((posterDid) => posterDid in identities) ··· 131 125 did, 132 126 rkey, 133 127 handle: identities[did].handle, 134 - title: value.title ?? "", 135 - body: value.body, 136 - createdAt: value.createdAt, 137 - lastActivityAt: lastActivity.get(record.uri) ?? value.createdAt, 128 + title: record.value.title ?? "", 129 + body: record.value.body, 130 + createdAt: record.value.createdAt, 131 + lastActivityAt: lastActivity.get(record.uri) ?? record.value.createdAt, 138 132 replyCount: replyCounts[record.uri] ?? 0, 139 133 participants, 140 134 };
+4 -7
web/src/lib/discovery.ts
··· 3 3 import { getAvatars, getRecord, resolveIdentitiesBatch } from "./atproto"; 4 4 import { SITE } from "./lexicon"; 5 5 import { SERVICES } from "./shared"; 6 - import { is } from "@atcute/lexicons/validations"; 7 - import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; 8 - import type { XyzAtbbsSite } from "../lexicons"; 6 + import { isSiteRecord } from "./recordGuards"; 9 7 10 8 export interface DiscoveredBBS { 11 9 did: string; ··· 42 40 if (!(repo.did in identities)) continue; 43 41 try { 44 42 const siteRecord = await getRecord(repo.did, SITE, "self"); 45 - if (!is(siteSchema, siteRecord.value)) continue; 46 - const siteValue = siteRecord.value as unknown as XyzAtbbsSite.Main; 43 + if (!isSiteRecord(siteRecord)) continue; 47 44 items.push({ 48 45 did: repo.did, 49 46 handle: identities[repo.did].handle, 50 - name: siteValue.name || identities[repo.did].handle, 51 - description: siteValue.description || "", 47 + name: siteRecord.value.name || identities[repo.did].handle, 48 + description: siteRecord.value.description || "", 52 49 }); 53 50 } catch { 54 51 continue;
+8 -17
web/src/lib/mythreads.ts
··· 3 3 import { listRecords, resolveIdentitiesBatch } from "./atproto"; 4 4 import { POST } from "./lexicon"; 5 5 import { parseAtUri } from "./util"; 6 - import { is } from "@atcute/lexicons/validations"; 7 - import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 8 - import type { XyzAtbbsPost } from "../lexicons"; 6 + import { isPostRecord } from "./recordGuards"; 9 7 10 8 export interface MyThread { 11 9 uri: string; ··· 23 21 ): Promise<MyThread[]> { 24 22 const records = await listRecords(pdsUrl, did, POST); 25 23 const rootPosts = records 26 - .filter((record) => is(postSchema, record.value)) 27 - .filter((record) => { 28 - const value = record.value as Record<string, unknown>; 29 - return !value.root && value.title; // root posts with titles = threads 30 - }); 24 + .filter(isPostRecord) 25 + .filter((record) => !record.value.root && record.value.title); 31 26 if (!rootPosts.length) return []; 32 27 33 28 const bbsDids = new Set( 34 - rootPosts.map((record) => { 35 - const value = record.value as unknown as XyzAtbbsPost.Main; 36 - return parseAtUri(value.scope).did; 37 - }), 29 + rootPosts.map((record) => parseAtUri(record.value.scope).did), 38 30 ); 39 31 const identities = await resolveIdentitiesBatch([...bbsDids]); 40 32 41 33 const results: MyThread[] = []; 42 34 for (const record of rootPosts) { 43 - const value = record.value as unknown as XyzAtbbsPost.Main; 44 - const bbsDid = parseAtUri(value.scope).did; 35 + const bbsDid = parseAtUri(record.value.scope).did; 45 36 const identity = identities[bbsDid]; 46 37 if (!identity) continue; 47 38 results.push({ 48 39 uri: record.uri, 49 40 rkey: parseAtUri(record.uri).rkey, 50 - title: value.title ?? "", 51 - body: value.body, 52 - createdAt: value.createdAt, 41 + title: record.value.title ?? "", 42 + body: record.value.body, 43 + createdAt: record.value.createdAt, 53 44 bbsDid, 54 45 bbsHandle: identity.handle, 55 46 });
+11 -19
web/src/lib/news.ts
··· 3 3 import { getBacklinks, getRecordsBatch } from "./atproto"; 4 4 import { POST, SITE } from "./lexicon"; 5 5 import { makeAtUri, parseAtUri } from "./util"; 6 - import { is } from "@atcute/lexicons/validations"; 7 - import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 8 - import type { XyzAtbbsPost } from "../lexicons"; 6 + import { isPostRecord } from "./recordGuards"; 9 7 import type { NewsPost } from "./bbs"; 10 8 11 9 export async function fetchNews(bbsDid: string): Promise<NewsPost[]> { ··· 19 17 const records = await getRecordsBatch(sysopRefs); 20 18 21 19 const news: NewsPost[] = records 22 - .filter((record) => is(postSchema, record.value)) 23 - .filter((record) => { 24 - const value = record.value as unknown as XyzAtbbsPost.Main; 25 - return value.title && !value.root; 26 - }) 27 - .map((record) => { 28 - const value = record.value as unknown as XyzAtbbsPost.Main; 29 - return { 30 - uri: record.uri, 31 - rkey: parseAtUri(record.uri).rkey, 32 - title: value.title ?? "", 33 - body: value.body, 34 - createdAt: value.createdAt, 35 - attachments: value.attachments as NewsPost["attachments"], 36 - }; 37 - }); 20 + .filter(isPostRecord) 21 + .filter((record) => record.value.title && !record.value.root) 22 + .map((record) => ({ 23 + uri: record.uri, 24 + rkey: parseAtUri(record.uri).rkey, 25 + title: record.value.title ?? "", 26 + body: record.value.body, 27 + createdAt: record.value.createdAt, 28 + attachments: record.value.attachments as NewsPost["attachments"], 29 + })); 38 30 39 31 news.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); 40 32 return news;
+10 -17
web/src/lib/pins.ts
··· 7 7 resolveIdentitiesBatch, 8 8 } from "./atproto"; 9 9 import { PIN, SITE } from "./lexicon"; 10 - import { is } from "@atcute/lexicons/validations"; 11 - import { mainSchema as pinSchema } from "../lexicons/types/xyz/atbbs/pin"; 12 - import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; 13 - import type { XyzAtbbsPin, XyzAtbbsSite } from "../lexicons"; 10 + import { isPinRecord, isSiteRecord } from "./recordGuards"; 14 11 import { parseAtUri } from "./util"; 15 12 16 13 export interface PinnedBBS { ··· 27 24 did: string, 28 25 ): Promise<PinnedBBS[]> { 29 26 const records = await listRecords(pdsUrl, did, PIN); 30 - const pinRecords = records.filter((record) => is(pinSchema, record.value)); 27 + const pinRecords = records.filter(isPinRecord); 31 28 32 - const pinnedDids = pinRecords.map( 33 - (record) => (record.value as unknown as XyzAtbbsPin.Main).did, 34 - ); 29 + const pinnedDids = pinRecords.map((record) => record.value.did); 35 30 if (!pinnedDids.length) return []; 36 31 37 32 const [identities, siteResults, avatars] = await Promise.all([ ··· 45 40 const siteNames: Record<string, string> = {}; 46 41 siteResults.forEach((result, index) => { 47 42 if (result.status !== "fulfilled") return; 48 - if (!is(siteSchema, result.value.value)) return; 49 - const siteValue = result.value.value as unknown as XyzAtbbsSite.Main; 50 - siteNames[pinnedDids[index]] = siteValue.name; 43 + if (!isSiteRecord(result.value)) return; 44 + siteNames[pinnedDids[index]] = result.value.value.name; 51 45 }); 52 46 53 47 const results: PinnedBBS[] = []; 54 48 for (const record of pinRecords) { 55 - const value = record.value as unknown as XyzAtbbsPin.Main; 56 - const identity = identities[value.did]; 49 + const identity = identities[record.value.did]; 57 50 if (!identity) continue; 58 51 results.push({ 59 - did: value.did, 52 + did: record.value.did, 60 53 rkey: parseAtUri(record.uri).rkey, 61 54 handle: identity.handle, 62 - name: siteNames[value.did] ?? identity.handle, 63 - createdAt: value.createdAt, 64 - avatar: avatars[value.did], 55 + name: siteNames[record.value.did] ?? identity.handle, 56 + createdAt: record.value.createdAt, 57 + avatar: avatars[record.value.did], 65 58 }); 66 59 } 67 60 results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+5 -8
web/src/lib/profile.ts
··· 2 2 3 3 import { getAvatar, getRecord, resolveIdentity } from "./atproto"; 4 4 import { PROFILE, SITE } from "./lexicon"; 5 - import { is } from "@atcute/lexicons/validations"; 6 - import { mainSchema as profileSchema } from "../lexicons/types/xyz/atbbs/profile"; 7 - import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; 8 - import type { XyzAtbbsProfile, XyzAtbbsSite } from "../lexicons"; 5 + import { isProfileRecord, isSiteRecord } from "./recordGuards"; 9 6 10 7 export interface Profile { 11 8 did: string; ··· 45 42 46 43 if ( 47 44 profileResult.status === "fulfilled" && 48 - is(profileSchema, profileResult.value.value) 45 + isProfileRecord(profileResult.value) 49 46 ) { 50 - const value = profileResult.value.value as unknown as XyzAtbbsProfile.Main; 47 + const value = profileResult.value.value; 51 48 profile.name = value.name; 52 49 profile.pronouns = value.pronouns; 53 50 profile.bio = value.bio; ··· 56 53 57 54 if ( 58 55 siteResult.status === "fulfilled" && 59 - is(siteSchema, siteResult.value.value) 56 + isSiteRecord(siteResult.value) 60 57 ) { 61 - const value = siteResult.value.value as unknown as XyzAtbbsSite.Main; 58 + const value = siteResult.value.value; 62 59 profile.bbsName = value.name; 63 60 profile.bbsDescription = value.description; 64 61 }
+61
web/src/lib/recordGuards.ts
··· 1 + // Type guards for narrowing raw ATRecord.value into typed lexicon records. 2 + // Each guard runs the schema's runtime check and, if it passes, narrows the 3 + // record so downstream code can access typed fields without `as unknown as ...` 4 + // casts. 5 + 6 + import { is } from "@atcute/lexicons/validations"; 7 + import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 8 + import { mainSchema as banSchema } from "../lexicons/types/xyz/atbbs/ban"; 9 + import { mainSchema as hideSchema } from "../lexicons/types/xyz/atbbs/hide"; 10 + import { mainSchema as pinSchema } from "../lexicons/types/xyz/atbbs/pin"; 11 + import { mainSchema as profileSchema } from "../lexicons/types/xyz/atbbs/profile"; 12 + import { mainSchema as siteSchema } from "../lexicons/types/xyz/atbbs/site"; 13 + import { mainSchema as boardSchema } from "../lexicons/types/xyz/atbbs/board"; 14 + import type { 15 + XyzAtbbsBan, 16 + XyzAtbbsBoard, 17 + XyzAtbbsHide, 18 + XyzAtbbsPin, 19 + XyzAtbbsPost, 20 + XyzAtbbsProfile, 21 + XyzAtbbsSite, 22 + } from "../lexicons"; 23 + import type { ATRecord } from "./atproto"; 24 + 25 + export type TypedRecord<T> = Omit<ATRecord, "value"> & { value: T }; 26 + 27 + export type PostRecord = TypedRecord<XyzAtbbsPost.Main>; 28 + export type BanRecord = TypedRecord<XyzAtbbsBan.Main>; 29 + export type HideRecord = TypedRecord<XyzAtbbsHide.Main>; 30 + export type PinRecord = TypedRecord<XyzAtbbsPin.Main>; 31 + export type ProfileRecord = TypedRecord<XyzAtbbsProfile.Main>; 32 + export type SiteRecord = TypedRecord<XyzAtbbsSite.Main>; 33 + export type BoardRecord = TypedRecord<XyzAtbbsBoard.Main>; 34 + 35 + export function isPostRecord(record: ATRecord): record is PostRecord { 36 + return is(postSchema, record.value); 37 + } 38 + 39 + export function isBanRecord(record: ATRecord): record is BanRecord { 40 + return is(banSchema, record.value); 41 + } 42 + 43 + export function isHideRecord(record: ATRecord): record is HideRecord { 44 + return is(hideSchema, record.value); 45 + } 46 + 47 + export function isPinRecord(record: ATRecord): record is PinRecord { 48 + return is(pinSchema, record.value); 49 + } 50 + 51 + export function isProfileRecord(record: ATRecord): record is ProfileRecord { 52 + return is(profileSchema, record.value); 53 + } 54 + 55 + export function isSiteRecord(record: ATRecord): record is SiteRecord { 56 + return is(siteSchema, record.value); 57 + } 58 + 59 + export function isBoardRecord(record: ATRecord): record is BoardRecord { 60 + return is(boardSchema, record.value); 61 + }
+8 -11
web/src/lib/replies.ts
··· 1 1 /** Pure helpers for reply pagination and hydration. */ 2 2 3 - import { type BacklinkRef } from "./atproto"; 3 + import { type ATRecord, type BacklinkRef } from "./atproto"; 4 4 import { parseAtUri } from "./util"; 5 - import { is } from "@atcute/lexicons/validations"; 6 - import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 7 - import type { XyzAtbbsPost } from "../lexicons"; 5 + import { isPostRecord } from "./recordGuards"; 8 6 import type { Reply } from "../components/post/ReplyCard"; 9 7 10 8 export type { BacklinkRef }; ··· 44 42 } 45 43 46 44 export function recordToReply( 47 - record: { uri: string; value: Record<string, unknown> }, 45 + record: ATRecord, 48 46 authors: Record<string, { handle: string; pds?: string }>, 49 47 ): Reply | null { 50 48 const { did, rkey } = parseAtUri(record.uri); 51 49 if (!(did in authors)) return null; 52 - if (!is(postSchema, record.value)) return null; 53 - const value = record.value as unknown as XyzAtbbsPost.Main; 50 + if (!isPostRecord(record)) return null; 54 51 return { 55 52 uri: record.uri, 56 53 did, 57 54 rkey, 58 55 handle: authors[did].handle, 59 56 pds: authors[did].pds ?? "", 60 - body: value.body, 61 - createdAt: value.createdAt, 62 - parent: value.parent ?? null, 63 - attachments: (value.attachments ?? []) as Reply["attachments"], 57 + body: record.value.body, 58 + createdAt: record.value.createdAt, 59 + parent: record.value.parent ?? null, 60 + attachments: (record.value.attachments ?? []) as Reply["attachments"], 64 61 }; 65 62 }
+3 -5
web/src/lib/thread.ts
··· 12 12 import { POST } from "./lexicon"; 13 13 import { makeAtUri, parseAtUri } from "./util"; 14 14 import { recordToReply } from "./replies"; 15 + import { isPostRecord } from "./recordGuards"; 15 16 import type { Reply } from "../components/post/ReplyCard"; 16 - import { is } from "@atcute/lexicons/validations"; 17 - import { mainSchema as postSchema } from "../lexicons/types/xyz/atbbs/post"; 18 - import type { XyzAtbbsPost } from "../lexicons"; 19 17 20 18 export interface ThreadRoot { 21 19 uri: string; ··· 58 56 tid: string, 59 57 ): Promise<ThreadRoot> { 60 58 const threadRecord = await getRecord(did, POST, tid); 61 - if (!is(postSchema, threadRecord.value)) { 59 + if (!isPostRecord(threadRecord)) { 62 60 throw new Error("Invalid post record"); 63 61 } 64 62 const author = await resolveIdentity(did); 65 - const postValue = threadRecord.value as unknown as XyzAtbbsPost.Main; 63 + const postValue = threadRecord.value; 66 64 const boardSlug = parseAtUri(postValue.scope).rkey; 67 65 return { 68 66 uri: threadRecord.uri,