An HTML-only Bluesky frontend

move to js and update feeds

+276 -282
+229
actor.js
··· 1 + import { agent } from "./main.js"; 2 + import { getFacets, getDescriptionFacets } 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 Actor { 12 + uri; 13 + actor; 14 + 15 + constructor(uri) { 16 + this.uri = uri; 17 + this.actor = this.#get(); 18 + } 19 + 20 + async #get() { 21 + const { data: actor } = await agent.api.app.bsky.actor.getProfile({ 22 + actor: this.uri, 23 + }); 24 + return actor; 25 + } 26 + 27 + async HTML(prevCursor) { 28 + const actor = await this.actor; 29 + 30 + actor.username = actor.displayName ? actor.displayName : actor.handle; 31 + actor.avatar = actor.avatar ? actor.avatar : "/static/avatar.jpg"; 32 + 33 + return ` 34 + <head> 35 + <meta name="color-scheme" content="light dark"> 36 + <meta name="viewport" content="width=device-width, initial-scale=1"> 37 + <title>${actor.username} (@${actor.handle}) &#8212; HTMLsky</title> 38 + </head> 39 + <table> 40 + <tr> 41 + <td valign="top" height="60" width="60"> 42 + <img src="${actor.avatar}" alt="${actor.username}'s avatar" height="60" width="60"> 43 + </td> 44 + <td> 45 + <h1> 46 + <span>${actor.username}</span><br> 47 + <small><small><small>@${actor.handle}</small></small></small> 48 + </h1> 49 + </td> 50 + </tr> 51 + ${ 52 + actor.description 53 + ? `<tr> 54 + <td colspan="2"> 55 + <p>${await getDescriptionFacets(actor.description).then((res) => res.replaceAll("\n", "<br>"))}</p> 56 + </td> 57 + </tr> 58 + <tr> 59 + <td colspan="2">&nbsp;</td> 60 + </tr>` 61 + : `` 62 + } 63 + <tr> 64 + <td colspan="2"> 65 + <a href="./followers/"><b>${actor.followersCount}</b> followers</a> 66 + <a href="./follows/"><b>${actor.followsCount}</b> following</a> 67 + <b>${actor.postsCount}</b> posts 68 + </td> 69 + </tr> 70 + </table> 71 + <hr> 72 + ${prevCursor ? await this.Feed(prevCursor) : await this.Feed()} 73 + `; 74 + } 75 + 76 + async Feed(prevCursor) { 77 + const actor = await this.actor; 78 + let options = { actor: actor.did }; 79 + if (prevCursor) { 80 + options = { actor: actor.did, cursor: prevCursor }; 81 + } 82 + const { data } = await agent.api.app.bsky.feed.getAuthorFeed(options); 83 + const { feed, cursor } = data; 84 + 85 + const feedList = []; 86 + 87 + feed.forEach(async (post) => { 88 + if (post.reply) { 89 + const reply = post.reply.parent ? post.reply.parent : post.reply.root; 90 + const author = reply.author; 91 + if (reply.notFound || reply.blocked) { 92 + feedList.push(` 93 + <tr> 94 + <td> 95 + <table> 96 + <tr><td> 97 + Post was deleted. 98 + </td></tr> 99 + </table> 100 + </td> 101 + </tr> 102 + `); 103 + } else { 104 + feedList.push(` 105 + <tr> 106 + <td> 107 + <table> 108 + <tr><td><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))}</td></tr> 109 + <tr><td>${/*await getFacets(*/reply.record.text/*).then((res) => res.replaceAll("\n", "<br>"))*/}</td></tr> 110 + <tr><td><b>${reply.replyCount}</b> replies &middot; <b>${reply.repostCount}</b> reposts &middot; <b>${reply.likeCount}</b> likes</td></tr> 111 + </table> 112 + </td> 113 + </tr> 114 + `); 115 + } 116 + } 117 + 118 + const record = post.post.record; 119 + const author = post.post.author; 120 + 121 + feedList.push(` 122 + <tr> 123 + <td> 124 + ${post.reply ? `<blockquote>` : ``} 125 + <table> 126 + ${actor.did !== author.did ? `<tr><td><i>Reposted by ${actor.displayName ? actor.displayName : actor.handle}</i></td></tr>` : ``} 127 + <tr><td><b>${author.displayName ? author.displayName : author.handle}</b> (<a href="/profile/${author.handle !== "handle.invalid" ? author.handle : author.did }/">@${author.handle}</a>) &middot; ${DateTimeFormat.format(new Date(record.createdAt))}</td></tr> 128 + <tr><td><p>${/*await getFacets(*/record.text/*).then((res) => res.replaceAll("\n", "<br>"))*/}</p></td></tr> 129 + <tr><td><b>${post.post.replyCount}</b> replies &middot; <b>${post.post.repostCount}</b> reposts &middot; <b>${post.post.likeCount}</b> likes</td></tr> 130 + </table> 131 + ${post.reply ? `</blockquote><hr>` : `<hr>`} 132 + </td> 133 + </tr> 134 + `); 135 + }); 136 + 137 + if (cursor) { 138 + feedList.push(` 139 + <tr> 140 + <td><br><a href="?cursor=${cursor}">Next page</a></td> 141 + </tr> 142 + `); 143 + } 144 + 145 + return ` 146 + <table width="100%"> 147 + ${feedList.join("")} 148 + </table> 149 + `; 150 + } 151 + 152 + async Followers(prevCursor) { 153 + const actor = await this.actor; 154 + let options = { actor: actor.did }; 155 + if (prevCursor) { 156 + options = { actor: actor.did, cursor: prevCursor }; 157 + } 158 + 159 + const { data } = await agent.api.app.bsky.graph.getFollowers(options); 160 + const { followers, cursor } = data; 161 + 162 + const followersList = []; 163 + followers.forEach((follower) => { 164 + followersList.push(` 165 + <tr> 166 + <td><b>${follower.displayName ? follower.displayName : follower.handle}</b> (<a href="/profile/${follower.handle !== "handle.invalid" ? follower.handle : follower.did}/">@${follower.handle}</a>)</td> 167 + </tr>`); 168 + }); 169 + 170 + if (cursor) { 171 + followersList.push(` 172 + <tr> 173 + <td><br><a href="?cursor=${cursor}">Next page</a></td> 174 + </tr> 175 + `); 176 + } 177 + 178 + return ` 179 + <head> 180 + <meta name="color-scheme" content="light dark"> 181 + <meta name="viewport" content="width=device-width, initial-scale=1"> 182 + <title>People following @${actor.handle} &#8212; HTMLsky</title> 183 + </head> 184 + <p><a href="..">Back</a></p> 185 + <table> 186 + ${followersList.join("")} 187 + </table> 188 + `; 189 + } 190 + 191 + async Follows(prevCursor) { 192 + const actor = await this.actor; 193 + let options = { actor: actor.did }; 194 + if (prevCursor) { 195 + options = { actor: actor.did, cursor: prevCursor }; 196 + } 197 + 198 + const { data } = await agent.api.app.bsky.graph.getFollows(options); 199 + const { follows, cursor } = data; 200 + 201 + const followsList = []; 202 + follows.forEach((follow) => { 203 + followsList.push(` 204 + <tr> 205 + <td><b>${follow.displayName ? follow.displayName : follow.handle}</b> (<a href="/profile/${follow.handle !== "handle.invalid" ? follow.handle : follow.did}/">@${follow.handle}</a>)</td> 206 + </tr>`); 207 + }); 208 + 209 + if (cursor) { 210 + followsList.push(` 211 + <tr> 212 + <td><br><a href="?cursor=${cursor}">Next page</a></td> 213 + </tr> 214 + `); 215 + } 216 + 217 + return ` 218 + <head> 219 + <meta name="color-scheme" content="light dark"> 220 + <meta name="viewport" content="width=device-width, initial-scale=1"> 221 + <title>People followed by @${actor.handle} &#8212; HTMLsky</title> 222 + </head> 223 + <p><a href="..">Back</a></p> 224 + <table> 225 + ${followsList.join("")} 226 + </table> 227 + `; 228 + } 229 + }
-246
actor.ts
··· 1 - import { 2 - AppBskyActorDefs, 3 - AppBskyFeedGetAuthorFeed, 4 - AppBskyFeedPost, 5 - AppBskyGraphGetFollowers, 6 - } from "npm:@atproto/api"; 7 - 8 - import { agent } from "./main.ts"; 9 - import { getDescriptionFacets } from "./facets.ts"; 10 - 11 - const DateTimeFormat = new Intl.DateTimeFormat("en-US", { 12 - dateStyle: "short", 13 - timeStyle: "long", 14 - timeZone: "UTC", 15 - hour12: false, 16 - }); 17 - 18 - export default class Actor { 19 - uri: string; 20 - actor: Promise<AppBskyActorDefs.ProfileViewDetailed>; 21 - 22 - constructor(uri: string) { 23 - this.uri = uri; 24 - this.actor = this.#get(); 25 - } 26 - 27 - async #get() { 28 - const { data: actor } = await agent.api.app.bsky.actor.getProfile({ 29 - actor: this.uri, 30 - }); 31 - return actor; 32 - } 33 - 34 - async Raw(): Promise<string> { 35 - const actor = await this.actor; 36 - 37 - return JSON.stringify(actor, null, 2); 38 - } 39 - 40 - async HTML(prevCursor?: string): Promise<string> { 41 - const actor = await this.actor; 42 - 43 - actor.username = actor.displayName ? actor.displayName : actor.handle; 44 - actor.avatar = actor.avatar ? actor.avatar : "/static/avatar.jpg"; 45 - 46 - return ` 47 - <head> 48 - <meta name="color-scheme" content="light dark"> 49 - <meta name="viewport" content="width=device-width, initial-scale=1"> 50 - <title>${actor.username} (@${actor.handle}) &#8212; HTMLsky</title> 51 - </head> 52 - <table> 53 - <tr> 54 - <td valign="top" height="60" width="60"> 55 - <img src="${actor.avatar}" alt="${actor.username}'s avatar" height="60" width="60"> 56 - </td> 57 - <td> 58 - <h1> 59 - <span>${actor.username}</span><br> 60 - <small><small><small>@${actor.handle}</small></small></small> 61 - </h1> 62 - </td> 63 - </tr> 64 - ${ 65 - actor.description 66 - ? `<tr> 67 - <td colspan="2"> 68 - <p>${await getDescriptionFacets(actor.description).then((res) => 69 - res.replaceAll("\n", "<br>") 70 - )}</p> 71 - </td> 72 - </tr> 73 - <tr> 74 - <td colspan="2">&nbsp;</td> 75 - </tr>` 76 - : `` 77 - } 78 - <tr> 79 - <td colspan="2"> 80 - <a href="./followers/"><b>${actor.followersCount}</b> followers</a> 81 - <a href="./follows/"><b>${actor.followsCount}</b> following</a> 82 - <b>${actor.postsCount}</b> posts 83 - </td> 84 - </tr> 85 - </table> 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> 244 - `; 245 - } 246 - }
+1 -5
deno.json
··· 1 1 { 2 2 "tasks": { 3 - "dev": "deno task clean && deno run -A --watch main.ts", 3 + "dev": "deno run -A --watch main.js", 4 4 "clean": "deno fmt && deno lint" 5 - }, 6 - "compilerOptions": { 7 - "jsx": "react-jsx", 8 - "jsxImportSource": "npm:preact@10.22.0" 9 5 } 10 6 }
+40
facets.js
··· 1 + import { RichText } from "npm:@atproto/api"; 2 + import { agent } from "./main.js"; 3 + 4 + export async function getFacets(text) { 5 + const rt = new RichText({ text: text }); 6 + await rt.detectFacets(agent); 7 + 8 + let res = ""; 9 + 10 + for (const segment of rt.segments()) { 11 + if (segment.isLink()) { 12 + res += `<a href="${segment.link?.uri}">${segment.text}</a>`; 13 + } else if (segment.isMention()) { 14 + res += `<a href="/profile/${segment.mention?.did}/">${segment.text}</a>`; 15 + } else { 16 + res += segment.text; 17 + } 18 + } 19 + 20 + return res; 21 + } 22 + 23 + export async function getDescriptionFacets(text) { 24 + const rt = new RichText({ text: text }); 25 + await rt.detectFacets(agent); 26 + 27 + let res = ""; 28 + 29 + for (const segment of rt.segments()) { 30 + if (segment.isLink()) { 31 + res += `<a href="${segment.link.uri}">${segment.text}</a>`; 32 + } else if (segment.isMention()) { 33 + res += `<a href="/profile/${segment.mention.did}/">${segment.text}</a>`; 34 + } else { 35 + res += segment.text; 36 + } 37 + } 38 + 39 + return res; 40 + }
-25
facets.ts
··· 1 - import { RichText } from "npm:@atproto/api"; 2 - import { agent } from "./main.ts"; 3 - 4 - export async function getDescriptionFacets( 5 - description: string, 6 - ): Promise<string> { 7 - const rt = new RichText({ text: description }); 8 - await rt.detectFacets(agent); 9 - 10 - let descriptionWithFacets = ""; 11 - 12 - for (const segment of rt.segments()) { 13 - if (segment.isLink()) { 14 - descriptionWithFacets += 15 - `<a href="${segment.link?.uri}">${segment.text}</a>`; 16 - } else if (segment.isMention()) { 17 - descriptionWithFacets += 18 - `<a href="/profile/${segment.mention?.did}">${segment.text}</a>`; 19 - } else { 20 - descriptionWithFacets += segment.text; 21 - } 22 - } 23 - 24 - return descriptionWithFacets; 25 - }
+6 -6
main.ts main.js
··· 1 1 import { AtpAgent } from "npm:@atproto/api"; 2 2 import { serveDir, serveFile } from "jsr:@std/http/file-server"; 3 3 4 - import Actor from "./actor.ts"; 4 + import Actor from "./actor.js"; 5 5 6 6 export const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 7 7 ··· 33 33 const profilePattern = new URLPattern({ pathname: "/profile/:actor/" }); 34 34 if (profilePattern.test(url)) { 35 35 const actorName = profilePattern.exec(url)?.pathname.groups.actor; 36 - const actor = new Actor(actorName!); 36 + const actor = new Actor(actorName); 37 37 const cursor = query.get("cursor"); 38 38 if (cursor) return new Response(await actor.HTML(cursor), html_headers); 39 39 else return new Response(await actor.HTML(), html_headers); ··· 45 45 }); 46 46 if (rawProfilePattern.test(url)) { 47 47 const actorName = rawProfilePattern.exec(url)?.pathname.groups.actor; 48 - const actor = new Actor(actorName!); 48 + const actor = new Actor(actorName); 49 49 50 50 return new Response(await actor.Raw(), json_headers); 51 51 } ··· 57 57 if (pageProfilePattern.test(url)) { 58 58 const actorName = pageProfilePattern.exec(url)?.pathname.groups.actor; 59 59 const pageName = pageProfilePattern.exec(url)?.pathname.groups.page; 60 - const actor = new Actor(actorName!); 60 + const actor = new Actor(actorName); 61 61 62 62 if (pageName === "followers") { 63 63 const cursor = query.get("cursor"); ··· 85 85 ); 86 86 }); 87 87 88 - const html_headers: ResponseInit = { 88 + const html_headers = { 89 89 "headers": { 90 90 "Content-Type": "text/html;charset=utf-8", 91 91 }, 92 92 }; 93 - const json_headers: ResponseInit = { 93 + const json_headers = { 94 94 "headers": { 95 95 "Content-Type": "application/json;charset=utf-8", 96 96 },