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 link to replies

+41 -27
+4
.gitignore
··· 15 15 # Node 16 16 node_modules/ 17 17 18 + # Build artifacts 19 + web/static/app.js 20 + web/static/style.css 21 + 18 22 # Auth secrets and database 19 23 secrets.json 20 24 *.db
+16 -1
core/records.py
··· 65 65 thread: Thread, 66 66 page: int = 1, 67 67 page_size: int = 10, 68 + focus_reply: str | None = None, 68 69 ) -> RepliesPage: 69 - """Fetch all reply refs, then hydrate only the requested page (oldest first).""" 70 + """Fetch all reply refs, then hydrate only the requested page (oldest first). 71 + 72 + If focus_reply is provided (an AT URI), automatically jump to the page 73 + containing that reply. 74 + """ 70 75 # Fetch all refs (cheap — just did/collection/rkey) 71 76 backlinks = await get_replies(client, thread.uri, limit=1000) 72 77 all_refs = list(reversed(backlinks.records)) # oldest first 73 78 74 79 total = len(all_refs) 75 80 total_pages = max(1, (total + page_size - 1) // page_size) 81 + 82 + # If a specific reply is requested, find its page 83 + if focus_reply: 84 + for i, ref in enumerate(all_refs): 85 + if f"at://{ref.did}/{ref.collection}/{ref.rkey}" == focus_reply: 86 + page = (i // page_size) + 1 87 + break 88 + 76 89 page = max(1, min(page, total_pages)) 77 90 78 91 # Slice the page we need ··· 406 419 items.append( 407 420 { 408 421 "type": "reply", 422 + "reply_uri": r.uri, 409 423 "thread_title": thread_title, 410 424 "thread_uri": thread_uri, 411 425 "handle": authors[author_did].handle, ··· 450 464 items.append( 451 465 { 452 466 "type": "quote", 467 + "reply_uri": r.uri, 453 468 "thread_title": "", 454 469 "thread_uri": thread_uri, 455 470 "handle": authors[author_did].handle,
+2 -1
tui/screens/activity.py
··· 90 90 ) 91 91 from tui.screens.thread import ThreadScreen 92 92 93 - self.app.push_screen(ThreadScreen(bbs, handle, thread)) 93 + focus_reply = item.get("reply_uri") 94 + self.app.push_screen(ThreadScreen(bbs, handle, thread, focus_reply=focus_reply)) 94 95 except Exception: 95 96 self.notify("Could not open thread.", severity="error") 96 97
+6 -3
tui/screens/thread.py
··· 22 22 Binding("ctrl+s", "save_attachment", "save attachments", show=False), 23 23 ] 24 24 25 - def __init__(self, bbs: BBS, handle: str, thread: Thread) -> None: 25 + def __init__(self, bbs: BBS, handle: str, thread: Thread, focus_reply: str | None = None) -> None: 26 26 super().__init__() 27 27 self.bbs = bbs 28 28 self.handle = handle 29 29 self.thread = thread 30 + self._focus_reply = focus_reply 30 31 self._page: int = 1 31 32 self._total_pages: int = 1 32 33 self._replies_map: dict[str, object] = {} ··· 65 66 self.query(Post).first().focus() 66 67 except Exception: 67 68 pass 68 - self.load_replies() 69 + self.load_replies(focus_reply=self._focus_reply) 70 + self._focus_reply = None # only use on first load 69 71 70 72 def _update_page_status(self) -> None: 71 73 text = f"page {self._page} of {self._total_pages}" if self._total_pages > 1 else "" ··· 79 81 self._replies_map.clear() 80 82 81 83 @work(exclusive=True) 82 - async def load_replies(self, page: int = 1) -> None: 84 + async def load_replies(self, page: int = 1, focus_reply: str | None = None) -> None: 83 85 client = self.app.http_client 84 86 try: 85 87 result = await fetch_replies( ··· 87 89 self.bbs, 88 90 self.thread, 89 91 page=page, 92 + focus_reply=focus_reply, 90 93 ) 91 94 except Exception: 92 95 self.notify("Could not fetch replies.", severity="error")
+2 -1
web/routes.py
··· 245 245 client = current_app.http_client 246 246 page = int(request.args.get("page", 1)) 247 247 handle = request.args.get("handle", "") 248 + focus_reply = request.args.get("reply", None) 248 249 249 250 try: 250 251 if handle: ··· 276 277 ) 277 278 278 279 try: 279 - result = await hydrate_replies(client, bbs, dummy_thread, page=page) 280 + result = await hydrate_replies(client, bbs, dummy_thread, page=page, focus_reply=focus_reply) 280 281 except Exception: 281 282 return {"replies": [], "page": 1, "total_pages": 1, "total_replies": 0} 282 283
-13
web/static/app.js
··· 1 - "use strict";(()=>{function u(e){let t=document.createElement("span");return t.textContent=e,t.innerHTML}function x(e){let t=new Date(e),n=r=>String(r).padStart(2,"0");return`${t.getFullYear()}-${n(t.getMonth()+1)}-${n(t.getDate())} ${n(t.getHours())}:${n(t.getMinutes())}`}function h(e){let t=new Date(e),n=Math.floor((Date.now()-t.getTime())/1e3);return n<60?"just now":n<3600?`${Math.floor(n/60)}m ago`:n<86400?`${Math.floor(n/3600)}h ago`:n<604800?`${Math.floor(n/86400)}d ago`:x(e)}function p(e,t){return document.getElementById(e)?.dataset[t]??""}function L(){document.querySelectorAll(".localtime").forEach(e=>{let t=e.getAttribute("datetime");t&&(e.textContent=h(t),e.setAttribute("title",x(t)))})}async function f(e){let t=await fetch(e);if(!t.ok)throw new Error(`${t.status}`);return t.json()}var w="https://slingshot.microcosm.blue/xrpc",j="https://constellation.microcosm.blue/xrpc";function b(e){let t=e.split("/");return{did:t[2],collection:t[3],rkey:t[4]}}async function F(e){return f(`${w}/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(e)}`)}async function y(e){let t=[...new Set(e)],n=await Promise.allSettled(t.map(F)),r={};for(let s of n)s.status==="fulfilled"&&(r[s.value.did]=s.value);return r}async function O(e,t,n){return f(`${w}/com.atproto.repo.getRecord?repo=${encodeURIComponent(e)}&collection=${encodeURIComponent(t)}&rkey=${encodeURIComponent(n)}`)}async function K(e){return(await Promise.allSettled(e.map(n=>O(n.did,n.collection,n.rkey)))).filter(n=>n.status==="fulfilled").map(n=>n.value)}async function z(e,t,n=25,r){let s=`${j}/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(e)}&source=${encodeURIComponent(t)}&limit=${n}`;return r&&(s+=`&cursor=${encodeURIComponent(r)}`),f(s)}async function v(e,t,n){let r=n?.limit??50,s=await z(e,t,r,n?.cursor);if(!s.records.length)return{records:[],cursor:null};let o=(await K(s.records)).filter(c=>{let{did:i}=b(c.uri);return!(n?.excludeDid&&i===n.excludeDid||n?.bannedDids?.has(i)||n?.hiddenPosts?.has(c.uri))});if(!o.length)return{records:[],cursor:s.cursor??null};let a=o.map(c=>b(c.uri).did),l=await y(a);return{records:o.filter(c=>b(c.uri).did in l).map(c=>{let i=b(c.uri),g=l[i.did];return{uri:c.uri,did:i.did,rkey:i.rkey,handle:g.handle,pds:g.pds??"",value:c.value}}),cursor:s.cursor??null}}async function R(e,t,n,r=20){try{return(await f(`${e}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(t)}&collection=${encodeURIComponent(n)}&limit=${r}`)).records}catch{return[]}}var T="xyz.atboards.site";var B="xyz.atboards.thread",I="xyz.atboards.reply";function k(){let e=document.getElementById("handle-form");e.addEventListener("submit",t=>{t.preventDefault();let r=e.elements.namedItem("handle").value.trim();r&&(window.location.href="/bbs/"+encodeURIComponent(r))}),J()}async function J(){try{let e=await f(`https://ufos-api.microcosm.blue/records?collection=${T}&limit=50`);if(!e.length)return;e.length>5&&(e=e.sort(()=>Math.random()-.5).slice(0,5));let t=e.map(s=>s.did),n=await y(t),r=document.getElementById("discover-list");if(!r)return;for(let s of e){if(!(s.did in n))continue;let d=n[s.did].handle,o=s.record.name||d,a=s.record.description||"",l=document.createElement("a");l.href="/bbs/"+encodeURIComponent(d),l.className="flex items-baseline gap-3 px-3 py-2 -mx-3 rounded hover:bg-neutral-900 group",l.innerHTML=`<span class="text-neutral-200 group-hover:text-white">${u(o)}</span><span class="text-neutral-500">${u(a)}</span>`,r.appendChild(l)}document.getElementById("discover")?.classList.remove("hidden")}catch{}}function M(e){let t=null;function n(s){let d=e.fetchUrl(s);f(d).then(o=>{let a=document.getElementById(e.containerId),l=document.getElementById(e.loadingId);if(!a)return;l&&l.remove(),e.onData?.(o);let m=o[e.dataKey];for(let i of m){let g=e.renderItem(i);typeof g=="string"?a.insertAdjacentHTML("beforeend",g):a.appendChild(g)}t=o.cursor??null;let c=document.getElementById(e.nextContainerId);c&&c.classList.toggle("hidden",!t),a.children.length===0&&e.emptyMessage&&(a.innerHTML=`<p class="text-neutral-500">${e.emptyMessage}</p>`)}).catch(()=>{let o=document.getElementById(e.loadingId);o&&(o.textContent="Could not fetch data.")})}let r=document.getElementById(e.loadMoreId);r&&r.addEventListener("click",()=>{t&&n(t)}),n(null)}function H(){let e=p("threads","handle"),t=p("threads","slug");M({fetchUrl:n=>{let r=`/api/threads/${encodeURIComponent(e)}/${encodeURIComponent(t)}`;return n&&(r+=`?cursor=${encodeURIComponent(n)}`),r},containerId:"threads",loadingId:"threads-loading",nextContainerId:"threads-next",loadMoreId:"load-more",dataKey:"threads",emptyMessage:"No threads yet.",renderItem:n=>{let r=document.createElement("a");return r.href=`/bbs/${e}/thread/${n.did}/${n.rkey}`,r.className="flex items-baseline justify-between gap-4 px-3 py-2.5 -mx-3 rounded hover:bg-neutral-900 group",r.innerHTML=`<span class="text-neutral-300 group-hover:text-white truncate">${u(n.title)}</span><span class="shrink-0 text-xs text-neutral-500">${u(n.handle)} \xB7 ${h(n.created_at)}</span>`,r}})}var $={};function Y(e,t){let n=document.getElementById("quote-uri"),r=document.getElementById("quote-preview-text");n&&(n.value=e),r&&(r.textContent=`quoting ${t}`),document.getElementById("quote-preview")?.classList.remove("hidden"),document.getElementById("reply-body")?.focus()}function Q(){document.getElementById("quote-uri").value="",document.getElementById("quote-preview")?.classList.add("hidden")}function G(e,t,n,r,s,d){let o=[];return t&&o.push(`<button type="button" class="quote-btn text-xs text-neutral-500 hover:text-neutral-300" data-uri="${e.uri}" data-handle="${u(e.handle)}">quote</button>`),t&&t===e.did&&o.push(`<form method="post" action="/bbs/${r}/thread/${s}/${d}/reply/${e.rkey}/delete" class="inline" onsubmit="return confirm('Delete this reply?')"><button type="submit" class="text-xs text-neutral-500 hover:text-red-400">delete</button></form>`),t&&t===n&&t!==e.did&&o.push(`<form method="post" action="/bbs/${r}/ban/${e.did}" class="inline" onsubmit="return confirm('Ban this user from your BBS?')"><button type="submit" class="text-xs text-neutral-500 hover:text-red-400">ban</button></form>`),t&&t===n&&o.push(`<form method="post" action="/bbs/${r}/hide" class="inline" onsubmit="return confirm('Hide this post?')"><input type="hidden" name="uri" value="${e.uri}"><button type="submit" class="text-xs text-neutral-500 hover:text-red-400">hide</button></form>`),o.length?`<span class="reply-actions flex items-center gap-3">${o.join(" ")}</span>`:""}function V(e){if(!e.quote||!$[e.quote])return"";let t=$[e.quote],n=t.body.substring(0,200)+(t.body.length>200?"...":"");return`<div class="border-l-2 border-neutral-700 pl-3 mb-3 py-1 text-sm text-neutral-500"><span class="text-neutral-400">${u(t.handle)}:</span> ${u(n)}</div>`}function W(e){return(e.attachments||[]).map(t=>`<a href="${e.pds_url}/xrpc/com.atproto.sync.getBlob?did=${e.did}&cid=${t.file.ref.$link}" target="_blank" class="text-xs text-neutral-500 hover:text-neutral-300 block mt-1">[${u(t.name)}]</a>`).join("")}function X(e,t,n,r,s,d){return`<div class="reply-card border border-neutral-800/50 rounded p-4"> 2 - <div class="flex items-baseline justify-between mb-2"> 3 - <div class="flex items-baseline gap-2"> 4 - <span class="text-neutral-300">${u(e.handle)}</span> 5 - <span class="text-neutral-600">&middot;</span> 6 - <span class="text-xs text-neutral-500" title="${x(e.created_at)}">${h(e.created_at)}</span> 7 - </div> 8 - ${G(e,s,d,t,n,r)} 9 - </div> 10 - ${V(e)} 11 - <p class="text-neutral-400 whitespace-pre-wrap leading-relaxed">${u(e.body)}</p> 12 - ${W(e)} 13 - </div>`}function Z(e,t,n){let r=document.createElement("div");r.className="flex items-center justify-center gap-2 text-sm w-full";function s(l,m,c=!1){let i=document.createElement("button");i.textContent=l,c?i.className="text-neutral-200 bg-neutral-800 rounded px-3 py-1":m!==null?(i.className="text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 rounded px-3 py-1",i.addEventListener("click",()=>n(m))):(i.className="text-neutral-600 px-2 py-1 cursor-default",i.disabled=!0),r.appendChild(i)}e>1&&s("\u2190",e-1);let d=2,o=Math.max(1,e-d),a=Math.min(t,e+d);a-o<4&&(o===1?a=Math.min(t,o+4):a===t&&(o=Math.max(1,a-4))),o>1&&(s("1",1),o>2&&s("...",null));for(let l=o;l<=a;l++)s(String(l),l,l===e);return a<t&&(a<t-1&&s("...",null),s(String(t),t)),e<t&&s("\u2192",e+1),r}function ee(e,t,n){for(let r of["replies-nav-top","replies-nav-bottom"]){let s=document.getElementById(r);s&&(s.innerHTML="",s.appendChild(Z(e,t,n)),s.classList.remove("hidden"))}}function C(){for(let e of["replies-nav-top","replies-nav-bottom"])document.getElementById(e)?.classList.add("hidden")}async function E(e,t,n,r,s,d){let o=document.getElementById("replies"),a=document.getElementById("replies-loading");try{let l=await f(`/api/replies/${t}/${n}?handle=${encodeURIComponent(r)}&page=${e}`);a&&a.remove();for(let m of l.replies)$[m.uri]=m;for(let m of l.replies)o.insertAdjacentHTML("beforeend",X(m,r,t,n,s,d));if(l.total_pages>1){let m=c=>{o.innerHTML='<p id="replies-loading" class="text-neutral-500">Loading replies...</p>',C();let i=new URL(window.location.href);i.searchParams.set("page",String(c)),history.pushState(null,"",i.toString()),E(c,t,n,r,s,d),document.getElementById("replies-nav-top")?.scrollIntoView({behavior:"smooth"})};ee(l.page,l.total_pages,m)}o.children.length===0&&!s&&(o.innerHTML='<p class="text-neutral-500">No replies yet.</p>')}catch{a&&(a.textContent="Could not fetch replies.")}}function A(){let e=p("replies","threadDid"),t=p("replies","threadTid"),n=p("replies","handle"),r=p("replies","userDid"),s=p("replies","sysopDid");document.getElementById("quote-clear")?.addEventListener("click",Q),document.getElementById("replies")?.addEventListener("click",o=>{let a=o.target.closest(".quote-btn");a&&Y(a.dataset.uri,a.dataset.handle)});let d=parseInt(new URLSearchParams(window.location.search).get("page")??"1",10);E(d,e,t,n,r,s),window.addEventListener("popstate",()=>{let o=parseInt(new URLSearchParams(window.location.search).get("page")??"1",10),a=document.getElementById("replies");a.innerHTML='<p id="replies-loading" class="text-neutral-500">Loading replies...</p>',C(),E(o,e,t,n,r,s)})}var P=50;function te(){let e=["inbox","bbs"];document.querySelectorAll(".tab-btn[data-tab]").forEach(t=>{t.addEventListener("click",()=>{let n=t.dataset.tab;document.querySelectorAll(".tab-btn").forEach(r=>{r.classList.remove("text-neutral-200","border-neutral-200"),r.classList.add("text-neutral-500","border-transparent")}),t.classList.remove("text-neutral-500","border-transparent"),t.classList.add("text-neutral-200","border-neutral-200");for(let r of e)document.getElementById(`panel-${r}`)?.classList.toggle("hidden",r!==n)})})}function ne(e,t){let{did:n,rkey:r}=b(e.thread_uri),s=document.createElement("a");s.href=`/bbs/${t}/thread/${n}/${r}`,s.className="block border border-neutral-800/50 rounded p-4 mb-2 hover:bg-neutral-900";let d=e.type==="quote"?"quoted your reply":"on: "+u(e.thread_title);return s.innerHTML=`<div class="flex items-baseline justify-between mb-1"><span class="text-neutral-300">${u(e.handle)}</span><span class="text-xs text-neutral-500">${h(e.created_at)}</span></div><p class="text-xs text-neutral-500 mb-1">${d}</p><p class="text-neutral-400">${u(e.body)}</p>`,s}function _(e,t,n,r,s){return e.map(d=>({type:t,thread_title:n,thread_uri:r,handle:d.handle,body:(d.value.body??"").substring(0,200),created_at:d.value.createdAt??"",bbs_handle:s}))}function re(e){let t=new Map;for(let n of e){let r=n.handle+n.body+n.created_at;(!t.has(r)||n.type==="quote")&&t.set(r,n)}return[...t.values()].sort((n,r)=>r.created_at.localeCompare(n.created_at))}async function se(e,t,n){let r=document.getElementById("inbox"),s=document.getElementById("inbox-loading");try{let[d,o]=await Promise.all([R(t,e,B,P),R(t,e,I,P)]),a=[...new Set(d.map(i=>i.value.board??"").filter(Boolean).map(i=>b(i).did))],l=a.length?await y(a):{},m=await Promise.all([...d.map(async i=>{let g=i.value.board??"",U=g?b(g).did:e,q=l[U]?.handle??"";try{let{records:N}=await v(i.uri,`${I}:subject`,{limit:50,excludeDid:e});return _(N,"reply",i.value.title??"",i.uri,q)}catch{return[]}}),...o.map(async i=>{try{let{records:g}=await v(i.uri,`${I}:quote`,{limit:50,excludeDid:e});return _(g,"quote","",i.value.subject??"","")}catch{return[]}})]),c=re(m.flat());if(s&&s.remove(),!c.length){r.innerHTML='<p class="text-neutral-500">No messages yet.</p>';return}for(let i of c.slice(0,50))r.appendChild(ne(i,n))}catch{s&&(s.textContent="Failed to fetch messages.")}}function S(){te();let e=p("inbox","handle"),t=p("inbox","did"),n=p("inbox","pds");t&&n&&se(t,n,e)}L();document.getElementById("handle-form")&&k();document.getElementById("threads")&&H();document.getElementById("replies")&&A();document.getElementById("inbox")&&S();})();
-2
web/static/style.css
··· 1 - /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */ 2 - @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-900:oklch(39.6% .141 25.723);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-bold:700;--tracking-wide:.025em;--leading-snug:1.375;--leading-relaxed:1.625;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-3{margin-inline:calc(var(--spacing) * -3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-24{margin-top:calc(var(--spacing) * 24)}.mt-auto{margin-top:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.min-h-screen{min-height:100vh}.w-1\/3{width:33.3333%}.w-1\/4{width:25%}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-700{border-color:var(--color-neutral-700)}.border-neutral-800{border-color:var(--color-neutral-800)}.border-neutral-800\/50{border-color:#26262680}@supports (color:color-mix(in lab, red, red)){.border-neutral-800\/50{border-color:color-mix(in oklab, var(--color-neutral-800) 50%, transparent)}}.border-transparent{border-color:#0000}.bg-neutral-800{background-color:var(--color-neutral-800)}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-neutral-950{background-color:var(--color-neutral-950)}.p-4{padding:calc(var(--spacing) * 4)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-16{padding-block:calc(var(--spacing) * 16)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-neutral-200{color:var(--color-neutral-200)}.text-neutral-300{color:var(--color-neutral-300)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-red-500{color:var(--color-red-500)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-neutral-500::placeholder{color:var(--color-neutral-500)}@media (hover:hover){.group-hover\:text-white:is(:where(.group):hover *){color:var(--color-white)}.hover\:border-neutral-700:hover{border-color:var(--color-neutral-700)}.hover\:border-red-900:hover{border-color:var(--color-red-900)}.hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.hover\:bg-neutral-800:hover{background-color:var(--color-neutral-800)}.hover\:bg-neutral-900:hover{background-color:var(--color-neutral-900)}.hover\:text-neutral-200:hover{color:var(--color-neutral-200)}.hover\:text-neutral-300:hover{color:var(--color-neutral-300)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-white:hover{color:var(--color-white)}.hover\:opacity-80:hover{opacity:.8}}.focus\:border-neutral-600:focus{border-color:var(--color-neutral-600)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}}pre,code,kbd,samp{font-family:Geist Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}.reply-actions{display:none}.reply-card:hover .reply-actions{display:inline}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}
+3 -1
web/ts/pages/account.ts
··· 12 12 type: string; 13 13 thread_title: string; 14 14 thread_uri: string; 15 + reply_uri: string; 15 16 handle: string; 16 17 body: string; 17 18 created_at: string; ··· 49 50 const { did: threadDid, rkey: threadRkey } = parseAtUri(m.thread_uri); 50 51 51 52 const el = document.createElement("a"); 52 - el.href = `/bbs/${handle}/thread/${threadDid}/${threadRkey}`; 53 + el.href = `/bbs/${handle}/thread/${threadDid}/${threadRkey}?reply=${encodeURIComponent(m.reply_uri)}`; 53 54 el.className = 54 55 "block border border-neutral-800/50 rounded p-4 mb-2 hover:bg-neutral-900"; 55 56 const label = ··· 73 74 type, 74 75 thread_title: threadTitle, 75 76 thread_uri: threadUri, 77 + reply_uri: r.uri, 76 78 handle: r.handle, 77 79 body: ((r.value.body as string) ?? "").substring(0, 200), 78 80 created_at: (r.value.createdAt as string) ?? "",
+8 -5
web/ts/pages/thread.ts
··· 206 206 handle: string, 207 207 userDid: string, 208 208 sysopDid: string, 209 + focusReply?: string, 209 210 ) { 210 211 const container = document.getElementById("replies")!; 211 212 const loading = document.getElementById("replies-loading"); 212 213 213 214 try { 214 - const data = await fetchJson<RepliesResponse>( 215 - `/api/replies/${threadDid}/${threadTid}?handle=${encodeURIComponent(handle)}&page=${page}`, 216 - ); 215 + let url = `/api/replies/${threadDid}/${threadTid}?handle=${encodeURIComponent(handle)}&page=${page}`; 216 + if (focusReply) url += `&reply=${encodeURIComponent(focusReply)}`; 217 + const data = await fetchJson<RepliesResponse>(url); 217 218 218 219 if (loading) loading.remove(); 219 220 ··· 265 266 if (btn) quoteReply(btn.dataset.uri!, btn.dataset.handle!); 266 267 }); 267 268 268 - const initialPage = parseInt(new URLSearchParams(window.location.search).get("page") ?? "1", 10); 269 - loadReplyPage(initialPage, threadDid, threadTid, handle, userDid, sysopDid); 269 + const params = new URLSearchParams(window.location.search); 270 + const initialPage = parseInt(params.get("page") ?? "1", 10); 271 + const focusReply = params.get("reply") ?? undefined; 272 + loadReplyPage(initialPage, threadDid, threadTid, handle, userDid, sysopDid, focusReply); 270 273 271 274 window.addEventListener("popstate", () => { 272 275 const p = parseInt(new URLSearchParams(window.location.search).get("page") ?? "1", 10);