An HTML-only Bluesky frontend

add threads (just top post atm); minor fixes in actor

+348 -47
+34 -28
actor.js
··· 9 9 }); 10 10 11 11 export default class Actor { 12 - uri; 13 12 actor; 14 13 15 - constructor(uri) { 16 - this.uri = uri; 17 - this.actor = this.#get(); 14 + constructor(actor) { 15 + this.actor = actor; 18 16 } 19 17 20 - async #get() { 21 - const { data: actor } = await agent.api.app.bsky.actor.getProfile({ 22 - actor: this.uri, 23 - }); 24 - return actor; 25 - } 18 + async Profile(prevCursor) { 19 + const actor = this.actor; 26 20 27 - async HTML(prevCursor) { 28 - const actor = await this.actor; 29 - 30 - actor.username = actor.displayName ? actor.displayName : actor.handle; 21 + actor.displayName = actor.displayName ? actor.displayName : actor.handle; 22 + actor.handle = actor.handle !== "handle.invalid" ? actor.handle : actor.did; 31 23 actor.avatar = actor.avatar ? actor.avatar : "/static/avatar.jpg"; 24 + 32 25 actor.description = actor.description ? `<tr><td colspan="2"><p>${getFacets(actor.description)}</p></td></tr><tr><td colspan="2">&nbsp;</td></tr>` : ""; 33 26 34 27 return ` 35 28 <head> 36 29 <meta name="color-scheme" content="light dark"> 37 30 <meta name="viewport" content="width=device-width, initial-scale=1"> 38 - <title>${actor.username} (@${actor.handle}) &#8212; HTMLsky</title> 31 + <title>${actor.displayName} (@${actor.handle}) &#8212; HTMLsky</title> 39 32 </head> 40 33 <table> 41 34 <tr> 42 35 <td valign="top" height="60" width="60"> 43 - <img src="${actor.avatar}" alt="${actor.username}'s avatar" height="60" width="60"> 36 + <img src="${actor.avatar}" alt="${actor.displayName}'s avatar" height="60" width="60"> 44 37 </td> 45 38 <td> 46 39 <h1> 47 - <span>${actor.username}</span><br> 48 - <small><small><small>@${actor.handle}</small></small></small> 40 + <span>${actor.displayName}</span><br> 41 + <small><small><small><a href="/profile/${actor.handle}">@${actor.handle}</a></small></small></small> 49 42 </h1> 50 43 </td> 51 44 </tr> 52 45 ${actor.description} 53 46 <tr> 54 47 <td colspan="2"> 55 - <a href="./followers/"><b>${actor.followersCount}</b> followers</a> 56 - <a href="./follows/"><b>${actor.followsCount}</b> following</a> 57 - <b>${actor.postsCount}</b> posts 48 + <a href="/profile/${actor.handle}/followers/"><b>${actor.followersCount}</b> followers</a> 49 + <a href="/profile/${actor.handle}/follows/"><b>${actor.followsCount}</b> following</a> 50 + <a href="/profile/${actor.handle}/"><b>${actor.postsCount}</b> posts</a> 58 51 </td> 59 52 </tr> 60 53 </table> 61 54 <hr> 62 - ${prevCursor ? await this.Feed(prevCursor) : await this.Feed()} 63 55 `; 64 56 } 65 57 ··· 75 67 const feedList = []; 76 68 77 69 for (const post of feed) { 70 + // add reply above post 78 71 if (post.reply) { 79 72 const reply = post.reply.parent ? post.reply.parent : post.reply.root; 73 + 74 + const rkey = reply.uri.split("/").at(-1); 75 + 80 76 if (reply.notFound || reply.blocked) { 81 77 feedList.push(` 82 78 <tr><td> ··· 88 84 </td></tr> 89 85 `); 90 86 } else { 87 + reply.author.displayName = reply.author.displayName ? reply.author.displayName : reply.author.handle; 88 + reply.author.handle = reply.author.handle !== "handle.invalid" ? reply.author.handle : reply.author.did; 89 + 91 90 const text = reply.record.text ? `<p>${getFacets(reply.record.text, reply.record.facets)}</p>` : ""; 92 91 feedList.push(` 93 92 <tr> 94 93 <td> 95 94 <table> 96 95 <tr><td> 97 - <b>${reply.author.displayName ? reply.author.displayName : reply.author.handle}</b> (<a href="/profile/${reply.author.handle !== "handle.invalid" ? reply.author.handle : reply.author.did}/">@${reply.author.handle}</a>) &middot; ${DateTimeFormat.format(new Date(reply.record.createdAt))} 96 + <b>${reply.author.displayName}</b> (<a href="/profile/${reply.author.handle}/">@${reply.author.handle}</a>) 97 + <a href="/profile/${reply.author.handle}/post/${rkey}/">&raquo;</a> 98 + ${DateTimeFormat.format(new Date(reply.record.createdAt))} 98 99 </td></tr> 99 100 <tr><td> 100 101 ${text} ··· 114 115 const record = post.post.record; 115 116 const author = post.post.author; 116 117 118 + 119 + const rkey = post.post.uri.split("/").at(-1); 120 + 117 121 // Embeds 118 122 const embeds = []; 119 123 if (record.embed) { ··· 140 144 } 141 145 } 142 146 147 + 148 + author.displayName = author.displayName ? author.displayName : author.handle; 149 + author.handle = author.handle !== "handle.invalid" ? author.handle : author.did; 150 + 143 151 const text = record.text ? `<p>${getFacets(record.text, record.facets)}</p>` : ""; 144 152 145 153 feedList.push(` 146 154 <tr><td> 147 155 ${post.reply ? `<blockquote>` : ``} 148 156 <table> 149 - ${actor.did !== author.did ? `<tr><td><i>Reposted by ${actor.displayName ? actor.displayName : actor.handle}</i></td></tr>` : ``} 157 + ${actor.did !== author.did ? `<tr><td><i>Reposted by ${actor.displayName}</i></td></tr>` : ``} 150 158 <tr><td> 151 - <b>${author.displayName ? author.displayName : author.handle}</b> (<a href="/profile/${author.handle !== "handle.invalid" ? author.handle : author.did }/">@${author.handle}</a>) 152 - &middot; 159 + <b>${author.displayName}</b> (<a href="/profile/${author.handle}/">@${author.handle}</a>) 160 + <a href="/profile/${author.handle}/post/${rkey}/">&raquo;</a> 153 161 ${DateTimeFormat.format(new Date(record.createdAt))} 154 162 </td></tr> 155 163 <tr><td> ··· 219 227 <meta name="viewport" content="width=device-width, initial-scale=1"> 220 228 <title>People following @${actor.handle} &#8212; HTMLsky</title> 221 229 </head> 222 - <p><a href="..">Back</a></p> 223 230 <table> 224 231 ${followersList.join("")} 225 232 </table> ··· 260 267 <meta name="viewport" content="width=device-width, initial-scale=1"> 261 268 <title>People followed by @${actor.handle} &#8212; HTMLsky</title> 262 269 </head> 263 - <p><a href="..">Back</a></p> 264 270 <table> 265 271 ${followsList.join("")} 266 272 </table>
+46 -19
main.js
··· 2 2 import { serveDir, serveFile } from "jsr:@std/http/file-server"; 3 3 4 4 import Actor from "./actor.js"; 5 + import Thread from "./thread.js"; 5 6 6 7 export const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 7 8 ··· 15 16 if (path === "/robots.txt") { 16 17 return serveFile(req, "./static/robots.txt"); 17 18 } 18 - 19 19 // "serve favicon" 20 20 if (path === "/favicon.ico") { 21 21 return new Response({ status: 404 }); 22 22 } 23 - 24 23 // serve static files 25 24 if (path.startsWith("/static")) { 26 25 return serveDir(req, { quiet: true }); 27 26 } 28 - 29 27 // handle trailing slashes 30 28 if (path.at(-1) !== "/") { 31 29 return Response.redirect(`${redir}${url.pathname}/`); 32 30 } 33 31 34 32 try { 33 + 35 34 // PROFILE 36 35 const profilePattern = new URLPattern({ 37 36 pathname: "/profile/:identifier/:page?/", 38 37 }); 39 38 if (profilePattern.test(url)) { 40 - const identifier = profilePattern.exec(url)?.pathname.groups.identifier, 41 - page = profilePattern.exec(url)?.pathname.groups.page; 39 + const identifier = profilePattern.exec(url)?.pathname.groups.identifier; 40 + const page = profilePattern.exec(url)?.pathname.groups.page; 41 + 42 + const { data } = await agent.api.app.bsky.actor.getProfile({ actor: identifier }); 43 + const actor = new Actor(data); 44 + const cursor = query.get("cursor"); 45 + 46 + let res = await actor.Profile(); 47 + switch (page) { 48 + // Empty case is for /profile/ 49 + case "": 50 + res += await actor.Feed(cursor); 51 + break; 52 + case "follows": 53 + res += await actor.Follows(cursor); 54 + break; 55 + case "followers": 56 + res += await actor.Followers(cursor); 57 + break; 58 + default: 59 + throw new Error("not a page"); 60 + } 61 + return new Response(res, html_headers); 62 + } 42 63 43 - // TODO: move API calls out 64 + // THREADS 65 + const postPattern = new URLPattern({ 66 + pathname: "/profile/:identifier/post/:rkey/:page?/", 67 + }); 68 + if (postPattern.test(url)) { 69 + const identifier = postPattern.exec(url)?.pathname.groups.identifier; 70 + const rkey = postPattern.exec(url)?.pathname.groups.rkey; 71 + const page = postPattern.exec(url)?.pathname.groups.page; 44 72 45 - const actor = new Actor(identifier); 73 + const { data } = await agent.api.app.bsky.feed.getPostThread({ uri: `at://${identifier}/app.bsky.feed.post/${rkey}` }); 74 + const thread = new Thread(data.thread); 46 75 const cursor = query.get("cursor"); 47 76 48 - if (page === "followers") { 49 - if (cursor) { 50 - return new Response(await actor.Followers(cursor), html_headers); 51 - } else return new Response(await actor.Followers(), html_headers); 52 - } 53 - if (page === "follows") { 54 - if (cursor) { 55 - return new Response(await actor.Follows(cursor), html_headers); 56 - } else return new Response(await actor.Follows(), html_headers); 77 + let res = await thread.Post(); 78 + switch (page) { 79 + case "": 80 + res += await thread.Replies(cursor); 81 + break; 82 + default: 83 + throw new Error("not a page"); 57 84 } 58 85 59 - if (cursor) return new Response(await actor.HTML(cursor), html_headers); 60 - else return new Response(await actor.HTML(), html_headers); 86 + return new Response(res, html_headers); 61 87 } 88 + 62 89 } catch (error) { 63 90 return new Response( 64 91 `<head><meta name="color-scheme" content="light dark"></head>\n${error}`, ··· 77 104 "Content-Type": "text/html;charset=utf-8", 78 105 }, 79 106 }; 80 - const _json_headers = { 107 + const json_headers = { 81 108 headers: { 82 109 "Content-Type": "application/json;charset=utf-8", 83 110 },
+268
thread.js
··· 1 + import { agent } from "./main.js"; 2 + import { getFacets } from "./facets.js"; 3 + 4 + const DateTimeFormat = new Intl.DateTimeFormat("en-US", { 5 + dateStyle: "short", 6 + timeStyle: "long", 7 + timeZone: "UTC", 8 + hour12: false, 9 + }); 10 + 11 + export default class Thread { 12 + thread; 13 + 14 + constructor(thread) { 15 + this.thread = thread; 16 + } 17 + 18 + Post() { 19 + const thread = this.thread; 20 + const post = thread.post; 21 + const author = post.author; 22 + const record = post.record; 23 + 24 + author.displayName = author.displayName ? author.displayName : author.handle; 25 + author.handle = author.handle !== "handle.invalid" ? author.handle : author.did; 26 + author.avatar = author.avatar ? author.avatar : "/static/avatar.jpg"; 27 + 28 + 29 + const text = record.text ? `<p>${getFacets(record.text, record.facets)}</p>` : ""; 30 + 31 + return ` 32 + <head> 33 + <meta name="color-scheme" content="light dark"> 34 + <meta name="viewport" content="width=device-width, initial-scale=1"> 35 + <title>${author.displayName}: ${post.record.text} &#8212; HTMLsky</title> 36 + </head> 37 + <table> 38 + <tr> 39 + <td valign="top" height="45" width="45"> 40 + <img src="${author.avatar}" alt="${author.displayName}'s avatar" height="45" width="45"> 41 + </td> 42 + <td> 43 + <h2> 44 + <span>${author.displayName}</span><br> 45 + <small><small><a href="/profile/${author.handle}">@${author.handle}</a></small></small> 46 + </h2> 47 + </td> 48 + </tr> 49 + <tr><td colspan="2"> 50 + ${text} 51 + </td></tr> 52 + <tr><td colspan="2">&nbsp;</td></tr> 53 + <tr><td colspan="2"> 54 + <i>${DateTimeFormat.format(new Date(record.createdAt))}</i> 55 + </td></tr> 56 + <tr><td colspan="2"> 57 + <b>${post.replyCount}</b> replies &middot; 58 + <b>${post.repostCount}</b> reposts &middot; 59 + <b>${post.likeCount}</b> likes 60 + </td></tr> 61 + </table> 62 + <hr> 63 + `; 64 + } 65 + 66 + async Replies(prevCursor) { 67 + const thread = this.thread; 68 + return ` 69 + <p>Replies are coming soon!</p> 70 + `; 71 + /* 72 + const feedList = []; 73 + 74 + for (const post of feed) { 75 + // add reply above post 76 + if (post.reply) { 77 + const reply = post.reply.parent ? post.reply.parent : post.reply.root; 78 + if (reply.notFound || reply.blocked) { 79 + feedList.push(` 80 + <tr><td> 81 + <table> 82 + <tr><td> 83 + Post not found. 84 + </td></tr> 85 + </table> 86 + </td></tr> 87 + `); 88 + } else { 89 + const text = reply.record.text ? `<p>${getFacets(reply.record.text, reply.record.facets)}</p>` : ""; 90 + feedList.push(` 91 + <tr> 92 + <td> 93 + <table> 94 + <tr><td> 95 + <b>${reply.author.displayName ? reply.author.displayName : reply.author.handle}</b> (<a href="/profile/${reply.author.handle !== "handle.invalid" ? reply.author.handle : reply.author.did}/">@${reply.author.handle}</a>) &middot; ${DateTimeFormat.format(new Date(reply.record.createdAt))} 96 + </td></tr> 97 + <tr><td> 98 + ${text} 99 + </td></tr> 100 + <tr><td> 101 + <b>${reply.replyCount}</b> replies &middot; 102 + <b>${reply.repostCount}</b> reposts &middot; 103 + <b>${reply.likeCount}</b> likes 104 + </td></tr> 105 + </table> 106 + </td> 107 + </tr> 108 + `); 109 + } 110 + } 111 + 112 + const record = post.post.record; 113 + const author = post.post.author; 114 + 115 + // Embeds 116 + const embeds = []; 117 + if (record.embed) { 118 + const embedType = record.embed["$type"]; 119 + 120 + switch(embedType) { 121 + case "app.bsky.embed.images": 122 + embeds.push(`<ul>`); 123 + for (const image of record.embed.images) { 124 + // TODO: have a separate page for images with alt text and stuff? 125 + const embedURL = `https://cdn.bsky.app/img/feed_fullsize/plain/${author.did}/${image.image.ref}@${image.image.mimeType.split("/")[1]}`; 126 + embeds.push(`<li><a href="${embedURL}">${image.image.ref}</a> (${image.image.mimeType})</li>`); 127 + } 128 + embeds.push(`</ul>`); 129 + break; 130 + case "app.bsky.embed.record": 131 + // TODO: record embed 132 + break; 133 + case "app.bsky.embed.recordWithMedia": 134 + break; 135 + default: 136 + embeds.push(`<pre>Missing embed type ${embedType}; <a href="https://todo.sr.ht/~jordanreger/htmlsky">please make an issue</a>.</pre>`); 137 + break; 138 + } 139 + } 140 + 141 + const text = record.text ? `<p>${getFacets(record.text, record.facets)}</p>` : ""; 142 + 143 + feedList.push(` 144 + <tr><td> 145 + ${post.reply ? `<blockquote>` : ``} 146 + <table> 147 + ${actor.did !== author.did ? `<tr><td><i>Reposted by ${actor.displayName ? actor.displayName : actor.handle}</i></td></tr>` : ``} 148 + <tr><td> 149 + <b>${author.displayName ? author.displayName : author.handle}</b> (<a href="/profile/${author.handle !== "handle.invalid" ? author.handle : author.did }/">@${author.handle}</a>) 150 + &middot; 151 + ${DateTimeFormat.format(new Date(record.createdAt))} 152 + </td></tr> 153 + <tr><td> 154 + ${text} 155 + </td></tr> 156 + <tr><td> 157 + ${record.embed ? embeds.join("\n") : ``} 158 + </td></tr> 159 + <tr><td> 160 + <b>${post.post.replyCount}</b> replies &middot; 161 + <b>${post.post.repostCount}</b> reposts &middot; 162 + <b>${post.post.likeCount}</b> likes 163 + </td></tr> 164 + </table> 165 + ${post.reply ? `</blockquote><hr>` : `<hr>`} 166 + </td></tr> 167 + `); 168 + } 169 + 170 + if (cursor) { 171 + feedList.push(` 172 + <tr><td> 173 + <br> 174 + <a href="?cursor=${cursor}">Next page</a> 175 + </td></tr> 176 + `); 177 + } 178 + 179 + 180 + return ` 181 + <table width="100%"> 182 + ${feedList.join("")} 183 + </table> 184 + `; 185 + */ 186 + } 187 + 188 + async Followers(prevCursor) { 189 + const actor = await this.actor; 190 + let options = { actor: actor.did }; 191 + if (prevCursor) { 192 + options = { actor: actor.did, cursor: prevCursor }; 193 + } 194 + 195 + const { data } = await agent.api.app.bsky.graph.getFollowers(options); 196 + const { followers, cursor } = data; 197 + 198 + const followersList = []; 199 + for (const follower of followers) { 200 + followersList.push(` 201 + <tr><td> 202 + <b>${follower.displayName ? follower.displayName : follower.handle}</b> (<a href="/profile/${follower.handle !== "handle.invalid" ? follower.handle : follower.did}/">@${follower.handle}</a>) 203 + </td></tr>`); 204 + } 205 + 206 + if (cursor) { 207 + followersList.push(` 208 + <tr><td> 209 + <br> 210 + <a href="?cursor=${cursor}">Next page</a> 211 + </td></tr> 212 + `); 213 + } 214 + 215 + return ` 216 + <head> 217 + <meta name="color-scheme" content="light dark"> 218 + <meta name="viewport" content="width=device-width, initial-scale=1"> 219 + <title>People following @${actor.handle} &#8212; HTMLsky</title> 220 + </head> 221 + <p><a href="..">Back</a></p> 222 + <table> 223 + ${followersList.join("")} 224 + </table> 225 + `; 226 + } 227 + 228 + async Follows(prevCursor) { 229 + const actor = await this.actor; 230 + let options = { actor: actor.did }; 231 + if (prevCursor) { 232 + options = { actor: actor.did, cursor: prevCursor }; 233 + } 234 + 235 + const { data } = await agent.api.app.bsky.graph.getFollows(options); 236 + const { follows, cursor } = data; 237 + 238 + const followsList = []; 239 + for (const follow of follows) { 240 + followsList.push(` 241 + <tr><td> 242 + <b>${follow.displayName ? follow.displayName : follow.handle}</b> (<a href="/profile/${follow.handle !== "handle.invalid" ? follow.handle : follow.did}/">@${follow.handle}</a>) 243 + </td> 244 + </tr>`); 245 + } 246 + 247 + if (cursor) { 248 + followsList.push(` 249 + <tr><td> 250 + <br> 251 + <a href="?cursor=${cursor}">Next page</a> 252 + </td></tr> 253 + `); 254 + } 255 + 256 + return ` 257 + <head> 258 + <meta name="color-scheme" content="light dark"> 259 + <meta name="viewport" content="width=device-width, initial-scale=1"> 260 + <title>People followed by @${actor.handle} &#8212; HTMLsky</title> 261 + </head> 262 + <p><a href="..">Back</a></p> 263 + <table> 264 + ${followsList.join("")} 265 + </table> 266 + `; 267 + } 268 + }