An HTML-only Bluesky frontend

add user feed

+332 -13
+175 -6
actor.ts
··· 1 - import { ProfileView } from "npm:@atproto/api"; 1 + import { 2 + AppBskyActorDefs, 3 + AppBskyFeedGetAuthorFeed, 4 + AppBskyFeedPost, 5 + AppBskyGraphGetFollowers, 6 + } from "npm:@atproto/api"; 2 7 3 8 import { agent } from "./main.ts"; 4 9 import { getDescriptionFacets } from "./facets.ts"; 5 10 11 + const DateTimeFormat = new Intl.DateTimeFormat("en-US", { 12 + dateStyle: "short", 13 + timeStyle: "long", 14 + timeZone: "UTC", 15 + hour12: false, 16 + }); 17 + 6 18 export default class Actor { 7 19 uri: string; 8 - actor: ProfileView; 20 + actor: Promise<AppBskyActorDefs.ProfileViewDetailed>; 9 21 10 22 constructor(uri: string) { 11 23 this.uri = uri; ··· 19 31 return actor; 20 32 } 21 33 22 - async Raw(): string { 34 + async Raw(): Promise<string> { 23 35 const actor = await this.actor; 24 36 25 37 return JSON.stringify(actor, null, 2); 26 38 } 27 39 28 - async HTML(): string { 40 + async HTML(prevCursor?: string): Promise<string> { 29 41 const actor = await this.actor; 30 42 31 43 actor.username = actor.displayName ? actor.displayName : actor.handle; ··· 65 77 } 66 78 <tr> 67 79 <td colspan="2"> 68 - <b>${actor.followersCount}</b> followers 69 - <b>${actor.followsCount}</b> following 80 + <a href="./followers/"><b>${actor.followersCount}</b> followers</a> 81 + <a href="./follows/"><b>${actor.followsCount}</b> following</a> 70 82 <b>${actor.postsCount}</b> posts 71 83 </td> 72 84 </tr> 73 85 </table> 74 86 <hr> 87 + ${prevCursor ? await this.Feed(prevCursor) : await this.Feed()} 88 + `; 89 + } 90 + 91 + async Feed(prevCursor?: string): Promise<string> { 92 + const actor = await this.actor; 93 + let options: AppBskyFeedGetAuthorFeed.QueryParams = { actor: actor.did }; 94 + if (prevCursor) { 95 + options = { actor: actor.did, cursor: prevCursor }; 96 + } 97 + const { data } = await agent.api.app.bsky.feed.getAuthorFeed(options); 98 + const { feed, cursor } = data; 99 + 100 + const feedList: string[] = []; 101 + feed.forEach((post) => { 102 + const postAuthor = post.post.author, 103 + reply = post.reply?.root, 104 + record: AppBskyFeedPost.Record = post.post.record; 105 + let replyAuthor: AppBskyActorDefs.ProfileView | undefined; 106 + if (reply) replyAuthor = reply.author; 107 + else replyAuthor = undefined; 108 + console.log(post); 109 + feedList.push(` 110 + <tr> 111 + <td> 112 + ${ 113 + reply 114 + ? ` 115 + <table> 116 + <tr><td><b>${ 117 + replyAuthor.displayName 118 + ? replyAuthor.displayName 119 + : replyAuthor.handle 120 + }</b> (@<a href="/profile/${ 121 + replyAuthor.handle !== "handle.invalid" 122 + ? replyAuthor.handle 123 + : replyAuthor.did 124 + }">${replyAuthor.handle}</a>)</td></tr> 125 + <tr><td>${reply.record.text}</td></tr> 126 + <tr><td>${DateTimeFormat.format(new Date(reply.record.createdAt))}</td></tr> 127 + <tr><td><br></td></tr> 128 + </table> 129 + <blockquote> 130 + ` 131 + : "" 132 + } 133 + <table> 134 + ${ 135 + actor.did !== postAuthor.did 136 + ? `<tr><td><i>Reposted by ${ 137 + actor.displayName ? actor.displayName : actor.handle 138 + }</i></td></tr>` 139 + : `` 140 + } 141 + <tr><td><b>${ 142 + postAuthor.displayName ? postAuthor.displayName : postAuthor.handle 143 + }</b> (@<a href="/profile/${ 144 + postAuthor.handle !== "handle.invalid" 145 + ? postAuthor.handle 146 + : postAuthor.did 147 + }">${postAuthor.handle}</a>)</td></tr> 148 + <tr><td>${record.text}</td></tr> 149 + <tr><td>${DateTimeFormat.format(new Date(record.createdAt))}</td></tr> 150 + <tr><td><br></td></tr> 151 + </table> 152 + ${post.reply ? "</blockquote>" : ""} 153 + </td> 154 + </tr> 155 + `); 156 + }); 157 + 158 + if (cursor) { 159 + feedList.push(` 160 + <tr> 161 + <td><br><a href="?cursor=${cursor}">Next page</a></td> 162 + </tr> 163 + `); 164 + } 165 + 166 + return ` 167 + <table> 168 + ${feedList.join("")} 169 + </table> 170 + `; 171 + } 172 + 173 + async Followers(prevCursor?: string): Promise<string> { 174 + const actor = await this.actor; 175 + let options: AppBskyGraphGetFollowers.QueryParams = { actor: actor.did }; 176 + if (prevCursor) { 177 + options = { actor: actor.did, cursor: prevCursor }; 178 + } 179 + 180 + const { data } = await agent.api.app.bsky.graph.getFollowers(options); 181 + const { followers, cursor } = data; 182 + 183 + const followersList: string[] = []; 184 + followers.forEach((follower) => { 185 + followersList.push(` 186 + <tr> 187 + <td><b>${ 188 + follower.displayName ? follower.displayName : follower.handle 189 + }</b> (@${follower.handle})</td> 190 + </tr>`); 191 + }); 192 + 193 + if (cursor) { 194 + followersList.push(` 195 + <tr> 196 + <td><br><a href="?cursor=${cursor}">Next page</a></td> 197 + </tr> 198 + `); 199 + } 200 + 201 + return ` 202 + <p><a href="..">Back</a></p> 203 + <table> 204 + ${followersList.join("")} 205 + </table> 206 + `; 207 + } 208 + 209 + async Follows(prevCursor?: string): Promise<string> { 210 + const actor = await this.actor; 211 + let options: AppBskyGraphGetFollowers.QueryParams = { actor: actor.did }; 212 + if (prevCursor) { 213 + options = { actor: actor.did, cursor: prevCursor }; 214 + } 215 + 216 + const { data } = await agent.api.app.bsky.graph.getFollows(options); 217 + const { follows, cursor } = data; 218 + 219 + const followsList: string[] = []; 220 + follows.forEach((follow) => { 221 + followsList.push(` 222 + <tr> 223 + <td><b>${ 224 + follow.displayName ? follow.displayName : follow.handle 225 + }</b> (<a href="/profile/${ 226 + follow.handle !== "handle.invalid" ? follow.handle : follow.did 227 + }">@${follow.handle}</a>)</td> 228 + </tr>`); 229 + }); 230 + 231 + if (cursor) { 232 + followsList.push(` 233 + <tr> 234 + <td><br><a href="?cursor=${cursor}">Next page</a></td> 235 + </tr> 236 + `); 237 + } 238 + 239 + return ` 240 + <p><a href="..">Back</a></p> 241 + <table> 242 + ${followsList.join("")} 243 + </table> 75 244 `; 76 245 } 77 246 }
+1
deno.lock
··· 12 12 "jsr:@std/path@1.0.0-rc.2": "jsr:@std/path@1.0.0-rc.2", 13 13 "jsr:@std/streams@^0.224.5": "jsr:@std/streams@0.224.5", 14 14 "npm:@atproto/api": "npm:@atproto/api@0.12.8", 15 + "npm:@atproto/xrpc": "npm:@atproto/xrpc@0.5.0", 15 16 "npm:preact": "npm:preact@10.22.0", 16 17 "npm:preact-render-to-string": "npm:preact-render-to-string@6.5.5_preact@10.22.0", 17 18 "npm:preact@10.22.0": "npm:preact@10.22.0",
+39 -7
main.ts
··· 1 1 import { AtpAgent } from "npm:@atproto/api"; 2 - import { serveDir } from "jsr:@std/http/file-server"; 2 + import { serveDir, serveFile } from "jsr:@std/http/file-server"; 3 3 4 4 import Actor from "./actor.ts"; 5 5 ··· 8 8 Deno.serve(async (req) => { 9 9 const url = new URL(req.url); 10 10 const path = url.pathname; 11 + const redir = `${url.protocol}${url.host}`; 12 + const query = new URLSearchParams(url.search); 11 13 14 + // wrangle bots 15 + if (path === "/robots.txt") { 16 + return serveFile(req, "./static/robots.txt"); 17 + } 18 + 19 + // serve static files 12 20 if (path.startsWith("/static")) { 13 21 return serveDir(req, { quiet: true }); 14 22 } ··· 16 24 // handle trailing slashes 17 25 if (path.at(-1) !== "/") { 18 26 return Response.redirect( 19 - `${url.protocol}${url.host}${url.pathname}/`, 27 + `${redir}${url.pathname}/`, 20 28 ); 21 29 } 22 30 ··· 25 33 const profilePattern = new URLPattern({ pathname: "/profile/:actor/" }); 26 34 if (profilePattern.test(url)) { 27 35 const actorName = profilePattern.exec(url)?.pathname.groups.actor; 28 - const actor = new Actor(actorName); 29 - 30 - return new Response(await actor.HTML(), html_headers); 36 + const actor = new Actor(actorName!); 37 + const cursor = query.get("cursor"); 38 + if (cursor) return new Response(await actor.HTML(cursor), html_headers); 39 + else return new Response(await actor.HTML(), html_headers); 31 40 } 32 41 33 42 // RAW PROFILE ··· 36 45 }); 37 46 if (rawProfilePattern.test(url)) { 38 47 const actorName = rawProfilePattern.exec(url)?.pathname.groups.actor; 39 - const actor = new Actor(actorName); 48 + const actor = new Actor(actorName!); 40 49 41 50 return new Response(await actor.Raw(), json_headers); 42 51 } 52 + 53 + // FOLLOWERS, FOLLOWING 54 + const pageProfilePattern = new URLPattern({ 55 + pathname: "/profile/:actor/:page/", 56 + }); 57 + if (pageProfilePattern.test(url)) { 58 + const actorName = pageProfilePattern.exec(url)?.pathname.groups.actor; 59 + const pageName = pageProfilePattern.exec(url)?.pathname.groups.page; 60 + const actor = new Actor(actorName!); 61 + 62 + if (pageName === "followers") { 63 + const cursor = query.get("cursor"); 64 + if (cursor) { 65 + return new Response(await actor.Followers(cursor), html_headers); 66 + } else return new Response(await actor.Followers(), html_headers); 67 + } 68 + if (pageName === "follows") { 69 + const cursor = query.get("cursor"); 70 + if (cursor) { 71 + return new Response(await actor.Follows(cursor), html_headers); 72 + } else return new Response(await actor.Follows(), html_headers); 73 + } 74 + } 43 75 } catch (error) { 44 76 return new Response( 45 77 `<head><meta name="color-scheme" content="light dark"></head>\n${error}`, 46 - headers, 78 + html_headers, 47 79 ); 48 80 } 49 81
+117
static/robots.txt
··· 1 + # Our policy (taken from sr.ht, see LICENSE below) 2 + # 3 + # Allowed: 4 + # - Search engine indexers 5 + # - Archival services (e.g. IA) 6 + # 7 + # Disallowed: 8 + # - Marketing or SEO crawlers 9 + # - Bots which are too agressive by default. This is subjective, if you annoy 10 + # our sysadmins you'll be blocked. 11 + # 12 + # Reach out to mail@jordanreger.com if you have questions. 13 + 14 + # Too aggressive, marketing/SEO 15 + User-agent: SemrushBot 16 + Disallow: / 17 + 18 + # Too aggressive, marketing/SEO 19 + User-agent: SemrushBot-SA 20 + Disallow: / 21 + 22 + # Marketing/SEO 23 + User-agent: AhrefsBot 24 + Disallow: / 25 + 26 + # Marketing/SEO 27 + User-agent: dotbot 28 + Disallow: / 29 + 30 + # Marketing/SEO 31 + User-agent: rogerbot 32 + Disallow: / 33 + 34 + User-agent: BLEXBot 35 + Disallow: / 36 + 37 + # Huwei something or another, badly behaved 38 + User-agent: AspiegelBot 39 + Disallow: / 40 + 41 + # Marketing/SEO 42 + User-agent: ZoominfoBot 43 + Disallow: / 44 + 45 + # YandexBot is a dickhead, too aggressive 46 + User-agent: Yandex 47 + Disallow: / 48 + 49 + # Marketing/SEO 50 + User-agent: MJ12bot 51 + Disallow: / 52 + 53 + # Marketing/SEO 54 + User-agent: DataForSeoBot 55 + Disallow: / 56 + 57 + # Used for Alexa, I guess, who cares 58 + User-agent: Amazonbot 59 + Disallow: / 60 + 61 + # No 62 + User-agent: turnitinbot 63 + Disallow: / 64 + 65 + User-agent: Turnitin 66 + Disallow: / 67 + 68 + # Does not respect * directives 69 + User-agent: Seekport Crawler 70 + Disallow: / 71 + 72 + # No thanks 73 + User-agent: GPTBot 74 + Disallow: / 75 + 76 + # Fairly certain that this is an LLM data vacuum 77 + User-agent: ClaudeBot 78 + Disallow: / 79 + 80 + # Same 81 + User-agent: Google-Extended 82 + Disallow: / 83 + 84 + # Marketing 85 + User-agent: serpstatbot 86 + Disallow: / 87 + 88 + # Marketing/SEO 89 + User-agent: barkrowler 90 + Disallow: / 91 + 92 + # Very aggressive, used for TikTok or something 93 + User-agent: Bytespider 94 + Disallow: / 95 + 96 + 97 + # LICENSE (https://git.sr.ht/~sircmpwn/sr.ht-nginx/tree/master/item/LICENSE) 98 + # =============================================================================== 99 + # Copyright (c) 2020 Drew DeVault 100 + 101 + # Permission is hereby granted, free of charge, to any person obtaining a copy of 102 + # this software and associated documentation files (the "Software"), to deal in 103 + # the Software without restriction, including without limitation the rights to 104 + # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 105 + # of the Software, and to permit persons to whom the Software is furnished to do 106 + # so, subject to the following conditions: 107 + 108 + # The above copyright notice and this permission notice shall be included in all 109 + # copies or substantial portions of the Software. 110 + 111 + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 112 + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 113 + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 114 + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 115 + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 116 + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 117 + # SOFTWARE.