An HTML-only Bluesky frontend

mvp

+207 -379
+71
actor.ts
··· 1 + import { ProfileView } from "npm:@atproto/api"; 2 + 3 + import { agent } from "./main.ts"; 4 + import { getDescriptionFacets } from "./facets.ts"; 5 + 6 + export default class Actor { 7 + uri: string; 8 + actor: ProfileView; 9 + 10 + constructor(uri: string) { 11 + this.uri = uri; 12 + this.actor = this.#get(); 13 + } 14 + 15 + async #get() { 16 + const { data: actor } = await agent.api.app.bsky.actor.getProfile({ 17 + actor: this.uri, 18 + }); 19 + return actor; 20 + } 21 + 22 + async Raw(): string { 23 + const actor = await this.actor; 24 + 25 + return JSON.stringify(actor, null, 2); 26 + } 27 + 28 + async HTML(): string { 29 + const actor = await this.actor; 30 + 31 + actor.username = actor.displayName ? actor.displayName : actor.handle; 32 + actor.avatar = actor.avatar ? actor.avatar : "/static/avatar.jpg"; 33 + 34 + return ` 35 + <head> 36 + <meta name="color-scheme" content="light dark"> 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 + ${actor.description ? `<tr> 52 + <td colspan="2"> 53 + <p>${await getDescriptionFacets(actor.description).then(res => res.replaceAll("\n", "<br>"))}</p> 54 + </td> 55 + </tr> 56 + <tr> 57 + <td colspan="2">&nbsp;</td> 58 + </tr>` 59 + : ``} 60 + <tr> 61 + <td colspan="2"> 62 + <b>${actor.followersCount}</b> followers 63 + <b>${actor.followsCount}</b> following 64 + <b>${actor.postsCount}</b> posts 65 + </td> 66 + </tr> 67 + </table> 68 + <hr> 69 + `; 70 + } 71 + }
-8
components/head.tsx
··· 1 - export const Head = ({ title }: { title: string }) => { 2 - return ( 3 - <head> 4 - <meta name="color-scheme" content="light dark" /> 5 - <title>{title} &#8212; HTMLsky</title> 6 - </head> 7 - ); 8 - };
-23
components/header.tsx
··· 1 - import { AppBskyActorDefs } from "npm:@atproto/api"; 2 - 3 - export function ActorHeader(actor: AppBskyActorDefs.ProfileViewDetailed) { 4 - return ( 5 - <header> 6 - <nav> 7 - <span> 8 - <b> 9 - <i>HTMLsky</i> 10 - </b>&nbsp; 11 - </span> 12 - [ <a href="/">Home</a> ] [{" "} 13 - <a href="https://sr.ht/~jordanreger/htmlsky">Source</a> ] [{" "} 14 - <a href={`https://bsky.app/profile/${actor.did}`}> 15 - View on Bluesky 16 - </a>{" "} 17 - ] 18 - </nav> 19 - 20 - <hr /> 21 - </header> 22 - ); 23 - }
+1 -1
deno.json
··· 1 1 { 2 2 "tasks": { 3 - "dev": "deno task clean && deno run -A --watch main.tsx", 3 + "dev": "deno task clean && deno run -A --watch main.ts", 4 4 "clean": "deno fmt && deno lint" 5 5 }, 6 6 "compilerOptions": {
+47
deno.lock
··· 2 2 "version": "3", 3 3 "packages": { 4 4 "specifiers": { 5 + "jsr:@std/cli@^0.224.7": "jsr:@std/cli@0.224.7", 6 + "jsr:@std/encoding@1.0.0-rc.2": "jsr:@std/encoding@1.0.0-rc.2", 7 + "jsr:@std/fmt@^0.225.4": "jsr:@std/fmt@0.225.4", 8 + "jsr:@std/http": "jsr:@std/http@0.224.5", 9 + "jsr:@std/media-types": "jsr:@std/media-types@0.224.1", 10 + "jsr:@std/media-types@^1.0.0-rc.1": "jsr:@std/media-types@1.0.0", 11 + "jsr:@std/net@^0.224.3": "jsr:@std/net@0.224.3", 12 + "jsr:@std/path@1.0.0-rc.2": "jsr:@std/path@1.0.0-rc.2", 13 + "jsr:@std/streams@^0.224.5": "jsr:@std/streams@0.224.5", 5 14 "npm:@atproto/api": "npm:@atproto/api@0.12.8", 6 15 "npm:preact": "npm:preact@10.22.0", 7 16 "npm:preact-render-to-string": "npm:preact-render-to-string@6.5.5_preact@10.22.0", 8 17 "npm:preact@10.22.0": "npm:preact@10.22.0", 9 18 "npm:sanitize-html": "npm:sanitize-html@2.13.0" 19 + }, 20 + "jsr": { 21 + "@std/cli@0.224.7": { 22 + "integrity": "654ca6477518e5e3a0d3fabafb2789e92b8c0febf1a1d24ba4b567aba94b5977" 23 + }, 24 + "@std/encoding@1.0.0-rc.2": { 25 + "integrity": "160d7674a20ebfbccdf610b3801fee91cf6e42d1c106dd46bbaf46e395cd35ef" 26 + }, 27 + "@std/fmt@0.225.4": { 28 + "integrity": "584c681cf422b70e28959b57e59012823609c087384cbf12d05f67814797fda3" 29 + }, 30 + "@std/http@0.224.5": { 31 + "integrity": "b03b5d1529f6c423badfb82f6640f9f2557b4034cd7c30655ba5bb447ff750a4", 32 + "dependencies": [ 33 + "jsr:@std/cli@^0.224.7", 34 + "jsr:@std/encoding@1.0.0-rc.2", 35 + "jsr:@std/fmt@^0.225.4", 36 + "jsr:@std/media-types@^1.0.0-rc.1", 37 + "jsr:@std/net@^0.224.3", 38 + "jsr:@std/path@1.0.0-rc.2", 39 + "jsr:@std/streams@^0.224.5" 40 + ] 41 + }, 42 + "@std/media-types@0.224.1": { 43 + "integrity": "9e69a5daed37c5b5c6d3ce4731dc191f80e67f79bed392b0957d1d03b87f11e1" 44 + }, 45 + "@std/media-types@1.0.0": { 46 + "integrity": "58ff81ce1f7af8c46304a52482db804c42936492daad19c0bd5d887832737d2b" 47 + }, 48 + "@std/net@0.224.3": { 49 + "integrity": "a6257b9a35a4f299a0a9d4a4b76aef1f90ad05572374c5267c6c51a2ec41dfba" 50 + }, 51 + "@std/path@1.0.0-rc.2": { 52 + "integrity": "39f20d37a44d1867abac8d91c169359ea6e942237a45a99ee1e091b32b921c7d" 53 + }, 54 + "@std/streams@0.224.5": { 55 + "integrity": "bcde7818dd5460d474cdbd674b15f6638b9cd73cd64e52bd852fba2bd4d8ec91" 56 + } 10 57 }, 11 58 "npm": { 12 59 "@atproto/api@0.12.8": {
+25
facets.ts
··· 1 + import { AtpAgent, 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 + }
-28
facets.tsx
··· 1 - import { RichText } from "npm:@atproto/api"; 2 - 3 - import { agent } from "./main.tsx"; 4 - 5 - export async function GetDescriptionFacets( 6 - description: string, 7 - ): Promise<preact.VNode[]> { 8 - const rt = new RichText({ text: description }); 9 - await rt.detectFacets(agent); 10 - 11 - const descriptionWithFacets: preact.VNode[] = []; 12 - 13 - for (const segment of rt.segments()) { 14 - if (segment.isLink()) { 15 - descriptionWithFacets.push( 16 - <a href={`${segment.link?.uri}`}>{segment.text}</a>, 17 - ); 18 - } else if (segment.isMention()) { 19 - descriptionWithFacets.push( 20 - <a href={`/profile/${segment.mention?.did}`}>{segment.text}</a>, 21 - ); 22 - } else { 23 - descriptionWithFacets.push(<>{segment.text</>); 24 - } 25 - } 26 - 27 - return descriptionWithFacets; 28 - }
+63
main.ts
··· 1 + import { AtpAgent } from "npm:@atproto/api"; 2 + import { serveDir } from "jsr:@std/http/file-server"; 3 + 4 + import Actor from "./actor.ts"; 5 + 6 + export const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 7 + 8 + Deno.serve(async (req) => { 9 + const url = new URL(req.url); 10 + const path = url.pathname; 11 + 12 + if (path.startsWith("/static")) { 13 + return serveDir(req, { quiet: true }); 14 + } 15 + 16 + // handle trailing slashes 17 + if (path.at(-1) !== "/") { 18 + return Response.redirect( 19 + `${url.protocol}${url.host}${url.pathname}/`, 20 + ); 21 + } 22 + 23 + try { 24 + // PROFILE 25 + const profilePattern = new URLPattern({ pathname: "/profile/:actor/" }); 26 + if (profilePattern.test(url)) { 27 + const actorName = profilePattern.exec(url)?.pathname.groups.actor; 28 + const actor = new Actor(actorName); 29 + 30 + return new Response(await actor.HTML(), html_headers); 31 + } 32 + 33 + // RAW PROFILE 34 + const rawProfilePattern = new URLPattern({ pathname: "/raw/profile/:actor/" }); 35 + if (rawProfilePattern.test(url)) { 36 + const actorName = rawProfilePattern.exec(url)?.pathname.groups.actor; 37 + const actor = new Actor(actorName); 38 + 39 + return new Response(await actor.Raw(), json_headers); 40 + } 41 + } catch (error) { 42 + return new Response( 43 + `<head><meta name="color-scheme" content="light dark"></head>\n${error}`, 44 + headers, 45 + ); 46 + } 47 + 48 + return Response.redirect( 49 + `${url.protocol}${url.host}/profile/htmlsky.app/`, 50 + 303, 51 + ); 52 + }); 53 + 54 + const html_headers: ResponseInit = { 55 + "headers": { 56 + "Content-Type": "text/html;charset=utf-8", 57 + }, 58 + }; 59 + const json_headers: ResponseInit = { 60 + "headers": { 61 + "Content-Type": "application/json;charset=utf-8", 62 + }, 63 + };
-91
main.tsx
··· 1 - import { renderToStringAsync } from "npm:preact-render-to-string"; 2 - import { AtpAgent } from "npm:@atproto/api"; 3 - 4 - import { 5 - Actor, 6 - ActorFeed, 7 - ActorFollowers, 8 - ActorFollows, 9 - } from "./pages/actor.tsx"; 10 - 11 - export const agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 12 - 13 - Deno.serve({ 14 - port: 8080, 15 - }, async (req) => { 16 - const url = new URL(req.url); 17 - const path = url.pathname; 18 - 19 - // handle trailing slashes 20 - if (path.at(-1) !== "/") { 21 - return Response.redirect( 22 - `${url.protocol}${url.host}${url.pathname}/`, 23 - ); 24 - } 25 - 26 - const profilePattern = new URLPattern({ pathname: "/profile/:actor/" }); 27 - if (profilePattern.test(url)) { 28 - const actorName = profilePattern.exec(url)?.pathname.groups.actor; 29 - 30 - try { 31 - const actor = await agent.api.app.bsky.actor.getProfile({ 32 - actor: actorName!, 33 - }).then((res) => res.data); 34 - 35 - return new Response( 36 - await renderToStringAsync(await Actor({ actor: actor })), 37 - headers, 38 - ); 39 - } catch (e) { 40 - // TODO: add error page 41 - return new Response(e.message, headers); 42 - } 43 - } 44 - const profilePagePattern = new URLPattern({ 45 - pathname: "/profile/:actor/:page/", 46 - }); 47 - if (profilePagePattern.test(url)) { 48 - const actorName = profilePagePattern.exec(url)?.pathname.groups.actor; 49 - const actor = await agent.api.app.bsky.actor.getProfile({ 50 - actor: actorName!, 51 - }).then((res) => res.data); 52 - const page = profilePagePattern.exec(url)?.pathname.groups.page; 53 - 54 - if (page === "followers") { 55 - return new Response( 56 - await renderToStringAsync( 57 - await ActorFollowers({ url: url, actor: actor }), 58 - ), 59 - headers, 60 - ); 61 - } 62 - if (page === "follows") { 63 - return new Response( 64 - await renderToStringAsync( 65 - await ActorFollows({ url: url, actor: actor }), 66 - ), 67 - headers, 68 - ); 69 - } 70 - 71 - if (page === "posts") { 72 - return new Response( 73 - await renderToStringAsync( 74 - await ActorFeed({ url: url, actor: actor }), 75 - ), 76 - headers, 77 - ); 78 - } 79 - } 80 - 81 - return Response.redirect( 82 - `${url.protocol}${url.host}/profile/htmlsky.app/`, 83 - 303, 84 - ); 85 - }); 86 - 87 - const headers: ResponseInit = { 88 - "headers": { 89 - "Content-Type": "text/html;charset=utf-8", 90 - }, 91 - };
-228
pages/actor.tsx
··· 1 - import { AppBskyActorDefs /*AppBskyFeedDefs*/ } from "npm:@atproto/api"; 2 - import sanitizeHtml from "npm:sanitize-html"; 3 - 4 - import { agent } from "../main.tsx"; 5 - import { GetDescriptionFacets } from "../facets.tsx"; 6 - 7 - // Components 8 - import { Head } from "../components/head.tsx"; 9 - 10 - /* MAIN PAGE */ 11 - export async function Actor( 12 - { actor }: { actor: AppBskyActorDefs.ProfileViewDetailed }, 13 - ) { 14 - return ( 15 - <> 16 - <Head 17 - title={actor.displayName 18 - ? actor.displayName + ` (@${actor.handle})` 19 - : `@${actor.handle}`} 20 - /> 21 - <table draggable={false}> 22 - <tr> 23 - <td> 24 - <img 25 - src={actor.avatar 26 - ? actor.avatar 27 - : "https://htmlsky.app/avatar.jpeg"} 28 - alt={`${sanitizeHtml(actor.displayName)}'s avatar`} 29 - width="90" 30 - /> 31 - </td> 32 - <td>&nbsp;</td> 33 - <td> 34 - <small> 35 - <small> 36 - <small> 37 - <span>&nbsp;</span> 38 - </small> 39 - </small> 40 - </small> 41 - <h1> 42 - <span 43 - dangerouslySetInnerHTML={{ 44 - __html: actor.displayName ? actor.displayName : actor.handle, 45 - }} 46 - > 47 - </span> 48 - <br /> 49 - <small> 50 - <small> 51 - <small> 52 - <i>@{actor.handle}</i> 53 - </small> 54 - </small> 55 - </small> 56 - </h1> 57 - </td> 58 - </tr> 59 - </table> 60 - 61 - <p> 62 - <span> 63 - <a href="./followers"> 64 - <b>{actor.followersCount}</b> followers 65 - </a> 66 - </span>&nbsp; 67 - <span> 68 - <a href="./follows"> 69 - <b>{actor.followsCount}</b> following 70 - </a> 71 - </span>&nbsp; 72 - <span> 73 - <b>{actor.postsCount}</b> posts 74 - </span>&nbsp; 75 - </p> 76 - 77 - <p> 78 - {await GetDescriptionFacets(sanitizeHtml(actor.description))} 79 - </p> 80 - 81 - <hr /> 82 - 83 - <p>A list of posts will be here eventually.</p> 84 - </> 85 - ); 86 - } 87 - 88 - /* FOLLOWS */ 89 - export async function ActorFollows( 90 - { url, actor }: { url: URL; actor: AppBskyActorDefs.ProfileViewDetailed }, 91 - ) { 92 - const cursor = url.searchParams.get("cursor")!; 93 - 94 - const follows: AppBskyActorDefs.ProfileViewDetailed[] = await agent.api.app 95 - .bsky.graph.getFollows({ actor: actor.did, cursor: cursor }).then((res) => 96 - res.data 97 - ).then( 98 - (res) => { 99 - //cursor = res.cursor!; 100 - return res.follows; 101 - }, 102 - ); 103 - 104 - const followList: preact.VNode[] = []; 105 - 106 - follows.forEach((follow) => { 107 - followList.push( 108 - <p> 109 - <b>{follow.displayName ? follow.displayName : follow.handle}</b> 110 - <br />{" "} 111 - <a 112 - href={`/profile/${ 113 - follow.handle !== "handle.invalid" ? follow.handle : follow.did 114 - }/`} 115 - > 116 - @{follow.handle} 117 - </a> 118 - </p>, 119 - ); 120 - }); 121 - 122 - if (cursor) { 123 - followList.push(<a href={`?cursor=${cursor}`}>Next page</a>); 124 - } 125 - 126 - return ( 127 - <> 128 - <Head title={`People followed by @${actor.handle}`} /> 129 - <header> 130 - <a href="..">Back</a>&nbsp; 131 - <span> 132 - <b> 133 - <i> 134 - {actor.displayName ? actor.displayName : actor.handle}'s Follows 135 - </i> 136 - </b> 137 - </span> 138 - 139 - <hr /> 140 - </header> 141 - {followList} 142 - </> 143 - ); 144 - } 145 - 146 - /* FOLLOWERS */ 147 - export async function ActorFollowers( 148 - { url, actor }: { url: URL; actor: AppBskyActorDefs.ProfileViewDetailed }, 149 - ) { 150 - const cursor = url.searchParams.get("cursor")!; 151 - 152 - const followers: AppBskyActorDefs.ProfileViewDetailed[] = await agent.api.app 153 - .bsky.graph.getFollowers({ actor: actor.did, cursor: cursor }).then((res) => 154 - res.data 155 - ).then( 156 - (res) => { 157 - //cursor = res.cursor!; 158 - return res.followers; 159 - }, 160 - ); 161 - 162 - const followersList: preact.VNode[] = []; 163 - 164 - followers.forEach((follower) => { 165 - followersList.push( 166 - <p> 167 - <b>{follower.displayName ? follower.displayName : follower.handle}</b> 168 - <br /> <a href={`/profile/${follower.handle}/`}>@{follower.handle}</a> 169 - </p>, 170 - ); 171 - }); 172 - 173 - if (cursor) { 174 - followersList.push(<a href={`?cursor=${cursor}`}>Next page</a>); 175 - } 176 - 177 - return ( 178 - <> 179 - <Head title={`People following @${actor.handle}`} /> 180 - <header> 181 - <a href="..">Back</a>&nbsp; 182 - <span> 183 - <b> 184 - <i> 185 - {actor.displayName ? actor.displayName : actor.handle}'s Followers 186 - </i> 187 - </b> 188 - </span> 189 - 190 - <hr /> 191 - </header> 192 - {followersList} 193 - </> 194 - ); 195 - } 196 - 197 - /* FEED */ 198 - export async function ActorFeed( 199 - { url, actor }: { url: URL; actor: AppBskyActorDefs.ProfileViewDetailed }, 200 - ) { 201 - // TODO: implement cursor 202 - const _prevCursor = url.searchParams.get("cursor")!; 203 - 204 - const { data } = await agent.api.app 205 - .bsky.feed.getAuthorFeed({ actor: actor.did }); 206 - 207 - const { feed, cursor } = data; 208 - 209 - const feedList: preact.VNode[] = []; 210 - 211 - feed.forEach((post) => { 212 - feedList.push( 213 - <p> 214 - {post.post} 215 - </p>, 216 - ); 217 - }); 218 - 219 - if (cursor) { 220 - feedList.push(<a href={`?cursor=${cursor}`}>Next page</a>); 221 - } 222 - 223 - return ( 224 - <> 225 - {feedList} 226 - </> 227 - ); 228 - }
static/avatar.jpg

This is a binary file and will not be displayed.