+41
-4
api.js
+41
-4
api.js
···
106
106
return this.handleCache.findHandleByDid(did);
107
107
}
108
108
109
+
/** @param {string} did, @returns {Promise<string>} */
110
+
111
+
async fetchHandleForDid(did) {
112
+
let cachedHandle = this.handleCache.findHandleByDid(did);
113
+
114
+
if (cachedHandle) {
115
+
return cachedHandle;
116
+
} else {
117
+
let author = await this.loadUserProfile(did);
118
+
return author.handle;
119
+
}
120
+
}
121
+
109
122
/** @param {string} string, @returns {[string, string]} */
110
123
111
124
static parsePostURL(string) {
···
169
182
async loadThreadById(author, postId) {
170
183
let did = author.startsWith('did:') ? author : await this.resolveHandle(author);
171
184
let postURI = `at://${did}/app.bsky.feed.post/${postId}`;
172
-
let threadJSON = await this.getRequest('app.bsky.feed.getPostThread', { uri: postURI, depth: 10 });
173
-
return threadJSON;
185
+
return await this.loadThreadByAtURI(postURI);
186
+
}
187
+
188
+
/** @param {string} uri, @returns {Promise<json>} */
189
+
190
+
async loadThreadByAtURI(uri) {
191
+
return await this.getRequest('app.bsky.feed.getPostThread', { uri: uri, depth: 10 });
174
192
}
175
193
176
194
/** @param {string} handle, @returns {Promise<json>} */
···
216
234
}
217
235
}
218
236
237
+
/** @param {string} uri, @returns {Promise<json[]>} */
238
+
239
+
async getReplies(uri) {
240
+
let json = await this.getRequest('blue.feeds.post.getReplies', { uri });
241
+
return json.replies;
242
+
}
243
+
219
244
/** @param {string} uri, @returns {Promise<number>} */
220
245
221
246
async getQuoteCount(uri) {
222
-
let json = await this.getRequest('eu.mackuba.private.getQuoteCount', { uri });
247
+
let json = await this.getRequest('blue.feeds.post.getQuoteCount', { uri });
223
248
return json.quoteCount;
224
249
}
225
250
···
236
261
params['cursor'] = cursor;
237
262
}
238
263
239
-
return await this.getRequest('eu.mackuba.private.getPostQuotes', params);
264
+
return await this.getRequest('blue.feeds.post.getQuotes', params);
240
265
}
241
266
242
267
/** @param {string} hashtag, @param {string | undefined} cursor, @returns {Promise<json>} */
···
254
279
/** @param {string} postURI, @returns {Promise<json>} */
255
280
256
281
async loadPost(postURI) {
282
+
let posts = await this.loadPosts([postURI]);
283
+
284
+
if (posts.length == 1) {
285
+
return posts[0];
286
+
} else {
287
+
throw new ResponseDataError('Post not found');
288
+
}
289
+
}
290
+
291
+
/** @param {string} postURI, @returns {Promise<json | undefined>} */
292
+
293
+
async loadPostIfExists(postURI) {
257
294
let posts = await this.loadPosts([postURI]);
258
295
return posts[0];
259
296
}
+12
-6
embed_component.js
+12
-6
embed_component.js
···
65
65
let div = $tag('div.quote-embed');
66
66
67
67
if (embed.post instanceof Post || embed.post instanceof BlockedPost) {
68
-
let postView = new PostComponent(embed.post).buildElement('quote');
68
+
let postView = new PostComponent(embed.post, 'quote').buildElement();
69
69
div.appendChild(postView);
70
70
71
71
} else if (embed.post instanceof MissingPost) {
72
-
let postView = new PostComponent(embed.post).buildElement('quote');
72
+
let postView = new PostComponent(embed.post, 'quote').buildElement();
73
73
div.appendChild(postView);
74
74
75
75
} else if (embed.post instanceof FeedGeneratorRecord) {
···
251
251
/** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */
252
252
253
253
async loadQuotedPost(uri, div) {
254
-
let result = await api.loadPost(uri);
255
-
let post = new Post(result);
254
+
let record = await api.loadPostIfExists(uri);
256
255
257
-
let postView = new PostComponent(post).buildElement('quote');
258
-
div.replaceChildren(postView);
256
+
if (record) {
257
+
let post = new Post(record);
258
+
let postView = new PostComponent(post, 'quote').buildElement();
259
+
div.replaceChildren(postView);
260
+
} else {
261
+
let post = new MissingPost(this.embed.record);
262
+
let postView = new PostComponent(post, 'quote').buildElement();
263
+
div.replaceChildren(postView);
264
+
}
259
265
}
260
266
}
+23
-3
index.html
+23
-3
index.html
···
41
41
42
42
<div id="account_menu">
43
43
<ul>
44
-
<li><a href="#" data-action="incognito" title="Temporarily load threads as a logged-out user">Incognito mode</a></li>
44
+
<li><a href="#" data-action="incognito"
45
+
title="Temporarily load threads as a logged-out user"><span class="check">✓ </span>Incognito mode</a></li>
46
+
47
+
<li><a href="#" data-action="biohazard"
48
+
title="Show links to blocked and hidden comments"><span class="check">✓ </span>Show infohazards</a></li>
49
+
50
+
<li><a href="#" data-action="login">Log in</a></li>
45
51
<li><a href="#" data-action="logout">Log out</a></li>
46
52
</ul>
47
53
</div>
48
54
49
-
<div id="login">
55
+
<div id="thread">
56
+
</div>
57
+
58
+
<div id="login" class="dialog">
50
59
<form method="get">
51
60
<i class="close fa-circle-xmark fa-regular"></i>
52
61
<h2>🌤 Skythread</h2>
···
65
74
</form>
66
75
</div>
67
76
68
-
<div id="thread">
77
+
<div id="biohazard_dialog" class="dialog">
78
+
<form method="get">
79
+
<i class="close fa-circle-xmark fa-regular"></i>
80
+
<h2>☣️ Infohazard Warning</h2>
81
+
<p>“<em>This thread is not a place of honor... no highly esteemed post is commemorated here... nothing valued is here.</em>”</p>
82
+
<p>This feature allows access to comments in a thread which were hidden because one of the commenters has blocked another. Bluesky currently hides such comments to avoid escalating conflicts.</p>
83
+
<p>Are you sure you want to enter?<br>(You can toggle this in the menu in top-left corner.)</p>
84
+
<p class="submit">
85
+
<input type="submit" id="biohazard_show" value="Show me the drama 😈">
86
+
<input type="submit" id="biohazard_hide" value="Nope, I'd rather not 🙈">
87
+
</p>
88
+
</form>
69
89
</div>
70
90
71
91
<script src="lib/purify.min.js"></script>
+86
-13
models.js
+86
-13
models.js
···
52
52
*/
53
53
54
54
class Post extends ATProtoRecord {
55
-
/** @type {ATProtoRecord | undefined} */
55
+
/**
56
+
* Post object which is the direct parent of this post.
57
+
* @type {ATProtoRecord | undefined}
58
+
*/
56
59
parent;
57
60
58
-
/** @type {ATProtoRecord | undefined} */
59
-
root;
61
+
/**
62
+
* Post object which is the root of the whole thread (as specified in the post record).
63
+
* @type {ATProtoRecord | undefined}
64
+
*/
65
+
threadRoot;
66
+
67
+
/**
68
+
* Post which is at the top of the (sub)thread currently loaded on the page (might not be the same as threadRoot).
69
+
* @type {Post | undefined}
70
+
*/
71
+
pageRoot;
60
72
61
-
/** @type {object | undefined} */
73
+
/**
74
+
* Depth of the post in the getPostThread response it was loaded from, starting from 0. May be negative.
75
+
* @type {number | undefined}
76
+
*/
77
+
level;
78
+
79
+
/**
80
+
* Depth of the post in the whole tree visible on the page (pageRoot's absoluteLevel is 0). May be negative.
81
+
* @type {number | undefined}
82
+
*/
83
+
absoluteLevel;
84
+
85
+
/**
86
+
* For posts in feeds and timelines - specifies e.g. that a post was reposted by someone.
87
+
* @type {object | undefined}
88
+
*/
62
89
reason;
63
90
64
-
/** @type {boolean | undefined} */
91
+
/**
92
+
* True if the post was extracted from inner embed of a quote, not from a #postView.
93
+
* @type {boolean | undefined}
94
+
*/
65
95
isEmbed;
66
96
67
97
/**
68
98
* View of a post as part of a thread, as returned from getPostThread.
69
99
* Expected to be #threadViewPost, but may be blocked or missing.
70
100
*
71
-
* @param {json} json, @returns {AnyPost}
101
+
* @param {json} json
102
+
* @param {Post?} [pageRoot]
103
+
* @param {number} [level]
104
+
* @param {number} [absoluteLevel]
105
+
* @returns {AnyPost}
72
106
*/
73
107
74
-
static parseThreadPost(json) {
108
+
static parseThreadPost(json, pageRoot = null, level = 0, absoluteLevel = 0) {
75
109
switch (json.$type) {
76
110
case 'app.bsky.feed.defs#threadViewPost':
77
-
let post = new Post(json.post);
111
+
let post = new Post(json.post, { level: level, absoluteLevel: absoluteLevel });
112
+
113
+
post.pageRoot = pageRoot ?? post;
78
114
79
115
if (json.replies) {
80
-
post.setReplies(json.replies.map(x => Post.parseThreadPost(x)));
116
+
let replies = json.replies.map(x => Post.parseThreadPost(x, post.pageRoot, level + 1, absoluteLevel + 1));
117
+
post.setReplies(replies);
81
118
}
82
119
83
-
if (json.parent) {
84
-
post.parent = Post.parseThreadPost(json.parent);
120
+
if (absoluteLevel <= 0 && json.parent) {
121
+
post.parent = Post.parseThreadPost(json.parent, post.pageRoot, level - 1, absoluteLevel - 1);
85
122
}
86
123
87
124
return post;
···
140
177
141
178
if (json.reply) {
142
179
post.parent = Post.parsePostView(json.reply.parent);
143
-
post.root = Post.parsePostView(json.reply.root);
180
+
post.threadRoot = Post.parsePostView(json.reply.root);
181
+
182
+
if (json.reply.grandparentAuthor) {
183
+
post.grandparentAuthor = json.reply.grandparentAuthor;
184
+
}
144
185
}
145
186
146
187
if (json.reason) {
···
180
221
super(data);
181
222
Object.assign(this, extra ?? {});
182
223
224
+
if (this.absoluteLevel === 0) {
225
+
this.pageRoot = this;
226
+
}
227
+
183
228
this.record = this.isPostView ? data.record : data.value;
184
229
185
230
if (this.isPostView && data.embed) {
···
201
246
}
202
247
}
203
248
249
+
/** @param {Post} post */
250
+
251
+
updateDataFromPost(post) {
252
+
this.record = post.record;
253
+
this.embed = post.embed;
254
+
this.author = post.author;
255
+
this.replies = post.replies;
256
+
this.viewerData = post.viewerData;
257
+
this.viewerLike = post.viewerLike;
258
+
this.level = post.level;
259
+
this.absoluteLevel = post.absoluteLevel;
260
+
}
261
+
204
262
/** @param {AnyPost[]} replies */
205
263
206
264
setReplies(replies) {
···
252
310
return this.record.bridgyOriginalText;
253
311
}
254
312
313
+
/** @returns {boolean} */
314
+
get isRoot() {
315
+
// I AM ROOOT
316
+
return (this.pageRoot === this);
317
+
}
318
+
255
319
/** @returns {string} */
256
320
get authorFediHandle() {
257
321
if (this.isFediPost) {
···
288
352
289
353
/** @returns {boolean} */
290
354
get hasMoreReplies() {
291
-
return this.replyCount !== undefined && this.replyCount !== this.replies.length;
355
+
let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length);
356
+
357
+
return shouldHaveMoreReplies && (this.replies.length === 0) && (this.level !== undefined && this.level > 4);
358
+
}
359
+
360
+
/** @returns {boolean} */
361
+
get hasHiddenReplies() {
362
+
let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length);
363
+
364
+
return shouldHaveMoreReplies && (this.replies.length > 0 || (this.level !== undefined && this.level <= 4));
292
365
}
293
366
294
367
/** @returns {number} */
+137
-67
post_component.js
+137
-67
post_component.js
···
3
3
*/
4
4
5
5
class PostComponent {
6
-
/** @param {Post} post, @param {Post} [root] */
7
-
constructor(post, root) {
8
-
this.post = post;
9
-
this.root = root ?? post;
10
-
this.isRoot = (this.post === this.root);
6
+
/**
7
+
Contexts:
8
+
- thread - a post in the thread tree
9
+
- parent - parent reference above the thread root
10
+
- quote - a quote embed
11
+
- quotes - a post on the quotes page
12
+
- feed - a post on the hashtag feed page
13
+
14
+
@typedef {'thread' | 'parent' | 'quote' | 'quotes' | 'feed'} PostContext
15
+
@param {AnyPost} post, @param {PostContext} context
16
+
*/
17
+
constructor(post, context) {
18
+
this.post = /** @type {Post}, TODO */ (post);
19
+
this.context = context;
20
+
}
21
+
22
+
/** @returns {boolean} */
23
+
get isRoot() {
24
+
return this.post.isRoot;
11
25
}
12
26
13
27
/** @returns {string} */
···
45
59
46
60
/** @returns {json} */
47
61
get timeFormatForTimestamp() {
48
-
if (this.isRoot) {
62
+
if (this.isRoot || this.context != 'thread') {
49
63
return { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' };
50
-
} else if (!sameDay(this.post.createdAt, this.root.createdAt)) {
64
+
} else if (this.post.pageRoot && !sameDay(this.post.createdAt, this.post.pageRoot.createdAt)) {
51
65
return { day: 'numeric', month: 'short', hour: 'numeric', minute: 'numeric' };
52
66
} else {
53
67
return { hour: 'numeric', minute: 'numeric' };
54
68
}
55
69
}
56
70
57
-
/**
58
-
Contexts:
59
-
- thread - a post in the thread tree
60
-
- parent - parent reference above the thread root
61
-
- quote - a quote embed
62
-
- quotes - a post on the quotes page
63
-
- feed - a post on the hashtag feed page
64
-
65
-
@typedef {'thread' | 'parent' | 'quote' | 'quotes' | 'feed'} PostContext
66
-
@param {PostContext} context
67
-
@returns {AnyElement}
68
-
*/
69
-
70
-
buildElement(context) {
71
+
/** @returns {AnyElement} */
72
+
buildElement() {
71
73
let div = $tag('div.post');
72
74
73
75
if (this.post.muted) {
···
82
84
return div;
83
85
}
84
86
85
-
let header = this.buildPostHeader(context);
87
+
let header = this.buildPostHeader();
86
88
div.appendChild(header);
87
89
88
90
let content = $tag('div.content');
89
91
90
-
if (!this.isRoot) {
92
+
if (this.context == 'thread' && !this.isRoot) {
91
93
let edge = $tag('div.edge');
92
94
let line = $tag('div.line');
93
95
edge.appendChild(line);
···
133
135
}
134
136
135
137
if (this.post.replies.length == 1 && this.post.replies[0].author?.did == this.post.author.did) {
136
-
let component = new PostComponent(this.post.replies[0], this.root);
137
-
let element = component.buildElement('thread');
138
+
let component = new PostComponent(this.post.replies[0], 'thread');
139
+
let element = component.buildElement();
138
140
element.classList.add('flat');
139
141
content.appendChild(element);
140
142
} else {
141
143
for (let reply of this.post.replies) {
142
144
if (reply instanceof MissingPost) { continue }
145
+
if (reply instanceof BlockedPost && window.biohazardEnabled === false) { continue }
143
146
144
-
let component = new PostComponent(reply, this.root);
145
-
content.appendChild(component.buildElement('thread'));
147
+
let component = new PostComponent(reply, 'thread');
148
+
content.appendChild(component.buildElement());
146
149
}
147
150
}
148
151
149
-
if (context == 'thread' && this.post.hasMoreReplies) {
150
-
let loadMore = this.buildLoadMoreLink()
151
-
content.appendChild(loadMore);
152
+
if (this.context == 'thread') {
153
+
if (this.post.hasMoreReplies) {
154
+
let loadMore = this.buildLoadMoreLink();
155
+
content.appendChild(loadMore);
156
+
} else if (this.post.hasHiddenReplies && window.biohazardEnabled !== false) {
157
+
let loadMore = this.buildHiddenRepliesLink();
158
+
content.appendChild(loadMore);
159
+
}
152
160
}
153
161
154
162
div.appendChild(content);
···
156
164
return div;
157
165
}
158
166
159
-
/** @param {PostContext} context, @returns {AnyElement} */
167
+
/** @returns {AnyElement} */
160
168
161
-
buildPostHeader(context) {
169
+
buildPostHeader() {
162
170
let timeFormat = this.timeFormatForTimestamp;
163
171
let formattedTime = this.post.createdAt.toLocaleString(window.dateLocale, timeFormat);
164
172
let isoTime = this.post.createdAt.toISOString();
···
178
186
h.innerHTML += `<span class="separator">•</span> ` +
179
187
`<a class="time" href="${this.linkToPost}" target="_blank" title="${isoTime}">${formattedTime}</a> `;
180
188
181
-
if (this.post.replyCount > 0 && !this.isRoot || ['quote', 'quotes', 'feed'].includes(context)) {
189
+
if (this.post.replyCount > 0 && !this.isRoot || ['quote', 'quotes', 'feed'].includes(this.context)) {
182
190
h.innerHTML +=
183
191
`<span class="separator">•</span> ` +
184
192
`<a href="${linkToPostThread(this.post)}" class="action" title="Load this subtree">` +
···
303
311
304
312
link.addEventListener('click', (e) => {
305
313
e.preventDefault();
306
-
link.innerHTML = `<img class="loader" src="icons/sunny.png">`;
307
-
loadThread(this.post.author.handle, this.post.rkey, loadMore.parentNode.parentNode);
314
+
loadMore.innerHTML = `<img class="loader" src="icons/sunny.png">`;
315
+
loadSubtree(this.post, loadMore.closest('.post'));
308
316
});
309
317
310
318
loadMore.appendChild(link);
311
319
return loadMore;
312
320
}
313
321
322
+
/** @returns {AnyElement} */
323
+
324
+
buildHiddenRepliesLink() {
325
+
let loadMore = $tag('p.hidden-replies');
326
+
327
+
let link = $tag('a', {
328
+
href: linkToPostThread(this.post),
329
+
text: "Load hidden replies…"
330
+
});
331
+
332
+
link.addEventListener('click', (e) => {
333
+
e.preventDefault();
334
+
335
+
if (window.biohazardEnabled === true) {
336
+
this.loadHiddenReplies(loadMore);
337
+
} else {
338
+
window.loadInfohazard = () => this.loadHiddenReplies(loadMore);
339
+
showDialog($id('biohazard_dialog'));
340
+
}
341
+
});
342
+
343
+
loadMore.append("☣️ ", link);
344
+
return loadMore;
345
+
}
346
+
347
+
/** @param {HTMLLinkElement} loadMoreButton */
348
+
349
+
loadHiddenReplies(loadMoreButton) {
350
+
loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`;
351
+
loadHiddenSubtree(this.post, loadMoreButton.closest('.post'));
352
+
}
353
+
314
354
/** @param {AnyElement} div, @returns {AnyElement} */
315
355
316
356
buildBlockedPostElement(div) {
317
357
let p = $tag('p.blocked-header');
318
-
p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Blocked post</span> ` +
319
-
`(<a href="${this.didLinkToAuthor}" target="_blank">see author</a>) `;
358
+
p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Blocked post</span>`;
359
+
360
+
if (window.biohazardEnabled === false) {
361
+
div.appendChild(p);
362
+
div.classList.add('blocked');
363
+
return p;
364
+
}
365
+
366
+
let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : '';
367
+
blockStatus = blockStatus ? `, ${blockStatus}` : '';
368
+
369
+
let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' });
370
+
p.append(' (', authorLink, blockStatus, ') ');
320
371
div.appendChild(p);
321
372
322
-
let authorLink = p.querySelector('a');
323
373
let did = atURI(this.post.uri).repo;
324
-
let cachedHandle = api.findHandleByDid(did);
325
-
let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : '';
326
-
327
-
if (cachedHandle) {
328
-
this.post.author.handle = cachedHandle;
374
+
375
+
api.fetchHandleForDid(did).then(handle => {
376
+
this.post.author.handle = handle;
329
377
authorLink.href = this.linkToAuthor;
330
-
authorLink.innerText = `@${cachedHandle}`;
331
-
if (blockStatus) {
332
-
authorLink.after(`, ${blockStatus}`);
333
-
}
334
-
} else {
335
-
api.loadUserProfile(did).then((author) => {
336
-
this.post.author = author;
337
-
authorLink.href = this.linkToAuthor;
338
-
authorLink.innerText = `@${author.handle}`;
339
-
if (blockStatus) {
340
-
authorLink.after(`, ${blockStatus}`);
341
-
}
342
-
});
343
-
}
378
+
authorLink.innerText = `@${handle}`;
379
+
});
344
380
345
381
let loadPost = $tag('p.load-post');
346
382
let a = $tag('a', { href: '#', text: "Load post…" });
···
362
398
buildMissingPostElement(div) {
363
399
let p = $tag('p.blocked-header');
364
400
p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`;
401
+
402
+
let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' });
403
+
p.append(' (', authorLink, ') ');
404
+
405
+
let did = atURI(this.post.uri).repo;
406
+
407
+
api.fetchHandleForDid(did).then(handle => {
408
+
this.post.author = { did, handle };
409
+
authorLink.href = this.linkToAuthor;
410
+
authorLink.innerText = `@${handle}`;
411
+
});
412
+
365
413
div.appendChild(p);
366
414
div.classList.add('blocked');
367
415
return div;
···
370
418
/** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */
371
419
372
420
async loadBlockedPost(uri, div) {
373
-
let record = await appView.loadPost(this.post.uri);
421
+
let record = await appView.loadPostIfExists(this.post.uri);
422
+
423
+
if (!record) {
424
+
let post = new MissingPost({ uri: this.post.uri });
425
+
let postView = new PostComponent(post, 'quote').buildElement();
426
+
div.replaceWith(postView);
427
+
return;
428
+
}
429
+
374
430
this.post = new Post(record);
375
431
432
+
let userView = await api.getRequest('app.bsky.actor.getProfile', { actor: this.post.author.did });
433
+
434
+
if (!userView.viewer || !(userView.viewer.blockedBy || userView.viewer.blocking)) {
435
+
let { repo, rkey } = atURI(this.post.uri);
436
+
437
+
let a = $tag('a', {
438
+
href: linkToPostById(repo, rkey),
439
+
className: 'action',
440
+
title: "Load thread",
441
+
html: `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i>`
442
+
});
443
+
444
+
let header = div.querySelector('p.blocked-header');
445
+
let separator = $tag('span.separator', { html: '•' });
446
+
header.append(separator, ' ', a);
447
+
}
448
+
376
449
div.querySelector('p.load-post').remove();
377
450
378
451
if (this.isRoot && this.post.parentReference) {
···
392
465
if (this.post.embed) {
393
466
let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
394
467
div.appendChild(embed);
468
+
469
+
// TODO
470
+
Array.from(div.querySelectorAll('a.link-card')).forEach(x => x.remove());
395
471
}
396
472
}
397
473
···
443
519
alert("Sorry, this post is blocked.");
444
520
});
445
521
} else {
446
-
showLogin();
522
+
showDialog(loginDialog);
447
523
}
448
524
return;
449
525
}
···
455
531
this.post.viewerLike = like.uri;
456
532
heart.classList.add('liked');
457
533
count.innerText = String(parseInt(count.innerText, 10) + 1);
458
-
}).catch((error) => {
459
-
console.log(error);
460
-
alert(error);
461
-
});
534
+
}).catch(showError);
462
535
} else {
463
536
accountAPI.removeLike(this.post.viewerLike).then(() => {
464
537
this.post.viewerLike = undefined;
465
538
heart.classList.remove('liked');
466
539
count.innerText = String(parseInt(count.innerText, 10) - 1);
467
-
}).catch((error) => {
468
-
console.log(error);
469
-
alert(error);
470
-
});
540
+
}).catch(showError);
471
541
}
472
542
}
473
543
}
+227
-84
skythread.js
+227
-84
skythread.js
···
4
4
5
5
window.dateLocale = localStorage.getItem('locale') || undefined;
6
6
window.isIncognito = !!localStorage.getItem('incognito');
7
+
window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null');
8
+
9
+
window.loginDialog = document.querySelector('#login');
7
10
8
-
document.addEventListener('click', (e) => {
11
+
html.addEventListener('click', (e) => {
9
12
$id('account_menu').style.visibility = 'hidden';
10
13
});
11
14
···
14
17
submitSearch();
15
18
});
16
19
17
-
document.querySelector('#login').addEventListener('click', (e) => {
18
-
if (e.target === e.currentTarget) {
19
-
hideLogin();
20
-
} else {
21
-
e.stopPropagation();
22
-
}
23
-
});
20
+
for (let dialog of document.querySelectorAll('.dialog')) {
21
+
dialog.addEventListener('click', (e) => {
22
+
if (e.target === e.currentTarget) {
23
+
hideDialog(dialog);
24
+
} else {
25
+
e.stopPropagation();
26
+
}
27
+
});
28
+
29
+
dialog.querySelector('.close')?.addEventListener('click', (e) => {
30
+
hideDialog(dialog);
31
+
});
32
+
}
24
33
25
34
document.querySelector('#login .info a').addEventListener('click', (e) => {
26
35
e.preventDefault();
···
32
41
submitLogin();
33
42
});
34
43
35
-
document.querySelector('#login .close').addEventListener('click', (e) => {
36
-
hideLogin();
44
+
document.querySelector('#biohazard_show').addEventListener('click', (e) => {
45
+
hideDialog(e.target.closest('.dialog'));
46
+
window.biohazardEnabled = true;
47
+
localStorage.setItem('biohazard', 'true');
48
+
49
+
if (window.loadInfohazard) {
50
+
window.loadInfohazard();
51
+
window.loadInfohazard = undefined;
52
+
}
37
53
});
38
54
39
-
document.querySelector('#account').addEventListener('click', (e) => {
40
-
if (accountAPI.isLoggedIn) {
41
-
toggleAccount();
42
-
} else {
43
-
toggleLogin();
55
+
document.querySelector('#biohazard_hide').addEventListener('click', (e) => {
56
+
window.biohazardEnabled = false;
57
+
localStorage.setItem('biohazard', 'false');
58
+
toggleMenuButton('biohazard', false);
59
+
60
+
for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) {
61
+
p.style.display = 'none';
44
62
}
63
+
64
+
hideDialog(e.target.closest('.dialog'));
65
+
});
66
+
67
+
document.querySelector('#account').addEventListener('click', (e) => {
68
+
toggleAccountMenu();
45
69
e.stopPropagation();
46
70
});
47
71
···
49
73
e.stopPropagation();
50
74
});
51
75
76
+
document.querySelector('#account_menu a[data-action=biohazard]').addEventListener('click', (e) => {
77
+
e.preventDefault();
78
+
79
+
let hazards = document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post');
80
+
81
+
if (window.biohazardEnabled === false) {
82
+
window.biohazardEnabled = true;
83
+
localStorage.setItem('biohazard', 'true');
84
+
toggleMenuButton('biohazard', true);
85
+
Array.from(hazards).forEach(p => { p.style.display = 'block' });
86
+
} else {
87
+
window.biohazardEnabled = false;
88
+
localStorage.setItem('biohazard', 'false');
89
+
toggleMenuButton('biohazard', false);
90
+
Array.from(hazards).forEach(p => { p.style.display = 'none' });
91
+
}
92
+
});
93
+
52
94
document.querySelector('#account_menu a[data-action=incognito]').addEventListener('click', (e) => {
53
95
e.preventDefault();
54
96
55
97
if (isIncognito) {
56
-
localStorage.removeItem('incognito');
98
+
localStorage.removeItem('incognito');
57
99
} else {
58
100
localStorage.setItem('incognito', '1');
59
101
}
···
61
103
location.reload();
62
104
});
63
105
106
+
document.querySelector('#account_menu a[data-action=login]').addEventListener('click', (e) => {
107
+
e.preventDefault();
108
+
toggleDialog(loginDialog);
109
+
$id('account_menu').style.visibility = 'hidden';
110
+
});
111
+
64
112
document.querySelector('#account_menu a[data-action=logout]').addEventListener('click', (e) => {
65
113
e.preventDefault();
66
114
logOut();
···
70
118
window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false);
71
119
window.accountAPI = new BlueskyAPI(undefined, true);
72
120
73
-
if (accountAPI.isLoggedIn && !isIncognito) {
74
-
window.api = accountAPI;
75
-
accountAPI.host = accountAPI.user.pdsEndpoint;
76
-
showLoggedInStatus(true, api.user.avatar);
77
-
} else if (accountAPI.isLoggedIn && isIncognito) {
78
-
window.api = appView;
121
+
if (accountAPI.isLoggedIn) {
79
122
accountAPI.host = accountAPI.user.pdsEndpoint;
80
-
showLoggedInStatus('incognito');
81
-
document.querySelector('#account_menu a[data-action=incognito]').innerText = '✓ Incognito mode';
123
+
hideMenuButton('login');
124
+
125
+
if (!isIncognito) {
126
+
window.api = accountAPI;
127
+
showLoggedInStatus(true, api.user.avatar);
128
+
} else {
129
+
window.api = appView;
130
+
showLoggedInStatus('incognito');
131
+
toggleMenuButton('incognito', true);
132
+
}
82
133
} else {
83
134
window.api = appView;
135
+
hideMenuButton('logout');
136
+
hideMenuButton('incognito');
84
137
}
138
+
139
+
toggleMenuButton('biohazard', window.biohazardEnabled !== false);
85
140
86
141
parseQueryParams();
87
142
}
···
102
157
loadHashtagPage(decodeURIComponent(hash));
103
158
} else if (query) {
104
159
showLoader();
105
-
loadThread(decodeURIComponent(query));
160
+
loadThreadByURL(decodeURIComponent(query));
106
161
} else if (author && post) {
107
162
showLoader();
108
-
loadThread(decodeURIComponent(author), decodeURIComponent(post));
163
+
loadThreadById(decodeURIComponent(author), decodeURIComponent(post));
109
164
} else {
110
165
showSearch();
111
166
}
···
117
172
let p = $tag('p.back');
118
173
119
174
if (post instanceof BlockedPost) {
120
-
let element = new PostComponent(post).buildElement('parent');
175
+
let element = new PostComponent(post, 'parent').buildElement();
121
176
element.className = 'back';
122
177
element.querySelector('p.blocked-header span').innerText = 'Parent post blocked';
123
178
return element;
···
148
203
$id('search').style.visibility = 'hidden';
149
204
}
150
205
151
-
function showLogin() {
152
-
$id('login').style.visibility = 'visible';
206
+
function showDialog(dialog) {
207
+
dialog.style.visibility = 'visible';
153
208
$id('thread').classList.add('overlay');
154
-
$id('login_handle').focus();
209
+
210
+
dialog.querySelector('input[type=text]')?.focus();
155
211
}
156
212
157
-
function hideLogin() {
158
-
$id('login').style.visibility = 'hidden';
159
-
$id('login').classList.remove('expanded');
213
+
function hideDialog(dialog) {
214
+
dialog.style.visibility = 'hidden';
215
+
dialog.classList.remove('expanded');
160
216
$id('thread').classList.remove('overlay');
161
-
$id('login_handle').value = '';
162
-
$id('login_password').value = '';
217
+
218
+
for (let field of dialog.querySelectorAll('input[type=text]')) {
219
+
field.value = '';
220
+
}
163
221
}
164
222
165
-
function toggleLogin() {
166
-
if ($id('login').style.visibility == 'visible') {
167
-
hideLogin();
223
+
function toggleDialog(dialog) {
224
+
if (dialog.style.visibility == 'visible') {
225
+
hideDialog(dialog);
168
226
} else {
169
-
showLogin();
227
+
showDialog(dialog);
170
228
}
171
229
}
172
230
···
174
232
$id('login').classList.toggle('expanded');
175
233
}
176
234
177
-
function toggleAccount() {
235
+
function toggleAccountMenu() {
178
236
let menu = $id('account_menu');
179
237
menu.style.visibility = (menu.style.visibility == 'visible') ? 'hidden' : 'visible';
180
238
}
181
239
240
+
/** @param {string} buttonName */
241
+
242
+
function showMenuButton(buttonName) {
243
+
let button = document.querySelector(`#account_menu a[data-action=${buttonName}]`);
244
+
button.parentNode.style.display = 'list-item';
245
+
}
246
+
247
+
/** @param {string} buttonName */
248
+
249
+
function hideMenuButton(buttonName) {
250
+
let button = document.querySelector(`#account_menu a[data-action=${buttonName}]`);
251
+
button.parentNode.style.display = 'none';
252
+
}
253
+
254
+
/** @param {string} buttonName, @param {boolean} state */
255
+
256
+
function toggleMenuButton(buttonName, state) {
257
+
let button = document.querySelector(`#account_menu a[data-action=${buttonName}]`);
258
+
button.querySelector('.check').style.display = (state) ? 'inline' : 'none';
259
+
}
260
+
182
261
/** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */
183
262
184
263
function showLoggedInStatus(loggedIn, avatar) {
···
225
304
window.api = pds;
226
305
window.accountAPI = pds;
227
306
228
-
hideLogin();
307
+
hideDialog(loginDialog);
229
308
submit.style.display = 'inline';
230
309
cloudy.style.display = 'none';
231
310
232
311
loadCurrentUserAvatar();
312
+
showMenuButton('logout');
313
+
showMenuButton('incognito');
314
+
hideMenuButton('login');
233
315
})
234
316
.catch((error) => {
235
317
submit.style.display = 'inline';
···
273
355
274
356
function logOut() {
275
357
accountAPI.resetTokens();
358
+
localStorage.removeItem('incognito');
276
359
location.reload();
277
360
}
278
361
···
281
364
282
365
if (!url) { return }
283
366
367
+
if (url.startsWith('at://')) {
368
+
let target = new URL(getLocation());
369
+
target.searchParams.set('q', url);
370
+
location.assign(target.toString());
371
+
return;
372
+
}
373
+
284
374
if (url.match(/^#?((\p{Letter}|\p{Number})+)$/u)) {
285
375
let target = new URL(getLocation());
286
376
target.searchParams.set('hash', encodeURIComponent(url.replace(/^#/, '')));
···
337
427
}
338
428
339
429
for (let post of posts) {
340
-
let postView = new PostComponent(post).buildElement('feed');
430
+
let postView = new PostComponent(post, 'feed').buildElement();
341
431
$id('thread').appendChild(postView);
342
432
}
343
433
···
392
482
}
393
483
394
484
for (let post of posts) {
395
-
let postView = new PostComponent(post).buildElement('quotes');
485
+
let postView = new PostComponent(post, 'quotes').buildElement();
396
486
$id('thread').appendChild(postView);
397
487
}
398
488
···
428
518
});
429
519
}
430
520
431
-
/** @param {string} url, @param {string} [postId], @param {AnyElement} [nodeToUpdate] */
521
+
/** @param {string} url */
432
522
433
-
function loadThread(url, postId, nodeToUpdate) {
434
-
let load = postId ? api.loadThreadById(url, postId) : api.loadThreadByURL(url);
523
+
function loadThreadByURL(url) {
524
+
let loadThread = url.startsWith('at://') ? api.loadThreadByAtURI(url) : api.loadThreadByURL(url);
435
525
436
-
load.then(json => {
437
-
let root = Post.parseThreadPost(json.thread);
438
-
window.root = root;
526
+
loadThread.then(json => {
527
+
displayThread(json);
528
+
}).catch(error => {
529
+
hideLoader();
530
+
showError(error);
531
+
});
532
+
}
439
533
440
-
let loadQuoteCount;
534
+
/** @param {string} author, @param {string} rkey */
441
535
442
-
if (!nodeToUpdate && root instanceof Post) {
443
-
setPageTitle(root);
444
-
loadQuoteCount = blueAPI.getQuoteCount(root.uri);
536
+
function loadThreadById(author, rkey) {
537
+
api.loadThreadById(author, rkey).then(json => {
538
+
displayThread(json);
539
+
}).catch(error => {
540
+
hideLoader();
541
+
showError(error);
542
+
});
543
+
}
544
+
545
+
/** @param {json} json */
546
+
547
+
function displayThread(json) {
548
+
let root = Post.parseThreadPost(json.thread);
549
+
window.root = root;
550
+
window.subtreeRoot = root;
551
+
552
+
let loadQuoteCount;
553
+
554
+
if (root instanceof Post) {
555
+
setPageTitle(root);
556
+
loadQuoteCount = blueAPI.getQuoteCount(root.uri);
445
557
446
-
if (root.parent) {
447
-
let p = buildParentLink(root.parent);
448
-
$id('thread').appendChild(p);
449
-
}
558
+
if (root.parent) {
559
+
let p = buildParentLink(root.parent);
560
+
$id('thread').appendChild(p);
450
561
}
562
+
}
451
563
452
-
let component = new PostComponent(root);
453
-
let list = component.buildElement('thread');
454
-
hideLoader();
564
+
let component = new PostComponent(root, 'thread');
565
+
let view = component.buildElement();
566
+
hideLoader();
567
+
$id('thread').appendChild(view);
455
568
456
-
if (nodeToUpdate) {
457
-
nodeToUpdate.querySelector('.content').replaceWith(list.querySelector('.content'));
458
-
} else {
459
-
$id('thread').appendChild(list);
569
+
loadQuoteCount?.then(count => {
570
+
if (count > 0) {
571
+
let stats = view.querySelector(':scope > .content > p.stats');
572
+
let q = new URL(getLocation());
573
+
q.searchParams.set('quotes', component.linkToPost);
574
+
stats.append($tag('i', { className: count > 1 ? 'fa-regular fa-comments' : 'fa-regular fa-comment' }));
575
+
stats.append(" ");
576
+
let quotes = $tag('a', {
577
+
text: count > 1 ? `${count} quotes` : '1 quote',
578
+
href: q.toString()
579
+
});
580
+
stats.append(quotes);
460
581
}
582
+
}).catch(error => {
583
+
console.warn("Couldn't load quote count: " + error);
584
+
});
585
+
}
461
586
462
-
loadQuoteCount?.then(count => {
463
-
if (count > 0) {
464
-
let stats = list.querySelector(':scope > .content > p.stats');
465
-
let q = new URL(getLocation());
466
-
q.searchParams.set('quotes', component.linkToPost);
467
-
stats.append($tag('i', { className: count > 1 ? 'fa-regular fa-comments' : 'fa-regular fa-comment' }));
468
-
stats.append(" ");
469
-
let quotes = $tag('a', {
470
-
html: count > 1 ? `${count} quotes` : '1 quote',
471
-
href: q.toString()
472
-
});
473
-
stats.append(quotes);
587
+
/** @param {Post} post, @param {AnyElement} nodeToUpdate */
588
+
589
+
function loadSubtree(post, nodeToUpdate) {
590
+
api.loadThreadByAtURI(post.uri).then(json => {
591
+
let root = Post.parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel);
592
+
post.updateDataFromPost(root);
593
+
window.subtreeRoot = post;
594
+
595
+
let component = new PostComponent(post, 'thread');
596
+
let view = component.buildElement();
597
+
598
+
nodeToUpdate.querySelector('.content').replaceWith(view.querySelector('.content'));
599
+
}).catch(showError);
600
+
}
601
+
602
+
/** @param {Post} post, @param {AnyElement} nodeToUpdate */
603
+
604
+
function loadHiddenSubtree(post, nodeToUpdate) {
605
+
blueAPI.getReplies(post.uri).then(replies => {
606
+
let missingReplies = replies.filter(r => !post.replies.some(x => x.uri === r));
607
+
608
+
Promise.allSettled(missingReplies.map(uri => api.loadThreadByAtURI(uri))).then(responses => {
609
+
let replies = responses
610
+
.map(r => r.status == 'fulfilled' ? r.value : undefined)
611
+
.filter(v => v)
612
+
.map(json => Post.parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1));
613
+
614
+
post.setReplies(replies);
615
+
616
+
let content = nodeToUpdate.querySelector('.content');
617
+
content.querySelector(':scope > .hidden-replies').remove();
618
+
619
+
for (let reply of post.replies) {
620
+
let component = new PostComponent(reply, 'thread');
621
+
let view = component.buildElement();
622
+
content.append(view);
474
623
}
475
-
}).catch(error => {
476
-
console.warn("Couldn't load quote count: " + error);
477
-
});
478
-
}).catch(error => {
479
-
hideLoader();
480
-
console.log(error);
481
-
alert(error);
482
-
});
624
+
}).catch(showError);
625
+
}).catch(showError);
483
626
}
+55
-16
style.css
+55
-16
style.css
···
143
143
text-decoration: none;
144
144
}
145
145
146
-
#login {
146
+
#account_menu li .check {
147
+
display: none;
148
+
}
149
+
150
+
.dialog {
147
151
visibility: hidden;
148
152
position: fixed;
149
153
top: 0;
···
158
162
background-color: rgba(240, 240, 240, 0.4);
159
163
}
160
164
161
-
#login.expanded {
165
+
.dialog.expanded {
162
166
padding-bottom: 0;
163
167
}
164
168
165
-
#login form {
169
+
.dialog form {
166
170
position: relative;
167
171
border: 2px solid hsl(210, 100%, 85%);
168
172
background-color: hsl(210, 100%, 98%);
···
170
174
padding: 15px 25px;
171
175
}
172
176
173
-
#login .close {
177
+
.dialog .close {
174
178
position: absolute;
175
179
top: 5px;
176
180
right: 5px;
···
178
182
opacity: 0.6;
179
183
}
180
184
181
-
#login .close:hover {
185
+
.dialog .close:hover {
182
186
color: hsl(210, 100%, 65%);
183
187
opacity: 1.0;
184
188
}
185
189
186
-
#login p {
190
+
.dialog p {
187
191
text-align: center;
192
+
line-height: 125%;
188
193
}
189
194
190
-
#login h2 {
195
+
.dialog h2 {
191
196
font-size: 13pt;
192
197
font-weight: 600;
193
198
text-align: center;
···
195
200
padding-right: 10px;
196
201
}
197
202
198
-
#login p.submit {
203
+
.dialog p.submit {
199
204
margin-top: 25px;
200
205
}
201
206
202
-
#login p.info {
207
+
.dialog p.info {
203
208
font-size: 9pt;
204
209
}
205
210
206
-
#login p.info a {
211
+
.dialog p.info a {
207
212
color: #666;
208
213
}
209
214
210
-
#login input[type="text"], #login input[type="password"] {
215
+
.dialog input[type="text"], .dialog input[type="password"] {
211
216
width: 200px;
212
217
font-size: 11pt;
213
218
border: 1px solid #d6d6d6;
···
216
221
margin: 0px 15px;
217
222
}
218
223
219
-
#login input[type="submit"] {
224
+
.dialog input[type="submit"] {
220
225
width: 150px;
221
226
font-size: 11pt;
222
227
border: 1px solid hsl(210, 90%, 85%);
···
225
230
padding: 5px 6px;
226
231
}
227
232
228
-
#login input[type="submit"]:active {
233
+
.dialog input[type="submit"]:hover {
229
234
background-color: hsl(210, 100%, 90%);
235
+
border: 1px solid hsl(210, 90%, 82%);
236
+
}
237
+
238
+
.dialog input[type="submit"]:active {
239
+
background-color: hsl(210, 100%, 87%);
240
+
border: 1px solid hsl(210, 90%, 80%);
230
241
}
231
242
232
243
#login #cloudy {
···
250
261
251
262
#login .info-box p {
252
263
margin: 15px 15px;
253
-
line-height: 125%;
254
264
text-align: left;
265
+
}
266
+
267
+
#biohazard_dialog form {
268
+
width: 400px;
269
+
}
270
+
271
+
#biohazard_dialog p.submit {
272
+
margin-top: 40px;
273
+
margin-bottom: 20px;
274
+
}
275
+
276
+
#biohazard_dialog input[type="submit"] {
277
+
width: 180px;
278
+
margin-left: 5px;
279
+
margin-right: 5px;
255
280
}
256
281
257
282
#loader {
···
404
429
vertical-align: text-top;
405
430
}
406
431
407
-
.post h2 .separator {
432
+
.post h2 .separator, .post .blocked-header .separator, .blocked-header .separator {
408
433
color: #888;
409
434
font-weight: normal;
410
435
font-size: 11pt;
···
418
443
vertical-align: text-top;
419
444
}
420
445
421
-
.post h2 .action {
446
+
.post h2 .action, .post .blocked-header .action, .blocked-header .action {
422
447
color: #888;
423
448
font-weight: normal;
424
449
font-size: 10pt;
425
450
vertical-align: text-top;
451
+
}
452
+
453
+
.post h2 .action:hover, .post .blocked-header .action:hover, .blocked-header .action:hover {
454
+
color: #444;
426
455
}
427
456
428
457
.post h2 img.mastodon {
···
630
659
width: 24px;
631
660
animation: rotation 3s infinite linear;
632
661
margin-top: 5px;
662
+
}
663
+
664
+
.post p.hidden-replies {
665
+
margin-top: 20px;
666
+
font-size: 11pt;
667
+
}
668
+
669
+
.post p.hidden-replies a {
670
+
font-size: 12pt;
671
+
color: saddlebrown;
633
672
}
634
673
635
674
@media (prefers-color-scheme: dark) {
+4
types.d.ts
+4
types.d.ts
···
1
1
interface Window {
2
2
dateLocale: string | undefined;
3
3
root: AnyPost;
4
+
subtreeRoot: AnyPost;
5
+
loadInfohazard: (() => void) | undefined;
4
6
}
5
7
6
8
declare var accountAPI: BlueskyAPI;
···
8
10
declare var appView: BlueskyAPI;
9
11
declare var api: BlueskyAPI;
10
12
declare var isIncognito: boolean;
13
+
declare var biohazardEnabled: boolean;
14
+
declare var loginDialog: AnyElement;
11
15
12
16
type SomeElement = Element | HTMLElement | AnyElement;
13
17
type json = Record<string, any>;
+7
utils.js
+7
utils.js
···
97
97
return location.origin + location.pathname;
98
98
}
99
99
100
+
/** @param {object} error */
101
+
102
+
function showError(error) {
103
+
console.log(error);
104
+
alert(error);
105
+
}
106
+
100
107
/** @param {Date} date1, @param {Date} date2, @returns {boolean} */
101
108
102
109
function sameDay(date1, date2) {