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: add profiles

+344 -16
+3
core/limits.py
··· 12 12 REPLY_BODY = 10000 13 13 ATTACHMENT_NAME = 256 14 14 MAX_ATTACHMENTS = 10 15 + PROFILE_NAME = 100 16 + PROFILE_PRONOUNS = 50 17 + PROFILE_BIO = 1000
+1 -5
lexicons/xyz.atboards.profile.json
··· 7 7 "key": "literal:self", 8 8 "record": { 9 9 "type": "object", 10 - "required": ["createdAt"], 10 + "required": [], 11 11 "properties": { 12 12 "name": { 13 13 "type": "string", ··· 22 22 "maxLength": 1000 23 23 }, 24 24 "createdAt": { 25 - "type": "string", 26 - "format": "datetime" 27 - }, 28 - "updatedAt": { 29 25 "type": "string", 30 26 "format": "datetime" 31 27 }
+1 -1
web/src/components/layout/Header.tsx
··· 28 28 <div className="hidden md:flex items-center gap-3 shrink-0 ml-4"> 29 29 {user ? ( 30 30 <> 31 - <Link to="/" className={linkStyle}>{user.handle}</Link> 31 + <Link to={`/profile/${encodeURIComponent(user.handle)}`} className={linkStyle}>{user.handle}</Link> 32 32 <button type="button" onClick={onLogout} className={linkStyle}>log out</button> 33 33 </> 34 34 ) : (
+1 -1
web/src/components/layout/MobileMenu.tsx
··· 37 37 <div className={panelStyle}> 38 38 {user ? ( 39 39 <> 40 - <Link to="/" onClick={close} className="text-neutral-300 hover:text-neutral-200"> 40 + <Link to={`/profile/${encodeURIComponent(user.handle)}`} onClick={close} className="text-neutral-300 hover:text-neutral-200"> 41 41 {user.handle} 42 42 </Link> 43 43 <button type="button" onClick={() => { close(); onLogout(); }} className="text-neutral-500 hover:text-neutral-300">
+8 -1
web/src/components/post/PostMeta.tsx
··· 1 + import { Link } from "react-router-dom"; 1 2 import { formatFullDate, relativeDate } from "../../lib/util"; 2 3 3 4 interface PostMetaProps { ··· 8 9 export default function PostMeta({ handle, createdAt }: PostMetaProps) { 9 10 return ( 10 11 <div className="flex items-baseline gap-2"> 11 - <span className="text-neutral-200">{handle}</span> 12 + <Link 13 + to={`/profile/${encodeURIComponent(handle)}`} 14 + className="text-neutral-200 hover:underline" 15 + onClick={(event) => event.stopPropagation()} 16 + > 17 + {handle} 18 + </Link> 12 19 <span className="text-neutral-600">·</span> 13 20 <time 14 21 className="text-xs text-neutral-500"
+75
web/src/components/profile/EditProfile.tsx
··· 1 + import { useState } from "react"; 2 + import { Input, Textarea, Button } from "../form/Form"; 3 + import * as limits from "../../lib/limits"; 4 + 5 + interface EditProfileProps { 6 + initialName: string; 7 + initialPronouns: string; 8 + initialBio: string; 9 + onSave: (name?: string, pronouns?: string, bio?: string) => Promise<void>; 10 + onCancel: () => void; 11 + } 12 + 13 + export default function EditProfile({ 14 + initialName, 15 + initialPronouns, 16 + initialBio, 17 + onSave, 18 + onCancel, 19 + }: EditProfileProps) { 20 + const [name, setName] = useState(initialName); 21 + const [pronouns, setPronouns] = useState(initialPronouns); 22 + const [bio, setBio] = useState(initialBio); 23 + 24 + async function handleSubmit() { 25 + await onSave(name || undefined, pronouns || undefined, bio || undefined); 26 + } 27 + 28 + return ( 29 + <div className="space-y-4"> 30 + <div> 31 + <label className="text-xs text-neutral-500 uppercase tracking-wide"> 32 + Name 33 + </label> 34 + <Input 35 + value={name} 36 + onChange={(event) => setName(event.target.value)} 37 + maxLength={limits.PROFILE_NAME} 38 + className="mt-1" 39 + /> 40 + </div> 41 + <div> 42 + <label className="text-xs text-neutral-500 uppercase tracking-wide"> 43 + Pronouns 44 + </label> 45 + <Input 46 + value={pronouns} 47 + onChange={(event) => setPronouns(event.target.value)} 48 + maxLength={limits.PROFILE_PRONOUNS} 49 + className="mt-1" 50 + /> 51 + </div> 52 + <div> 53 + <label className="text-xs text-neutral-500 uppercase tracking-wide"> 54 + Bio 55 + </label> 56 + <Textarea 57 + value={bio} 58 + onChange={(event) => setBio(event.target.value)} 59 + maxLength={limits.PROFILE_BIO} 60 + rows={4} 61 + className="mt-1" 62 + /> 63 + </div> 64 + <div className="flex gap-2"> 65 + <Button onClick={handleSubmit}>save</Button> 66 + <button 67 + onClick={onCancel} 68 + className="text-neutral-500 hover:text-neutral-300 text-xs" 69 + > 70 + cancel 71 + </button> 72 + </div> 73 + </div> 74 + ); 75 + }
+75
web/src/components/profile/ViewProfile.tsx
··· 1 + import { Link } from "react-router-dom"; 2 + import PostBody from "../post/PostBody"; 3 + import type { Profile } from "../../lib/profile"; 4 + 5 + interface ViewProfileProps { 6 + handle: string; 7 + profile: Profile | null; 8 + isOwner: boolean; 9 + onEdit: () => void; 10 + } 11 + 12 + export default function ViewProfile({ 13 + handle, 14 + profile, 15 + isOwner, 16 + onEdit, 17 + }: ViewProfileProps) { 18 + return ( 19 + <> 20 + <div className="flex items-baseline justify-between"> 21 + <h1 className="text-lg text-neutral-200 mb-1"> 22 + {profile?.name ?? handle} 23 + </h1> 24 + {isOwner && ( 25 + <button 26 + onClick={onEdit} 27 + className="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded text-xs" 28 + > 29 + edit profile 30 + </button> 31 + )} 32 + </div> 33 + <p className="text-neutral-500"> 34 + {handle} 35 + {profile?.pronouns && ( 36 + <> 37 + <span className="text-neutral-600 mx-1">·</span> 38 + {profile.pronouns} 39 + </> 40 + )} 41 + </p> 42 + {profile?.bio && ( 43 + <div className="mt-4"> 44 + <PostBody>{profile.bio}</PostBody> 45 + </div> 46 + )} 47 + {profile?.bbsName && ( 48 + <div className="mt-6"> 49 + <p className="text-xs text-neutral-500 uppercase tracking-wide mb-2"> 50 + BBS 51 + </p> 52 + <Link 53 + to={`/bbs/${handle}`} 54 + className="flex items-center justify-between bg-neutral-900 border border-neutral-800 rounded px-4 py-3 hover:border-neutral-700 group" 55 + > 56 + <div> 57 + <div className="text-neutral-200">{profile.bbsName}</div> 58 + {profile.bbsDescription && ( 59 + <div className="text-xs text-neutral-500 mt-1"> 60 + {profile.bbsDescription} 61 + </div> 62 + )} 63 + </div> 64 + <span className="text-neutral-600 group-hover:text-neutral-300 text-lg ml-4"> 65 + 66 + </span> 67 + </Link> 68 + </div> 69 + )} 70 + {!profile?.name && !profile?.bio && !profile?.bbsName && !isOwner && ( 71 + <p className="text-neutral-500 mt-4">No profile yet.</p> 72 + )} 73 + </> 74 + ); 75 + }
+1 -2
web/src/lexicons/types/xyz/atboards/profile.ts
··· 14 14 /*#__PURE__*/ v.stringLength(0, 1000), 15 15 ]), 16 16 ), 17 - createdAt: /*#__PURE__*/ v.datetimeString(), 17 + createdAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 18 18 /** 19 19 * @maxLength 100 20 20 */ ··· 31 31 /*#__PURE__*/ v.stringLength(0, 50), 32 32 ]), 33 33 ), 34 - updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 35 34 }), 36 35 ); 37 36
+3
web/src/lib/limits.ts
··· 12 12 export const REPLY_BODY = 10000; 13 13 export const ATTACHMENT_NAME = 256; 14 14 export const MAX_ATTACHMENTS = 10; 15 + export const PROFILE_NAME = 100; 16 + export const PROFILE_PRONOUNS = 50; 17 + export const PROFILE_BIO = 1000;
+62
web/src/lib/profile.ts
··· 1 + /** Fetch a user's atbbs profile and BBS info. */ 2 + 3 + import { getRecord, resolveIdentity } from "./atproto"; 4 + import { PROFILE, SITE } from "./lexicon"; 5 + import { is } from "@atcute/lexicons/validations"; 6 + import { mainSchema as profileSchema } from "../lexicons/types/xyz/atboards/profile"; 7 + import { mainSchema as siteSchema } from "../lexicons/types/xyz/atboards/site"; 8 + import type { XyzAtboardsProfile, XyzAtboardsSite } from "../lexicons"; 9 + 10 + export interface Profile { 11 + did: string; 12 + handle: string; 13 + pdsUrl: string; 14 + name?: string; 15 + pronouns?: string; 16 + bio?: string; 17 + bbsName?: string; 18 + bbsDescription?: string; 19 + createdAt?: string; 20 + } 21 + 22 + export async function fetchProfile(handle: string): Promise<Profile | null> { 23 + let identity; 24 + try { 25 + identity = await resolveIdentity(handle); 26 + } catch { 27 + return null; 28 + } 29 + 30 + const [profileResult, siteResult] = await Promise.allSettled([ 31 + getRecord(identity.did, PROFILE, "self"), 32 + getRecord(identity.did, SITE, "self"), 33 + ]); 34 + 35 + const profile: Profile = { 36 + did: identity.did, 37 + handle: identity.handle, 38 + pdsUrl: identity.pds ?? "", 39 + }; 40 + 41 + if ( 42 + profileResult.status === "fulfilled" && 43 + is(profileSchema, profileResult.value.value) 44 + ) { 45 + const value = profileResult.value.value as unknown as XyzAtboardsProfile.Main; 46 + profile.name = value.name; 47 + profile.pronouns = value.pronouns; 48 + profile.bio = value.bio; 49 + profile.createdAt = value.createdAt; 50 + } 51 + 52 + if ( 53 + siteResult.status === "fulfilled" && 54 + is(siteSchema, siteResult.value.value) 55 + ) { 56 + const value = siteResult.value.value as unknown as XyzAtboardsSite.Main; 57 + profile.bbsName = value.name; 58 + profile.bbsDescription = value.description; 59 + } 60 + 61 + return profile; 62 + }
+20 -1
web/src/lib/writes.ts
··· 1 1 /** Authenticated PDS write helpers using an atcute Client from useAuth().agent. */ 2 2 3 3 import type { Client } from "@atcute/client"; 4 - import { SITE, BOARD, NEWS, THREAD, REPLY, BAN, HIDE, PIN } from "./lexicon"; 4 + import { SITE, BOARD, NEWS, THREAD, REPLY, BAN, HIDE, PIN, PROFILE } from "./lexicon"; 5 5 import { invalidateBBSCache } from "./bbs"; 6 6 import { nowIso } from "./util"; 7 7 import { getCurrentUser } from "./auth"; ··· 14 14 XyzAtboardsBan, 15 15 XyzAtboardsHide, 16 16 XyzAtboardsPin, 17 + XyzAtboardsProfile, 17 18 } from "../lexicons"; 18 19 19 20 // --- Lexicon value types --- ··· 29 30 type BanValue = Omit<XyzAtboardsBan.Main, "$type">; 30 31 type HideValue = Omit<XyzAtboardsHide.Main, "$type">; 31 32 type PinValue = Omit<XyzAtboardsPin.Main, "$type">; 33 + type ProfileValue = Omit<XyzAtboardsProfile.Main, "$type">; 32 34 33 35 interface BlobRef { 34 36 $type: "blob"; ··· 263 265 }; 264 266 return createRecord(rpc, PIN, value); 265 267 } 268 + 269 + // --- Profiles --- 270 + 271 + export async function putProfile( 272 + rpc: Client, 273 + name?: string, 274 + pronouns?: string, 275 + bio?: string, 276 + ) { 277 + const value: ProfileValue = { 278 + ...(name ? { name } : {}), 279 + ...(pronouns ? { pronouns } : {}), 280 + ...(bio ? { bio } : {}), 281 + createdAt: nowIso() as ProfileValue["createdAt"], 282 + }; 283 + return putRecord(rpc, PROFILE, "self", value); 284 + }
+63
web/src/pages/ProfilePage.tsx
··· 1 + import { Suspense, useState } from "react"; 2 + import { Await, useLoaderData, useRevalidator } from "react-router-dom"; 3 + import { useAuth } from "../lib/auth"; 4 + import { usePageTitle } from "../hooks/usePageTitle"; 5 + import { putProfile } from "../lib/writes"; 6 + import ViewProfile from "../components/profile/ViewProfile"; 7 + import EditProfile from "../components/profile/EditProfile"; 8 + import MyThreadList from "../components/MyThreadList"; 9 + import type { MyThread } from "../lib/mythreads"; 10 + import type { ProfileLoaderData } from "../router/loaders/profile"; 11 + 12 + export default function ProfilePage() { 13 + const { handle, profile, threads } = useLoaderData() as ProfileLoaderData; 14 + const { user, agent } = useAuth(); 15 + const revalidator = useRevalidator(); 16 + const isOwner = user?.handle === handle; 17 + const [editing, setEditing] = useState(false); 18 + usePageTitle(`${profile?.name ?? handle} — atbbs`); 19 + 20 + async function handleSave(name?: string, pronouns?: string, bio?: string) { 21 + if (!agent) return; 22 + await putProfile(agent, name, pronouns, bio); 23 + setEditing(false); 24 + revalidator.revalidate(); 25 + } 26 + 27 + if (editing) { 28 + return ( 29 + <EditProfile 30 + initialName={profile?.name ?? ""} 31 + initialPronouns={profile?.pronouns ?? ""} 32 + initialBio={profile?.bio ?? ""} 33 + onSave={handleSave} 34 + onCancel={() => setEditing(false)} 35 + /> 36 + ); 37 + } 38 + 39 + return ( 40 + <> 41 + <ViewProfile 42 + handle={handle} 43 + profile={profile} 44 + isOwner={isOwner} 45 + onEdit={() => setEditing(true)} 46 + /> 47 + <div className="mt-8"> 48 + <p className="text-xs text-neutral-500 uppercase tracking-wide mb-3"> 49 + Recent Threads 50 + </p> 51 + <Suspense 52 + fallback={<p className="text-neutral-500">Loading...</p>} 53 + > 54 + <Await resolve={threads}> 55 + {(resolved: MyThread[]) => ( 56 + <MyThreadList threads={resolved.slice(0, 5)} /> 57 + )} 58 + </Await> 59 + </Suspense> 60 + </div> 61 + </> 62 + ); 63 + }
+2 -5
web/src/router/loaders/index.ts
··· 1 1 export { homeLoader } from "./home"; 2 2 export { bbsLoader, type BBSLoaderData } from "./bbs"; 3 + export { profileLoader } from "./profile"; 3 4 export { boardLoader, hydrateThreadPage, type ThreadItem } from "./board"; 4 5 export { threadLoader, type ThreadObj } from "./thread"; 5 6 export { requireAuthLoader } from "./account"; 6 7 export type { InboxItem } from "../../lib/inbox"; 7 8 export type { PinnedBBS } from "../../lib/pins"; 8 9 export type { MyThread } from "../../lib/mythreads"; 9 - export { 10 - sysopEditLoader, 11 - sysopModerateLoader, 12 - type HiddenInfo, 13 - } from "./sysop"; 10 + export { sysopEditLoader, sysopModerateLoader, type HiddenInfo } from "./sysop";
+21
web/src/router/loaders/profile.ts
··· 1 + import type { LoaderFunctionArgs } from "react-router-dom"; 2 + import { fetchProfile, type Profile } from "../../lib/profile"; 3 + import { fetchMyThreads, type MyThread } from "../../lib/mythreads"; 4 + 5 + export async function profileLoader({ params }: LoaderFunctionArgs) { 6 + const handle = params.handle!; 7 + const profile = await fetchProfile(handle); 8 + 9 + let threads: Promise<MyThread[]> = Promise.resolve([]); 10 + if (profile) { 11 + threads = fetchMyThreads(profile.pdsUrl, profile.did); 12 + } 13 + 14 + return { handle, profile, threads }; 15 + } 16 + 17 + export type ProfileLoaderData = { 18 + handle: string; 19 + profile: Profile | null; 20 + threads: Promise<MyThread[]>; 21 + };
+8
web/src/router/routes.tsx
··· 11 11 import Home from "../pages/Home"; 12 12 import Login from "../pages/Login"; 13 13 import OAuthCallback from "../pages/OAuthCallback"; 14 + import ProfilePage from "../pages/ProfilePage"; 14 15 import BBS from "../pages/BBS"; 15 16 import Board from "../pages/Board"; 16 17 import Thread from "../pages/Thread"; ··· 24 25 homeLoader, 25 26 bbsLoader, 26 27 boardLoader, 28 + profileLoader, 27 29 threadLoader, 28 30 requireAuthLoader, 29 31 sysopEditLoader, ··· 81 83 element: <News />, 82 84 }, 83 85 ], 86 + }, 87 + { 88 + path: "/profile/:handle", 89 + loader: profileLoader, 90 + element: <ProfilePage />, 91 + errorElement: <ErrorPage />, 84 92 }, 85 93 { path: "*", element: <NotFound /> }, 86 94 ],