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