···11+/** Pure helpers for reply pagination and hydration. */
22+33+import { type BacklinkRef } from "./atproto";
44+import { parseAtUri } from "./util";
55+import { is } from "@atcute/lexicons/validations";
66+import { mainSchema as replySchema } from "../lexicons/types/xyz/atboards/reply";
77+import type { XyzAtboardsReply } from "../lexicons";
88+import type { Reply } from "../components/ReplyCard";
99+1010+export type { BacklinkRef };
1111+1212+export const REPLIES_PER_PAGE = 10;
1313+1414+export function refToUri(ref: BacklinkRef): string {
1515+ return `at://${ref.did}/${ref.collection}/${ref.rkey}`;
1616+}
1717+1818+export function pageForReply(
1919+ refs: BacklinkRef[],
2020+ replyUri: string | null,
2121+): number | null {
2222+ if (!replyUri) return null;
2323+ const index = refs.findIndex((ref) => refToUri(ref) === replyUri);
2424+ return index >= 0 ? Math.floor(index / REPLIES_PER_PAGE) + 1 : null;
2525+}
2626+2727+export function rkeyFromHash(): string | null {
2828+ const hash = typeof window !== "undefined" ? window.location.hash : "";
2929+ return hash.startsWith("#reply-") ? hash.slice(7) : null;
3030+}
3131+3232+export function pageForRkey(
3333+ refs: BacklinkRef[],
3434+ rkey: string | null,
3535+): number | null {
3636+ if (!rkey) return null;
3737+ const index = refs.findIndex((ref) => ref.rkey === rkey);
3838+ return index >= 0 ? Math.floor(index / REPLIES_PER_PAGE) + 1 : null;
3939+}
4040+4141+export function clampPage(page: number, totalRefs: number): number {
4242+ const totalPages = Math.max(1, Math.ceil(totalRefs / REPLIES_PER_PAGE));
4343+ return Math.max(1, Math.min(page, totalPages));
4444+}
4545+4646+export function recordToReply(
4747+ record: { uri: string; value: Record<string, unknown> },
4848+ authors: Record<string, { handle: string; pds?: string }>,
4949+): Reply | null {
5050+ const { did, rkey } = parseAtUri(record.uri);
5151+ if (!(did in authors)) return null;
5252+ if (!is(replySchema, record.value)) return null;
5353+ const value = record.value as unknown as XyzAtboardsReply.Main;
5454+ return {
5555+ uri: record.uri,
5656+ did,
5757+ rkey,
5858+ handle: authors[did].handle,
5959+ pds: authors[did].pds ?? "",
6060+ body: value.body,
6161+ createdAt: value.createdAt,
6262+ quote: value.quote ?? null,
6363+ attachments: (value.attachments ?? []) as Reply["attachments"],
6464+ };
6565+}
+8-9
web/src/lib/util.ts
···11export function formatFullDate(iso: string): string {
22- const d = new Date(iso);
33- const pad = (n: number) => String(n).padStart(2, "0");
44- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
22+ const date = new Date(iso);
33+ const pad = (num: number) => String(num).padStart(2, "0");
44+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
55}
6677export function relativeDate(iso: string): string {
88- const d = new Date(iso);
99- const diff = Math.floor((Date.now() - d.getTime()) / 1000);
1010- if (diff < 60) return "just now";
1111- if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
1212- if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
1313- if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
88+ const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
99+ if (seconds < 60) return "just now";
1010+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
1111+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
1212+ if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
1413 return formatFullDate(iso);
1514}
1615
+2-2
web/src/lib/writes.ts
···4040type Did = `did:${string}:${string}`;
4141type Nsid = `${string}.${string}.${string}`;
42424343-const asDid = (s: string) => s as Did;
4444-const asNsid = (s: string) => s as Nsid;
4343+const asDid = (value: string) => value as Did;
4444+const asNsid = (value: string) => value as Nsid;
45454646function currentDid(): Did {
4747 const user = getCurrentUser();
···11+export { bbsLoader, type BBSLoaderData } from "./bbs";
22+export { boardLoader, hydrateThreadPage, type ThreadItem } from "./board";
33+export { threadLoader, type ThreadObj } from "./thread";
44+export {
55+ accountLoader,
66+ requireAuthLoader,
77+ type InboxItem,
88+} from "./account";
99+export {
1010+ sysopEditLoader,
1111+ sysopModerateLoader,
1212+ type HiddenInfo,
1313+} from "./sysop";
+111
web/src/router/loaders/sysop.ts
···11+import { redirect } from "react-router-dom";
22+import { resolveBBS, type BBS } from "../../lib/bbs";
33+import {
44+ getRecordByUri,
55+ listRecords,
66+ resolveIdentitiesBatch,
77+ resolveIdentity,
88+} from "../../lib/atproto";
99+import { BAN, HIDE } from "../../lib/lexicon";
1010+import { parseAtUri } from "../../lib/util";
1111+import { is } from "@atcute/lexicons/validations";
1212+import { mainSchema as banSchema } from "../../lexicons/types/xyz/atboards/ban";
1313+import { mainSchema as hideSchema } from "../../lexicons/types/xyz/atboards/hide";
1414+import type { XyzAtboardsBan, XyzAtboardsHide } from "../../lexicons";
1515+import { requireAuth } from "./auth";
1616+1717+export interface HiddenInfo {
1818+ uri: string;
1919+ handle: string;
2020+ title: string;
2121+ body: string;
2222+}
2323+2424+function buildRkeyMap<T>(
2525+ records: { uri: string; value: Record<string, unknown> }[],
2626+ schema: Parameters<typeof is>[0],
2727+ getKey: (value: T) => string,
2828+): Record<string, string> {
2929+ const map: Record<string, string> = {};
3030+ for (const record of records) {
3131+ if (!is(schema, record.value)) continue;
3232+ map[getKey(record.value as unknown as T)] = parseAtUri(record.uri).rkey;
3333+ }
3434+ return map;
3535+}
3636+3737+async function hydrateHiddenPosts(uris: Set<string>): Promise<HiddenInfo[]> {
3838+ const hidden: HiddenInfo[] = [];
3939+ for (const uri of uris) {
4040+ const did = parseAtUri(uri).did;
4141+ let handle = did;
4242+ try {
4343+ handle = (await resolveIdentity(did)).handle;
4444+ } catch {}
4545+ try {
4646+ const record = await getRecordByUri(uri);
4747+ const value = record.value as unknown as { title?: string; body?: string };
4848+ hidden.push({
4949+ uri,
5050+ handle,
5151+ title: value.title ?? "",
5252+ body: (value.body ?? "").substring(0, 100),
5353+ });
5454+ } catch {
5555+ hidden.push({ uri, handle, title: "", body: uri });
5656+ }
5757+ }
5858+ return hidden;
5959+}
6060+6161+export async function sysopEditLoader() {
6262+ const user = await requireAuth();
6363+ try {
6464+ const bbs = await resolveBBS(user.handle);
6565+ return { user, bbs };
6666+ } catch {
6767+ throw redirect("/account/create");
6868+ }
6969+}
7070+7171+export async function sysopModerateLoader() {
7272+ const user = await requireAuth();
7373+7474+ let bbs: BBS;
7575+ try {
7676+ bbs = await resolveBBS(user.handle);
7777+ } catch {
7878+ throw redirect("/account/create");
7979+ }
8080+8181+ const [banRecs, hideRecs] = await Promise.all([
8282+ listRecords(user.pdsUrl, user.did, BAN),
8383+ listRecords(user.pdsUrl, user.did, HIDE),
8484+ ]);
8585+8686+ const banRkeys = buildRkeyMap<XyzAtboardsBan.Main>(
8787+ banRecs,
8888+ banSchema,
8989+ (ban) => ban.did,
9090+ );
9191+ const hideRkeys = buildRkeyMap<XyzAtboardsHide.Main>(
9292+ hideRecs,
9393+ hideSchema,
9494+ (hide) => hide.uri,
9595+ );
9696+9797+ let bannedHandles: Record<string, string> = {};
9898+ if (bbs.site.bannedDids.size) {
9999+ try {
100100+ const authors = await resolveIdentitiesBatch([...bbs.site.bannedDids]);
101101+ for (const did of bbs.site.bannedDids)
102102+ bannedHandles[did] = authors[did]?.handle ?? did;
103103+ } catch {
104104+ for (const did of bbs.site.bannedDids) bannedHandles[did] = did;
105105+ }
106106+ }
107107+108108+ const hidden = await hydrateHiddenPosts(bbs.site.hiddenPosts);
109109+110110+ return { user, bbs, banRkeys, bannedHandles, hideRkeys, hidden };
111111+}