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.

add markdown file embeds for attachments

+329 -55
+10
core/util.py
··· 18 18 """Format an ISO datetime string in local timezone.""" 19 19 dt = datetime.fromisoformat(value).astimezone() 20 20 return dt.strftime("%Y-%m-%d %H:%M") 21 + 22 + 23 + def blob_url(pds: str, did: str, cid: str) -> str: 24 + """Construct an ATProto blob fetch URL.""" 25 + return f"{pds}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}" 26 + 27 + 28 + def attachment_cid(attachment: dict) -> str: 29 + """Return the blob CID from a post attachment, or empty string if missing.""" 30 + return (attachment.get("file") or {}).get("ref", {}).get("$link", "")
+31 -15
tui/screens/thread.py
··· 17 17 post_from_record, 18 18 ) 19 19 from core.slingshot import get_record, resolve_identity 20 + from core.util import attachment_cid, blob_url 20 21 from tui.screens.compose import ComposeReplyScreen 21 22 from tui.util import ban_user, hide_post, require_session, require_sysop 22 23 from tui.widgets.breadcrumb import Breadcrumb ··· 82 83 self.load_replies(focus_reply=self._focus_reply) 83 84 self._focus_reply = None 84 85 86 + def _reply_widgets(self) -> list[Post]: 87 + return [post for post in self.query(Post) if self._is_reply_widget(post)] 88 + 89 + def _focus_reply_by_uri(self, uri: str) -> None: 90 + for post in self._reply_widgets(): 91 + if post.record_uri == uri: 92 + post.focus() 93 + return 94 + 85 95 def _update_page_status(self) -> None: 86 96 text = ( 87 97 f"page {self._page} of {self._total_pages}" if self._total_pages > 1 else "" ··· 93 103 return post.record_uri is not None and post.record_uri != self.thread.uri 94 104 95 105 def _clear_replies(self) -> None: 96 - for post in self.query(Post): 97 - if self._is_reply_widget(post): 98 - post.remove() 106 + for post in self._reply_widgets(): 107 + post.remove() 99 108 100 109 @work(exclusive=True) 101 - async def load_replies(self, page: int = 1, focus_reply: str | None = None) -> None: 110 + async def load_replies( 111 + self, 112 + page: int = 1, 113 + focus_reply: str | None = None, 114 + focus_first_reply: bool = False, 115 + ) -> None: 102 116 client = self.app.http_client 103 117 try: 104 118 result = await fetch_replies( ··· 174 188 *post_widgets, before=self.query_one("#page-status-bottom") 175 189 ) 176 190 177 - # Focus first reply 178 - replies = [post for post in self.query(Post) if self._is_reply_widget(post)] 179 - if replies: 180 - replies[0].focus() 191 + if focus_reply: 192 + self._focus_reply_by_uri(focus_reply) 193 + elif focus_first_reply: 194 + replies = self._reply_widgets() 195 + if replies: 196 + replies[0].focus() 181 197 182 198 def action_ban(self) -> None: 183 199 if not require_sysop(self, self.bbs): ··· 210 226 def action_next_page(self) -> None: 211 227 if self._page < self._total_pages: 212 228 self._clear_replies() 213 - self.load_replies(page=self._page + 1) 229 + self.load_replies(page=self._page + 1, focus_first_reply=True) 214 230 215 231 def action_prev_page(self) -> None: 216 232 if self._page > 1: 217 233 self._clear_replies() 218 - self.load_replies(page=self._page - 1) 234 + self.load_replies(page=self._page - 1, focus_first_reply=True) 219 235 220 236 def refresh_data(self) -> None: 221 237 self._clear_replies() 222 238 self._page = 1 223 - self.load_replies(page=1) 239 + self.load_replies(page=1, focus_first_reply=True) 224 240 225 241 def action_reply(self) -> None: 226 242 session = require_session(self) ··· 290 306 downloads.mkdir(parents=True, exist_ok=True) 291 307 292 308 client = self.app.http_client 293 - for att in post.attachments: 294 - name = att.get("name", "file") 295 - cid = att.get("file", {}).get("ref", {}).get("$link", "") 309 + for attachment in post.attachments: 310 + name = attachment.get("name", "file") 311 + cid = attachment_cid(attachment) 296 312 if not cid or not post.author_pds or not post.author_did: 297 313 continue 298 314 299 - url = f"{post.author_pds}/xrpc/com.atproto.sync.getBlob?did={post.author_did}&cid={cid}" 315 + url = blob_url(post.author_pds, post.author_did, cid) 300 316 try: 301 317 resp = await client.get(url) 302 318 resp.raise_for_status()
+62 -7
tui/widgets/post.py
··· 1 + import re 1 2 import webbrowser 3 + from urllib.parse import unquote 2 4 3 5 from textual.app import ComposeResult 4 6 from textual.widget import Widget 5 7 from textual.widgets import Markdown, Static 6 8 7 9 from core.models import AtUri 8 - from core.util import format_datetime_local as format_datetime 10 + from core.util import ( 11 + attachment_cid, 12 + blob_url, 13 + format_datetime_local as format_datetime, 14 + ) 15 + 16 + ATTACHMENT_LINK_RE = re.compile(r"!?\[([^\]]*)\]\(attachment:([^)\s]+)\)") 17 + ATTACHMENT_REF_RE = re.compile(r"attachment:([^)\s>\"']+)") 18 + BODY_LINK_RE = re.compile(r"!?\[([^\]]+)\]\(([^)\s]+)\)") 19 + 20 + 21 + def extract_body_links(body: str) -> list[tuple[str, str]]: 22 + """Return (label, url) for every markdown link in body, in document order.""" 23 + return [ 24 + (match.group(1).strip(), match.group(2)) 25 + for match in BODY_LINK_RE.finditer(body) 26 + ] 27 + 28 + 29 + def resolve_attachment_links( 30 + body: str, 31 + attachments: list[dict], 32 + pds: str | None, 33 + did: str | None, 34 + ) -> str: 35 + """Rewrite [label](attachment:name) markdown links to point at blob URLs.""" 36 + if not attachments or not pds or not did: 37 + return body 38 + attachments_by_name = { 39 + attachment.get("name"): attachment for attachment in attachments 40 + } 41 + 42 + def replace_match(match: re.Match) -> str: 43 + label = match.group(1) 44 + name = unquote(match.group(2)) 45 + attachment = attachments_by_name.get(name) 46 + cid = attachment_cid(attachment) if attachment else "" 47 + if not cid: 48 + return f"[{label or name}] (missing attachment)" 49 + return f"[{label or name}]({blob_url(pds, did, cid)})" 50 + 51 + return ATTACHMENT_LINK_RE.sub(replace_match, body) 52 + 53 + 54 + def referenced_attachment_names(body: str) -> set[str]: 55 + return {unquote(match.group(1)) for match in ATTACHMENT_REF_RE.finditer(body)} 9 56 10 57 11 58 class AttachmentLink(Static, can_focus=True): ··· 25 72 } 26 73 """ 27 74 28 - def __init__(self, name: str, url: str, **kwargs) -> None: 29 - super().__init__(f"[{name}]", markup=False, **kwargs) 75 + def __init__(self, display: str, url: str, **kwargs) -> None: 76 + super().__init__(display, markup=False, **kwargs) 30 77 self._url = url 31 78 32 79 def on_click(self) -> None: ··· 112 159 yield Static(self._title, classes="post-title", markup=False) 113 160 if self._parent_preview: 114 161 yield Markdown(self._parent_preview, classes="post-parent") 115 - yield Markdown(self._body, classes="post-body") 162 + body = resolve_attachment_links( 163 + self._body, self.attachments, self.author_pds, self.author_did 164 + ) 165 + yield Markdown(body, classes="post-body") 166 + for number, (label, url) in enumerate(extract_body_links(body), 1): 167 + yield AttachmentLink(f"[{number}] {label}", url) 168 + embedded = referenced_attachment_names(self._body) 116 169 for attachment in self.attachments: 117 170 name = attachment.get("name", "file") 118 - cid = attachment.get("file", {}).get("ref", {}).get("$link", "") 171 + if name in embedded: 172 + continue 173 + cid = attachment_cid(attachment) 119 174 if cid and self.author_pds and self.author_did: 120 - url = f"{self.author_pds}/xrpc/com.atproto.sync.getBlob?did={self.author_did}&cid={cid}" 121 - yield AttachmentLink(name, url) 175 + url = blob_url(self.author_pds, self.author_did, cid) 176 + yield AttachmentLink(f"[{name}]", url) 122 177 else: 123 178 yield Static(f"[{name}]", classes="post-attachment", markup=False)
+38 -2
web/src/components/form/ComposeForm.tsx
··· 1 - import type { SyntheticEvent } from "react"; 1 + import { useRef, type SyntheticEvent } from "react"; 2 2 import { Send, Paperclip } from "lucide-react"; 3 3 import { Input, Textarea, Button } from "./Form"; 4 4 import FileChips from "./FileChips"; ··· 43 43 posting = false, 44 44 className = "", 45 45 }: ComposeFormProps) { 46 + const textareaRef = useRef<HTMLTextAreaElement>(null); 47 + 48 + function insertSnippet(snippet: string) { 49 + const textarea = textareaRef.current; 50 + const isFocused = !!textarea && document.activeElement === textarea; 51 + if (!textarea || !isFocused) { 52 + const sep = body.length > 0 && !body.endsWith("\n") ? "\n" : ""; 53 + onBodyChange(body + sep + snippet); 54 + return; 55 + } 56 + const start = textarea.selectionStart ?? body.length; 57 + const end = textarea.selectionEnd ?? body.length; 58 + const next = body.slice(0, start) + snippet + body.slice(end); 59 + onBodyChange(next); 60 + requestAnimationFrame(() => { 61 + textarea.focus(); 62 + const cursor = start + snippet.length; 63 + textarea.setSelectionRange(cursor, cursor); 64 + }); 65 + } 66 + 46 67 function addFiles(fileList: FileList | null) { 47 68 if (!fileList) return; 48 69 const combined = [...files, ...Array.from(fileList)].slice( ··· 58 79 onFilesChange(files.filter((_, i) => i !== index)); 59 80 } 60 81 82 + function insertAttachment(file: File) { 83 + const encoded = encodeURIComponent(file.name); 84 + const snippet = file.type.startsWith("image/") 85 + ? `![${file.name}](attachment:${encoded})` 86 + : `[${file.name}](attachment:${encoded})`; 87 + insertSnippet(snippet); 88 + } 89 + 61 90 return ( 62 91 <form onSubmit={onSubmit} className={`space-y-3 ${className}`}> 63 92 {replyingTo && onClearReplyTo && ( ··· 86 115 )} 87 116 88 117 <Textarea 118 + ref={textareaRef} 89 119 name="body" 90 120 value={body} 91 121 onChange={(e) => onBodyChange(e.target.value)} ··· 101 131 maxLength={bodyMaxLength} 102 132 /> 103 133 104 - {files.length > 0 && <FileChips files={files} onRemove={removeFile} />} 134 + {files.length > 0 && ( 135 + <FileChips 136 + files={files} 137 + onRemove={removeFile} 138 + onInsert={insertAttachment} 139 + /> 140 + )} 105 141 106 142 <div className="flex items-center gap-3"> 107 143 <Button type="submit" disabled={posting}>
+15 -3
web/src/components/form/FileChips.tsx
··· 1 1 interface FileChipsProps { 2 2 files: File[]; 3 3 onRemove: (index: number) => void; 4 + onInsert?: (file: File) => void; 4 5 } 5 6 6 - export default function FileChips({ files, onRemove }: FileChipsProps) { 7 + export default function FileChips({ 8 + files, 9 + onRemove, 10 + onInsert, 11 + }: FileChipsProps) { 7 12 return ( 8 13 <div className="flex flex-wrap gap-2 text-xs text-neutral-400"> 9 14 {files.map((file, i) => ( 10 15 <span 11 16 key={i} 12 - className="flex items-center gap-1 bg-neutral-800 px-2 py-1 rounded" 17 + className={`flex items-center gap-1 bg-neutral-800 px-2 py-1 rounded ${ 18 + onInsert ? "hover:bg-neutral-700 cursor-pointer" : "" 19 + }`} 20 + onClick={onInsert ? () => onInsert(file) : undefined} 21 + title={onInsert ? "click to embed in body" : undefined} 13 22 > 14 23 {file.name} 15 24 <button 16 25 type="button" 17 - onClick={() => onRemove(i)} 26 + onClick={(e) => { 27 + e.stopPropagation(); 28 + onRemove(i); 29 + }} 18 30 aria-label={`Remove ${file.name}`} 19 31 className="text-neutral-400 hover:text-red-400" 20 32 >
+8 -2
web/src/components/form/Form.tsx
··· 1 1 import type { 2 2 ButtonHTMLAttributes, 3 3 InputHTMLAttributes, 4 + Ref, 4 5 TextareaHTMLAttributes, 5 6 } from "react"; 6 7 ··· 16 17 return <input className={`${inputStyles} ${className ?? ""}`} {...rest} />; 17 18 } 18 19 19 - export function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) { 20 - const { className, ...rest } = props; 20 + export function Textarea( 21 + props: TextareaHTMLAttributes<HTMLTextAreaElement> & { 22 + ref?: Ref<HTMLTextAreaElement>; 23 + }, 24 + ) { 25 + const { className, ref, ...rest } = props; 21 26 return ( 22 27 <textarea 28 + ref={ref} 23 29 className={`${inputStyles} resize-y ${className ?? ""}`} 24 30 {...rest} 25 31 />
+9 -12
web/src/components/post/AttachmentLink.tsx
··· 1 1 import { Paperclip } from "lucide-react"; 2 + import { blobUrl } from "../../lib/atproto"; 2 3 3 4 interface AttachmentLinkProps { 4 5 pds: string; ··· 13 14 cid, 14 15 name, 15 16 }: AttachmentLinkProps) { 17 + const url = blobUrl(pds, did, cid); 18 + 16 19 async function download(e: React.MouseEvent) { 17 20 e.preventDefault(); 18 21 try { 19 - const resp = await fetch( 20 - `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, 21 - ); 22 + const resp = await fetch(url); 22 23 const blob = await resp.blob(); 23 - const url = URL.createObjectURL(blob); 24 + const objectUrl = URL.createObjectURL(blob); 24 25 const a = document.createElement("a"); 25 - a.href = url; 26 + a.href = objectUrl; 26 27 a.download = name; 27 28 a.click(); 28 - URL.revokeObjectURL(url); 29 + URL.revokeObjectURL(objectUrl); 29 30 } catch { 30 - // Fall back to opening in a new tab 31 - window.open( 32 - `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, 33 - "_blank", 34 - ); 31 + window.open(url, "_blank"); 35 32 } 36 33 } 37 34 38 35 return ( 39 36 <a 40 - href={`${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`} 37 + href={url} 41 38 onClick={download} 42 39 className="text-xs text-neutral-400 hover:text-neutral-300 inline-flex items-center gap-1 mt-3 cursor-pointer" 43 40 >
+8 -4
web/src/components/post/NewsCard.tsx
··· 1 1 import type { NewsPost } from "../../lib/bbs"; 2 2 import AttachmentLink from "./AttachmentLink"; 3 3 import PostActions from "./PostActions"; 4 - import PostBody from "./PostBody"; 4 + import PostBody, { unembeddedAttachments } from "./PostBody"; 5 5 import PostMeta from "./PostMeta"; 6 6 7 7 interface NewsCardProps { ··· 21 21 isSysop, 22 22 onDelete, 23 23 }: NewsCardProps) { 24 + const remaining = unembeddedAttachments(news.attachments, news.body); 25 + 24 26 return ( 25 27 <article className="bg-neutral-900 border border-neutral-800 rounded p-4"> 26 28 <div className="flex items-baseline justify-between mb-3"> ··· 28 30 <PostActions isAuthor={isSysop} isSysop={false} onDelete={onDelete} /> 29 31 </div> 30 32 <h1 className="text-lg text-neutral-200 font-bold mb-3">{news.title}</h1> 31 - <PostBody>{news.body}</PostBody> 32 - {news.attachments && news.attachments.length > 0 && ( 33 + <PostBody attachments={news.attachments} pds={pds} did={did}> 34 + {news.body} 35 + </PostBody> 36 + {remaining.length > 0 && ( 33 37 <div className="mt-3 space-y-1"> 34 - {news.attachments.map((attachment, index) => ( 38 + {remaining.map((attachment, index) => ( 35 39 <AttachmentLink 36 40 key={index} 37 41 pds={pds}
+121 -3
web/src/components/post/PostBody.tsx
··· 1 - import Markdown from "react-markdown"; 1 + import Markdown, { defaultUrlTransform } from "react-markdown"; 2 + import type { Components } from "react-markdown"; 3 + import AttachmentLink from "./AttachmentLink"; 4 + import { blobUrl } from "../../lib/atproto"; 5 + import type { PostAttachment } from "../../lib/bbs"; 6 + 7 + interface PostBodyProps { 8 + children: string; 9 + attachments?: PostAttachment[]; 10 + pds?: string; 11 + did?: string; 12 + } 13 + 14 + const ATTACHMENT_PREFIX = "attachment:"; 15 + const ATTACHMENT_NAME_RE = /attachment:([^\s)>"']+)/g; 16 + 17 + function decodeName(raw: string): string { 18 + try { 19 + return decodeURIComponent(raw); 20 + } catch { 21 + return raw; 22 + } 23 + } 2 24 3 - export default function PostBody({ children }: { children: string }) { 25 + function MissingAttachment({ name }: { name: string }) { 26 + return ( 27 + <span className="text-xs text-red-400">missing attachment: {name}</span> 28 + ); 29 + } 30 + 31 + function ImageEmbed({ url, alt }: { url: string; alt: string }) { 32 + return ( 33 + <a href={url} target="_blank" rel="noreferrer"> 34 + <img 35 + src={url} 36 + alt={alt} 37 + loading="lazy" 38 + className="max-w-full max-h-96 rounded" 39 + /> 40 + </a> 41 + ); 42 + } 43 + 44 + function findAttachment( 45 + url: string | undefined, 46 + attachments: PostAttachment[], 47 + ): { name: string; attachment: PostAttachment | undefined } | null { 48 + if (typeof url !== "string" || !url.startsWith(ATTACHMENT_PREFIX)) return null; 49 + const name = decodeName(url.slice(ATTACHMENT_PREFIX.length)); 50 + return { name, attachment: attachments.find((a) => a.name === name) }; 51 + } 52 + 53 + const passAttachmentUrls = (url: string) => 54 + url.startsWith(ATTACHMENT_PREFIX) ? url : defaultUrlTransform(url); 55 + 56 + function attachmentMarkdownComponents( 57 + attachments: PostAttachment[], 58 + pds: string, 59 + did: string, 60 + ): Components { 61 + return { 62 + img({ src, alt }) { 63 + const ref = findAttachment(src, attachments); 64 + if (!ref) return <img src={src} alt={alt} />; 65 + if (!ref.attachment) return <MissingAttachment name={ref.name} />; 66 + const url = blobUrl(pds, did, ref.attachment.file.ref.$link); 67 + return <ImageEmbed url={url} alt={alt ?? ref.name} />; 68 + }, 69 + a({ href, children, ...rest }) { 70 + const ref = findAttachment(href, attachments); 71 + if (!ref) { 72 + return ( 73 + <a href={href} {...rest}> 74 + {children} 75 + </a> 76 + ); 77 + } 78 + if (!ref.attachment) return <MissingAttachment name={ref.name} />; 79 + return ( 80 + <AttachmentLink 81 + pds={pds} 82 + did={did} 83 + cid={ref.attachment.file.ref.$link} 84 + name={ref.attachment.name} 85 + /> 86 + ); 87 + }, 88 + }; 89 + } 90 + 91 + export default function PostBody({ 92 + children, 93 + attachments, 94 + pds, 95 + did, 96 + }: PostBodyProps) { 97 + const resolver = 98 + attachments && pds && did 99 + ? { 100 + urlTransform: passAttachmentUrls, 101 + components: attachmentMarkdownComponents(attachments, pds, did), 102 + } 103 + : {}; 104 + 4 105 return ( 5 106 <div className="text-neutral-400 leading-relaxed prose dark:prose-invert prose-sm prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-h4:text-sm"> 6 - <Markdown>{children}</Markdown> 107 + <Markdown {...resolver}>{children}</Markdown> 7 108 </div> 8 109 ); 9 110 } 111 + 112 + function referencedAttachmentNames(body: string): Set<string> { 113 + return new Set( 114 + Array.from(body.matchAll(ATTACHMENT_NAME_RE), (match) => 115 + decodeName(match[1]), 116 + ), 117 + ); 118 + } 119 + 120 + export function unembeddedAttachments<T extends { name: string }>( 121 + attachments: T[] | undefined, 122 + body: string, 123 + ): T[] { 124 + if (!attachments?.length) return []; 125 + const embedded = referencedAttachmentNames(body); 126 + return attachments.filter((a) => !embedded.has(a.name)); 127 + }
+10 -3
web/src/components/post/ReplyCard.tsx
··· 2 2 import AttachmentLink from "./AttachmentLink"; 3 3 import ModerationBadge from "./ModerationBadge"; 4 4 import PostActions from "./PostActions"; 5 - import PostBody from "./PostBody"; 5 + import PostBody, { unembeddedAttachments } from "./PostBody"; 6 6 import PostMeta from "./PostMeta"; 7 7 8 8 export interface Reply { ··· 51 51 const isAuthor = userDid === reply.did; 52 52 const isSysop = userDid === sysopDid; 53 53 const isModerated = !!banRkey || !!hideRkey; 54 + const remaining = unembeddedAttachments(reply.attachments, reply.body); 54 55 55 56 return ( 56 57 <div ··· 90 91 </button> 91 92 )} 92 93 93 - <PostBody>{reply.body}</PostBody> 94 + <PostBody 95 + attachments={reply.attachments} 96 + pds={reply.pds} 97 + did={reply.did} 98 + > 99 + {reply.body} 100 + </PostBody> 94 101 95 - {reply.attachments.map((attachment, index) => ( 102 + {remaining.map((attachment, index) => ( 96 103 <AttachmentLink 97 104 key={index} 98 105 pds={reply.pds}
+11 -4
web/src/components/post/ThreadCard.tsx
··· 2 2 import AttachmentLink from "./AttachmentLink"; 3 3 import ModerationBadge from "./ModerationBadge"; 4 4 import PostActions from "./PostActions"; 5 - import PostBody from "./PostBody"; 5 + import PostBody, { unembeddedAttachments } from "./PostBody"; 6 6 import PostMeta from "./PostMeta"; 7 7 8 8 interface ThreadCardProps { ··· 33 33 const isAuthor = !!(userDid && userDid === thread.did); 34 34 const isSysop = !!(userDid && userDid === sysopDid); 35 35 const isModerated = !!banRkey || !!hideRkey; 36 + const remaining = unembeddedAttachments(thread.attachments, thread.body); 36 37 37 38 return ( 38 39 <article ··· 58 59 <h1 className="text-lg text-neutral-200 font-bold mb-3"> 59 60 {thread.title} 60 61 </h1> 61 - <PostBody>{thread.body}</PostBody> 62 - {thread.attachments && thread.attachments.length > 0 && ( 62 + <PostBody 63 + attachments={thread.attachments} 64 + pds={thread.authorPds} 65 + did={thread.did} 66 + > 67 + {thread.body} 68 + </PostBody> 69 + {remaining.length > 0 && ( 63 70 <div className="mt-3 space-y-1"> 64 - {thread.attachments.map((attachment, index) => ( 71 + {remaining.map((attachment, index) => ( 65 72 <AttachmentLink 66 73 key={index} 67 74 pds={thread.authorPds}
+6
web/src/lib/atproto.ts
··· 41 41 cursor?: string; 42 42 } 43 43 44 + // --- URLs --- 45 + 46 + export function blobUrl(pds: string, did: string, cid: string): string { 47 + return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`; 48 + } 49 + 44 50 // --- Low-level JSON fetcher --- 45 51 46 52 async function fetchJson<T>(url: string): Promise<T> {