Retro Bulletin Board Systems on atproto. Web app and TUI.
atbbs.xyz
python
tui
atproto
bbs
1import webbrowser
2
3from textual.app import ComposeResult
4from textual.widget import Widget
5from textual.widgets import Markdown, Static
6
7from core.models import AtUri
8from core.util import format_datetime_local as format_datetime
9
10
11class AttachmentLink(Static, can_focus=True):
12 """A clickable attachment that opens in a browser."""
13
14 DEFAULT_CSS = """
15 AttachmentLink {
16 color: #8a8a8a;
17 margin-top: 1;
18 width: auto;
19 }
20 AttachmentLink:hover {
21 color: #a3a3a3;
22 }
23 AttachmentLink:focus {
24 color: #e5e5e5;
25 }
26 """
27
28 def __init__(self, name: str, url: str, **kwargs) -> None:
29 super().__init__(f"[{name}]", markup=False, **kwargs)
30 self._url = url
31
32 def on_click(self) -> None:
33 webbrowser.open(self._url)
34
35 def key_enter(self) -> None:
36 webbrowser.open(self._url)
37
38
39class Post(Widget, can_focus=True):
40 """A post card showing author, date, optional title, body, and attachments."""
41
42 DEFAULT_CSS = """
43 Post {
44 height: auto;
45 padding: 1 2;
46 margin: 0 0 1 0;
47 border: solid #262626;
48 background: #1f1f1f;
49 }
50 Post:focus {
51 border: solid #525252;
52 }
53 Post .post-meta {
54 color: #8a8a8a;
55 margin-bottom: 1;
56 }
57 Post .post-title {
58 color: #e5e5e5;
59 text-style: bold;
60 margin-bottom: 1;
61 }
62 Post .post-body {
63 color: #a3a3a3;
64 }
65 Post .post-attachment {
66 color: #8a8a8a;
67 margin-top: 1;
68 }
69 Post .post-parent {
70 color: #8a8a8a;
71 border-left: solid #525252;
72 padding-left: 2;
73 margin-bottom: 1;
74 }
75 """
76
77 def __init__(
78 self,
79 author: str,
80 date: str,
81 body: str,
82 title: str | None = None,
83 author_did: str | None = None,
84 author_pds: str | None = None,
85 record_uri: str | None = None,
86 collection: str | None = None,
87 attachments: list[dict] | None = None,
88 parent_preview: str | None = None,
89 **kwargs,
90 ) -> None:
91 super().__init__(**kwargs)
92 self._author = author
93 self._date = format_datetime(date)
94 self._title = title
95 self._body = body
96 self.author_did = author_did
97 self.author_pds = author_pds
98 self.record_uri = record_uri
99 self.collection = collection
100 self.attachments = attachments or []
101 self._parent_preview = parent_preview
102
103 @property
104 def rkey(self) -> str | None:
105 if self.record_uri:
106 return AtUri.parse(self.record_uri).rkey
107 return None
108
109 def compose(self) -> ComposeResult:
110 yield Static(f"{self._author} {self._date}", classes="post-meta", markup=False)
111 if self._title:
112 yield Static(self._title, classes="post-title", markup=False)
113 if self._parent_preview:
114 yield Markdown(self._parent_preview, classes="post-parent")
115 yield Markdown(self._body, classes="post-body")
116 for attachment in self.attachments:
117 name = attachment.get("name", "file")
118 cid = attachment.get("file", {}).get("ref", {}).get("$link", "")
119 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)
122 else:
123 yield Static(f"[{name}]", classes="post-attachment", markup=False)