Thread viewer for Bluesky

Merge branch 'biohazard'

+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
··· 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
··· 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>&ldquo;<em>This thread is not a place of honor... no highly esteemed post is commemorated here... nothing valued is here.</em>&rdquo;</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
··· 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
··· 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">&bull;</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">&bull;</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: '&bull;' }); 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
··· 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
··· 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
··· 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
··· 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) {