Thread viewer for Bluesky

Compare changes

Choose any two refs to compare.

+119 -17
api.js
··· 130 throw new URLError(`${error}`); 131 } 132 133 - if (url.protocol != 'https:') { 134 - throw new URLError('URL must start with https://'); 135 } 136 137 let parts = url.pathname.split('/'); ··· 199 } 200 } 201 202 /** @returns {Promise<json | undefined>} */ 203 204 async getCurrentUserAvatar() { ··· 230 } 231 } 232 233 - /** @param {string} uri, @returns {Promise<json[]>} */ 234 235 async getReplies(uri) { 236 let json = await this.getRequest('blue.feeds.post.getReplies', { uri }); ··· 278 return await this.getRequest('app.bsky.feed.searchPosts', params); 279 } 280 281 - async loadNotifications(cursor) { 282 - let params = { limit: 100 }; 283 - 284 - if (cursor) { 285 - params.cursor = cursor; 286 - } 287 288 - return await this.getRequest('app.bsky.notification.listNotifications', params); 289 } 290 291 async loadMentions(cursor) { 292 - let response = await this.loadNotifications(cursor); 293 - let mentions = response.notifications.filter(x => ['reply', 'mention'].includes(x.reason)); 294 - let uris = mentions.map(x => x['uri']); 295 - let posts = []; 296 297 for (let i = 0; i < uris.length; i += 25) { 298 - let batch = await this.loadPosts(uris.slice(i, i + 25)); 299 - posts = posts.concat(batch); 300 } 301 302 - return { cursor: response.cursor, posts }; 303 } 304 305 /** @param {string} postURI, @returns {Promise<json>} */
··· 130 throw new URLError(`${error}`); 131 } 132 133 + if (url.protocol != 'https:' && url.protocol != 'http:') { 134 + throw new URLError('URL must start with http(s)://'); 135 } 136 137 let parts = url.pathname.split('/'); ··· 199 } 200 } 201 202 + /** @param {string} query, @returns {Promise<json[]>} */ 203 + 204 + async autocompleteUsers(query) { 205 + let json = await this.getRequest('app.bsky.actor.searchActorsTypeahead', { q: query }); 206 + return json.actors; 207 + } 208 + 209 /** @returns {Promise<json | undefined>} */ 210 211 async getCurrentUserAvatar() { ··· 237 } 238 } 239 240 + /** @param {string} uri, @returns {Promise<string[]>} */ 241 242 async getReplies(uri) { 243 let json = await this.getRequest('blue.feeds.post.getReplies', { uri }); ··· 285 return await this.getRequest('app.bsky.feed.searchPosts', params); 286 } 287 288 + /** @param {json} [params], @returns {Promise<json>} */ 289 290 + async loadNotifications(params) { 291 + return await this.getRequest('app.bsky.notification.listNotifications', params || {}); 292 } 293 294 + /** 295 + * @param {string} [cursor] 296 + * @returns {Promise<{ cursor: string | undefined, posts: json[] }>} 297 + */ 298 + 299 async loadMentions(cursor) { 300 + let response = await this.loadNotifications({ cursor: cursor ?? '', limit: 100, reasons: ['reply', 'mention'] }); 301 + let uris = response.notifications.map(x => x.uri); 302 + let batches = []; 303 304 for (let i = 0; i < uris.length; i += 25) { 305 + let batch = this.loadPosts(uris.slice(i, i + 25)); 306 + batches.push(batch); 307 } 308 309 + let postGroups = await Promise.all(batches); 310 + 311 + return { cursor: response.cursor, posts: postGroups.flat() }; 312 + } 313 + 314 + /** 315 + * @param {number} days 316 + * @param {{ onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} [options] 317 + * @returns {Promise<json[]>} 318 + */ 319 + 320 + async loadHomeTimeline(days, options = {}) { 321 + let now = new Date(); 322 + let timeLimit = now.getTime() - days * 86400 * 1000; 323 + 324 + return await this.fetchAll('app.bsky.feed.getTimeline', { 325 + params: { limit: 100 }, 326 + field: 'feed', 327 + breakWhen: (x) => (feedPostTime(x) < timeLimit), 328 + onPageLoad: options.onPageLoad, 329 + keepLastPage: options.keepLastPage 330 + }); 331 + } 332 + 333 + /** 334 + @typedef 335 + {'posts_with_replies' | 'posts_no_replies' | 'posts_and_author_threads' | 'posts_with_media' | 'posts_with_video'} 336 + AuthorFeedFilter 337 + 338 + Filters: 339 + - posts_with_replies: posts, replies and reposts (default) 340 + - posts_no_replies: posts and reposts (no replies) 341 + - posts_and_author_threads: posts, reposts, and replies in your own threads 342 + - posts_with_media: posts and replies, but only with images (no reposts) 343 + - posts_with_video: posts and replies, but only with videos (no reposts) 344 + */ 345 + 346 + /** 347 + * @param {string} did 348 + * @param {number} days 349 + * @param {{ filter: AuthorFeedFilter, onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} options 350 + * @returns {Promise<json[]>} 351 + */ 352 + 353 + async loadUserTimeline(did, days, options) { 354 + let now = new Date(); 355 + let timeLimit = now.getTime() - days * 86400 * 1000; 356 + 357 + return await this.fetchAll('app.bsky.feed.getAuthorFeed', { 358 + params: { 359 + actor: did, 360 + filter: options.filter, 361 + limit: 100 362 + }, 363 + field: 'feed', 364 + breakWhen: (x) => (feedPostTime(x) < timeLimit), 365 + onPageLoad: options.onPageLoad, 366 + keepLastPage: options.keepLastPage 367 + }); 368 + } 369 + 370 + /** @returns {Promise<json[]>} */ 371 + 372 + async loadUserLists() { 373 + let lists = await this.fetchAll('app.bsky.graph.getLists', { 374 + params: { 375 + actor: this.user.did, 376 + limit: 100 377 + }, 378 + field: 'lists' 379 + }); 380 + 381 + return lists.filter(x => x.purpose == "app.bsky.graph.defs#curatelist"); 382 + } 383 + 384 + /** 385 + * @param {string} list 386 + * @param {number} days 387 + * @param {{ onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} [options] 388 + * @returns {Promise<json[]>} 389 + */ 390 + 391 + async loadListTimeline(list, days, options = {}) { 392 + let now = new Date(); 393 + let timeLimit = now.getTime() - days * 86400 * 1000; 394 + 395 + return await this.fetchAll('app.bsky.feed.getListFeed', { 396 + params: { 397 + list: list, 398 + limit: 100 399 + }, 400 + field: 'feed', 401 + breakWhen: (x) => (feedPostTime(x) < timeLimit), 402 + onPageLoad: options.onPageLoad, 403 + keepLastPage: options.keepLastPage 404 + }); 405 } 406 407 /** @param {string} postURI, @returns {Promise<json>} */
+14
async_lint.sh
···
··· 1 + #!/bin/bash 2 + 3 + scan() { 4 + local identifier=$1 5 + grep "\b$identifier(" *.js | grep -Ev "await |async |return |\.then\(|\.map" 6 + } 7 + 8 + for name in $(grep -oE "async \w+\(" *.js | grep -oE "\w+\(" | sed -e "s/(//"); do 9 + scan $name 10 + done 11 + 12 + for name in $(grep -oE "async function \w+\(" *.js | grep -oE "\w+\(" | sed -e "s/(//"); do 13 + scan $name 14 + done
+61 -14
embed_component.js
··· 10 this.embed = embed; 11 } 12 13 - /** @returns {AnyElement} */ 14 15 buildElement() { 16 if (this.embed instanceof RawRecordEmbed) { ··· 54 } 55 } 56 57 - /** @returns {AnyElement} */ 58 59 quotedPostPlaceholder() { 60 return $tag('div.quote-embed', { ··· 62 }); 63 } 64 65 - /** @param {InlineRecordEmbed | InlineRecordWithMediaEmbed} embed, @returns {AnyElement} */ 66 67 buildQuotedPostElement(embed) { 68 let div = $tag('div.quote-embed'); ··· 88 return div; 89 } 90 91 - /** @params {RawLinkEmbed | InlineLinkEmbed} embed, @returns {AnyElement} */ 92 93 buildLinkComponent(embed) { 94 let hostname; ··· 125 126 a.append(box); 127 128 return a; 129 } 130 131 - /** @param {FeedGeneratorRecord} feedgen, @returns {AnyElement} */ 132 133 buildFeedGeneratorView(feedgen) { 134 let link = this.linkToFeedGenerator(feedgen); ··· 137 let box = $tag('div'); 138 139 if (feedgen.avatar) { 140 - let avatar = $tag('img.avatar'); 141 avatar.src = feedgen.avatar; 142 box.append(avatar); 143 } ··· 167 return `https://bsky.app/profile/${repo}/feed/${rkey}`; 168 } 169 170 - /** @param {UserListRecord} list, @returns {AnyElement} */ 171 172 buildUserListView(list) { 173 let link = this.linkToUserList(list); ··· 176 let box = $tag('div'); 177 178 if (list.avatar) { 179 - let avatar = $tag('img.avatar'); 180 avatar.src = list.avatar; 181 box.append(avatar); 182 } ··· 207 return a; 208 } 209 210 - /** @param {StarterPackRecord} pack, @returns {AnyElement} */ 211 212 buildStarterPackView(pack) { 213 let { repo, rkey } = atURI(pack.uri); ··· 236 return `https://bsky.app/profile/${repo}/lists/${rkey}`; 237 } 238 239 - /** @params {RawImageEmbed | InlineImageEmbed} embed, @returns {AnyElement} */ 240 241 buildImagesComponent(embed) { 242 let wrapper = $tag('div'); ··· 246 p.append('['); 247 248 // TODO: load image 249 - let a = $tag('a', { text: "Image" }); 250 251 if (image.fullsize) { 252 a.href = image.fullsize; ··· 272 return wrapper; 273 } 274 275 - /** @params {RawVideoEmbed | InlineVideoEmbed} embed, @returns {AnyElement} */ 276 277 buildVideoComponent(embed) { 278 let wrapper = $tag('div'); 279 280 // TODO: load thumbnail 281 - let a = $tag('a', { text: "Video" }); 282 283 if (embed.playlistURL) { 284 a.href = embed.playlistURL; ··· 303 return wrapper; 304 } 305 306 - /** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */ 307 308 async loadQuotedPost(uri, div) { 309 let record = await api.loadPostIfExists(uri);
··· 10 this.embed = embed; 11 } 12 13 + /** @returns {HTMLElement} */ 14 15 buildElement() { 16 if (this.embed instanceof RawRecordEmbed) { ··· 54 } 55 } 56 57 + /** @returns {HTMLElement} */ 58 59 quotedPostPlaceholder() { 60 return $tag('div.quote-embed', { ··· 62 }); 63 } 64 65 + /** @param {InlineRecordEmbed | InlineRecordWithMediaEmbed} embed, @returns {HTMLElement} */ 66 67 buildQuotedPostElement(embed) { 68 let div = $tag('div.quote-embed'); ··· 88 return div; 89 } 90 91 + /** @params {RawLinkEmbed | InlineLinkEmbed} embed, @returns {HTMLElement} */ 92 93 buildLinkComponent(embed) { 94 let hostname; ··· 125 126 a.append(box); 127 128 + if (hostname == 'media.tenor.com') { 129 + a.addEventListener('click', (e) => { 130 + e.preventDefault(); 131 + this.displayGIFInline(a, embed); 132 + }); 133 + } 134 + 135 return a; 136 } 137 138 + /** @param {HTMLElement} a, @param {RawLinkEmbed | InlineLinkEmbed} embed */ 139 + 140 + displayGIFInline(a, embed) { 141 + let gifDiv = $tag('div.gif'); 142 + let img = $tag('img', { src: embed.url }, HTMLImageElement); 143 + img.style.opacity = '0'; 144 + img.style.maxHeight = '200px'; 145 + gifDiv.append(img); 146 + a.replaceWith(gifDiv); 147 + 148 + img.addEventListener('load', (e) => { 149 + if (img.naturalWidth > img.naturalHeight) { 150 + img.style.maxHeight = '200px'; 151 + } else { 152 + img.style.maxWidth = '200px'; 153 + img.style.maxHeight = '400px'; 154 + } 155 + 156 + img.style.opacity = ''; 157 + }); 158 + 159 + let staticPic; 160 + 161 + if (typeof embed.thumb == 'string') { 162 + staticPic = embed.thumb; 163 + } else { 164 + staticPic = `https://cdn.bsky.app/img/avatar/plain/${this.post.author.did}/${embed.thumb.ref.$link}@jpeg`; 165 + } 166 + 167 + img.addEventListener('click', (e) => { 168 + if (img.classList.contains('static')) { 169 + img.src = embed.url; 170 + img.classList.remove('static'); 171 + } else { 172 + img.src = staticPic; 173 + img.classList.add('static'); 174 + } 175 + }); 176 + } 177 + 178 + /** @param {FeedGeneratorRecord} feedgen, @returns {HTMLElement} */ 179 180 buildFeedGeneratorView(feedgen) { 181 let link = this.linkToFeedGenerator(feedgen); ··· 184 let box = $tag('div'); 185 186 if (feedgen.avatar) { 187 + let avatar = $tag('img.avatar', HTMLImageElement); 188 avatar.src = feedgen.avatar; 189 box.append(avatar); 190 } ··· 214 return `https://bsky.app/profile/${repo}/feed/${rkey}`; 215 } 216 217 + /** @param {UserListRecord} list, @returns {HTMLElement} */ 218 219 buildUserListView(list) { 220 let link = this.linkToUserList(list); ··· 223 let box = $tag('div'); 224 225 if (list.avatar) { 226 + let avatar = $tag('img.avatar', HTMLImageElement); 227 avatar.src = list.avatar; 228 box.append(avatar); 229 } ··· 254 return a; 255 } 256 257 + /** @param {StarterPackRecord} pack, @returns {HTMLElement} */ 258 259 buildStarterPackView(pack) { 260 let { repo, rkey } = atURI(pack.uri); ··· 283 return `https://bsky.app/profile/${repo}/lists/${rkey}`; 284 } 285 286 + /** @params {RawImageEmbed | InlineImageEmbed} embed, @returns {HTMLElement} */ 287 288 buildImagesComponent(embed) { 289 let wrapper = $tag('div'); ··· 293 p.append('['); 294 295 // TODO: load image 296 + let a = $tag('a', { text: "Image" }, HTMLLinkElement); 297 298 if (image.fullsize) { 299 a.href = image.fullsize; ··· 319 return wrapper; 320 } 321 322 + /** @params {RawVideoEmbed | InlineVideoEmbed} embed, @returns {HTMLElement} */ 323 324 buildVideoComponent(embed) { 325 let wrapper = $tag('div'); 326 327 // TODO: load thumbnail 328 + let a = $tag('a', { text: "Video" }, HTMLLinkElement); 329 330 if (embed.playlistURL) { 331 a.href = embed.playlistURL; ··· 350 return wrapper; 351 } 352 353 + /** @param {string} uri, @param {HTMLElement} div, @returns Promise<void> */ 354 355 async loadQuotedPost(uri, div) { 356 let record = await api.loadPostIfExists(uri);
+147 -1
index.html
··· 10 font-src 'self'; 11 script-src-attr 'none'; 12 style-src-attr 'none'; 13 - connect-src https:; 14 base-uri 'none'; 15 form-action 'none';"> 16 ··· 49 50 <li><a href="#" data-action="login">Log in</a></li> 51 <li><a href="#" data-action="logout">Log out</a></li> 52 </ul> 53 </div> 54 ··· 88 </form> 89 </div> 90 91 <script src="lib/purify.min.js"></script> 92 <script src="minisky.js"></script> 93 <script src="api.js"></script> 94 <script src="utils.js"></script> 95 <script src="rich_text_lite.js"></script> 96 <script src="models.js"></script> 97 <script src="embed_component.js"></script> 98 <script src="post_component.js"></script> 99 <script src="skythread.js"></script>
··· 10 font-src 'self'; 11 script-src-attr 'none'; 12 style-src-attr 'none'; 13 + connect-src https: http://localhost:3000; 14 base-uri 'none'; 15 form-action 'none';"> 16 ··· 49 50 <li><a href="#" data-action="login">Log in</a></li> 51 <li><a href="#" data-action="logout">Log out</a></li> 52 + 53 + <li class="link"><a href="?">Home</a></li> 54 + <li class="link"><a href="?page=posting_stats">Posting stats</a></li> 55 + <li class="link"><a href="?page=like_stats">Like stats</a></li> 56 + <li class="link"><a href="?page=search">Timeline search</a></li> 57 + <li class="link"><a href="?page=search&mode=likes">Archive search</a></li> 58 </ul> 59 </div> 60 ··· 94 </form> 95 </div> 96 97 + <div id="posting_stats_page"> 98 + <h2>Bluesky posting statistics</h2> 99 + 100 + <form> 101 + <p> 102 + Scan posts from: 103 + <input type="radio" name="scan_type" id="scan_type_timeline" value="home" checked> 104 + <label for="scan_type_timeline">Home timeline</label> 105 + 106 + <input type="radio" name="scan_type" id="scan_type_list" value="list"> 107 + <label for="scan_type_list">List feed</label> 108 + 109 + <input type="radio" name="scan_type" id="scan_type_users" value="users"> 110 + <label for="scan_type_users">Selected users</label> 111 + 112 + <input type="radio" name="scan_type" id="scan_type_you" value="you"> 113 + <label for="scan_type_you">Your profile</label> 114 + </p> 115 + 116 + <p> 117 + Time range: <input type="range" min="1" max="60" value="7"> <label>7 days</label> 118 + </p> 119 + 120 + <p class="list-choice"> 121 + <label>Select list:</label> 122 + <select name="scan_list"></select> 123 + </p> 124 + 125 + <div class="user-choice"> 126 + <input type="text" placeholder="Add user" autocomplete="off"> 127 + <div class="autocomplete"></div> 128 + <div class="selected-users"></div> 129 + </div> 130 + 131 + <p> 132 + <input type="submit" value="Start scan"> <progress></progress> 133 + </p> 134 + </form> 135 + 136 + <p class="scan-info"></p> 137 + 138 + <table class="scan-result"> 139 + <thead></thead> 140 + <tbody></tbody> 141 + </table> 142 + </div> 143 + 144 + <div id="like_stats_page"> 145 + <h2>Like statistics</h2> 146 + 147 + <form> 148 + <p> 149 + Time range: <input type="range" min="1" max="60" value="7"> <label>7 days</label> 150 + </p> 151 + 152 + <p> 153 + <input type="submit" value="Start scan"> <progress></progress> 154 + </p> 155 + </form> 156 + 157 + <table class="scan-result given-likes"> 158 + <thead> 159 + <tr><th colspan="3">โค๏ธ Likes from you:</th></tr> 160 + </thead> 161 + <tbody></tbody> 162 + </table> 163 + 164 + <table class="scan-result received-likes"> 165 + <thead> 166 + <tr><th colspan="3">๐Ÿ’› Likes on your posts:</th></tr> 167 + </thead> 168 + <tbody></tbody> 169 + </table> 170 + </div> 171 + 172 + <div id="private_search_page"> 173 + <h2>Archive search</h2> 174 + 175 + <div class="timeline-search"> 176 + <form> 177 + <p> 178 + Fetch timeline posts: <input type="range" min="1" max="60" value="7"> <label>7 days</label> 179 + </p> 180 + 181 + <p> 182 + <input type="submit" value="Fetch timeline"> <progress></progress> 183 + </p> 184 + </form> 185 + 186 + <p class="archive-status"></p> 187 + 188 + <hr> 189 + </div> 190 + 191 + <form class="search-form"> 192 + <p class="search">Search: <input type="text" class="search-query" autocomplete="off"></p> 193 + 194 + <div class="search-collections"> 195 + <input type="radio" name="collection" value="likes" id="collection-likes" checked> <label for="collection-likes">Likes</label> 196 + <input type="radio" name="collection" value="reposts" id="collection-reposts"> <label for="collection-reposts">Reposts</label> 197 + <input type="radio" name="collection" value="quotes" id="collection-quotes"> <label for="collection-quotes">Quotes</label> 198 + <input type="radio" name="collection" value="pins" id="collection-pins"> <label for="collection-pins">Pins</label> 199 + </div> 200 + </form> 201 + 202 + <div class="lycan-import"> 203 + <form> 204 + <h4>Data not imported yet</h4> 205 + 206 + <p> 207 + In order to search within your likes and bookmarks, the posts you've liked or saved need to be imported into a database. 208 + This is a one-time process, but it can take several minutes or more, depending on the age of your account. 209 + </p> 210 + <p> 211 + To start the import, press the button below. You can then wait until it finishes, or close this tab and come back a bit later. 212 + After the import is complete, the database will be kept up to date automatically going forward. 213 + </p> 214 + <p> 215 + <input type="submit" value="Start import"> 216 + </p> 217 + </form> 218 + 219 + <div class="import-progress"> 220 + <h4>Import in progress</h4> 221 + 222 + <p class="import-status"></p> 223 + <p><progress></progress> <output></output></p> 224 + </div> 225 + </div> 226 + 227 + <div class="results"> 228 + </div> 229 + </div> 230 + 231 <script src="lib/purify.min.js"></script> 232 <script src="minisky.js"></script> 233 <script src="api.js"></script> 234 <script src="utils.js"></script> 235 <script src="rich_text_lite.js"></script> 236 <script src="models.js"></script> 237 + <script src="menu.js"></script> 238 + <script src="thread_page.js"></script> 239 + <script src="posting_stats_page.js"></script> 240 + <script src="like_stats_page.js"></script> 241 + <script src="notifications_page.js"></script> 242 + <script src="private_search_page.js"></script> 243 <script src="embed_component.js"></script> 244 <script src="post_component.js"></script> 245 <script src="skythread.js"></script>
+1 -1
jsconfig.json
··· 7 "exactOptionalPropertyTypes": true, 8 "useUnknownInCatchVariables": false 9 }, 10 - "include": ["*.js", "*.d.ts", "lib/**/*.d.ts"] 11 }
··· 7 "exactOptionalPropertyTypes": true, 8 "useUnknownInCatchVariables": false 9 }, 10 + "include": ["*.js", "*.d.ts", "lib/**/*.d.ts", "test/**/*.js"] 11 }
+289
like_stats_page.js
···
··· 1 + class LikeStatsPage { 2 + 3 + /** @type {number | undefined} */ 4 + scanStartTime; 5 + 6 + constructor() { 7 + this.pageElement = $id('like_stats_page'); 8 + 9 + this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement); 10 + this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement); 11 + this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement); 12 + 13 + this.receivedTable = $(this.pageElement.querySelector('.received-likes'), HTMLTableElement); 14 + this.givenTable = $(this.pageElement.querySelector('.given-likes'), HTMLTableElement); 15 + 16 + this.appView = new BlueskyAPI('public.api.bsky.app', false); 17 + 18 + this.setupEvents(); 19 + 20 + this.progressPosts = 0; 21 + this.progressLikeRecords = 0; 22 + this.progressPostLikes = 0; 23 + } 24 + 25 + setupEvents() { 26 + $(this.pageElement.querySelector('form')).addEventListener('submit', (e) => { 27 + e.preventDefault(); 28 + 29 + if (!this.scanStartTime) { 30 + this.findLikes(); 31 + } else { 32 + this.stopScan(); 33 + } 34 + }); 35 + 36 + this.rangeInput.addEventListener('input', (e) => { 37 + let days = parseInt(this.rangeInput.value, 10); 38 + let label = $(this.pageElement.querySelector('input[type=range] + label')); 39 + label.innerText = (days == 1) ? '1 day' : `${days} days`; 40 + }); 41 + } 42 + 43 + /** @returns {number} */ 44 + 45 + selectedDaysRange() { 46 + return parseInt(this.rangeInput.value, 10); 47 + } 48 + 49 + show() { 50 + this.pageElement.style.display = 'block'; 51 + } 52 + 53 + /** @returns {Promise<void>} */ 54 + 55 + async findLikes() { 56 + this.submitButton.value = 'Cancel'; 57 + 58 + let requestedDays = this.selectedDaysRange(); 59 + 60 + this.resetProgress(); 61 + this.progressBar.style.display = 'inline'; 62 + 63 + let startTime = new Date().getTime(); 64 + this.scanStartTime = startTime; 65 + 66 + this.receivedTable.style.display = 'none'; 67 + this.givenTable.style.display = 'none'; 68 + 69 + let fetchGivenLikes = this.fetchGivenLikes(requestedDays); 70 + 71 + let receivedLikes = await this.fetchReceivedLikes(requestedDays); 72 + let receivedStats = this.sumUpReceivedLikes(receivedLikes); 73 + let topReceived = this.getTopEntries(receivedStats); 74 + 75 + await this.renderResults(topReceived, this.receivedTable); 76 + 77 + let givenLikes = await fetchGivenLikes; 78 + let givenStats = this.sumUpGivenLikes(givenLikes); 79 + let topGiven = this.getTopEntries(givenStats); 80 + 81 + let profileInfo = await appView.getRequest('app.bsky.actor.getProfiles', { actors: topGiven.map(x => x.did) }); 82 + 83 + for (let profile of profileInfo.profiles) { 84 + let user = /** @type {LikeStat} */ (topGiven.find(x => x.did == profile.did)); 85 + user.handle = profile.handle; 86 + user.avatar = profile.avatar; 87 + } 88 + 89 + await this.renderResults(topGiven, this.givenTable); 90 + 91 + this.receivedTable.style.display = 'table'; 92 + this.givenTable.style.display = 'table'; 93 + 94 + this.submitButton.value = 'Start scan'; 95 + this.progressBar.style.display = 'none'; 96 + this.scanStartTime = undefined; 97 + } 98 + 99 + /** @param {number} requestedDays, @returns {Promise<json[]>} */ 100 + 101 + async fetchGivenLikes(requestedDays) { 102 + let startTime = /** @type {number} */ (this.scanStartTime); 103 + 104 + return await accountAPI.fetchAll('com.atproto.repo.listRecords', { 105 + params: { 106 + repo: accountAPI.user.did, 107 + collection: 'app.bsky.feed.like', 108 + limit: 100 109 + }, 110 + field: 'records', 111 + breakWhen: (x) => Date.parse(x['value']['createdAt']) < startTime - 86400 * requestedDays * 1000, 112 + onPageLoad: (data) => { 113 + let last = data.at(-1); 114 + 115 + if (!last) { return } 116 + 117 + let lastDate = Date.parse(last.value.createdAt); 118 + let daysBack = (startTime - lastDate) / 86400 / 1000; 119 + 120 + this.updateProgress({ likeRecords: Math.min(1.0, daysBack / requestedDays) }); 121 + } 122 + }); 123 + } 124 + 125 + /** @param {number} requestedDays, @returns {Promise<json[]>} */ 126 + 127 + async fetchReceivedLikes(requestedDays) { 128 + let startTime = /** @type {number} */ (this.scanStartTime); 129 + 130 + let myPosts = await this.appView.loadUserTimeline(accountAPI.user.did, requestedDays, { 131 + filter: 'posts_with_replies', 132 + onPageLoad: (data) => { 133 + let last = data.at(-1); 134 + 135 + if (!last) { return } 136 + 137 + let lastDate = feedPostTime(last); 138 + let daysBack = (startTime - lastDate) / 86400 / 1000; 139 + 140 + this.updateProgress({ posts: Math.min(1.0, daysBack / requestedDays) }); 141 + } 142 + }); 143 + 144 + let likedPosts = myPosts.filter(x => !x['reason'] && x['post']['likeCount'] > 0); 145 + 146 + let results = []; 147 + 148 + for (let i = 0; i < likedPosts.length; i += 10) { 149 + let batch = likedPosts.slice(i, i + 10); 150 + this.updateProgress({ postLikes: i / likedPosts.length }); 151 + 152 + let fetchBatch = batch.map(x => { 153 + return this.appView.fetchAll('app.bsky.feed.getLikes', { 154 + params: { 155 + uri: x['post']['uri'], 156 + limit: 100 157 + }, 158 + field: 'likes' 159 + }); 160 + }); 161 + 162 + let batchResults = await Promise.all(fetchBatch); 163 + results = results.concat(batchResults); 164 + } 165 + 166 + this.updateProgress({ postLikes: 1.0 }); 167 + 168 + return results.flat(); 169 + } 170 + 171 + /** 172 + * @typedef {{ handle?: string, did?: string, avatar?: string, count: number }} LikeStat 173 + * @typedef {Record<string, LikeStat>} LikeStatHash 174 + */ 175 + 176 + /** @param {json[]} likes, @returns {LikeStatHash} */ 177 + 178 + sumUpReceivedLikes(likes) { 179 + /** @type {LikeStatHash} */ 180 + let stats = {}; 181 + 182 + for (let like of likes) { 183 + let handle = like.actor.handle; 184 + 185 + if (!stats[handle]) { 186 + stats[handle] = { handle: handle, count: 0, avatar: like.actor.avatar }; 187 + } 188 + 189 + stats[handle].count += 1; 190 + } 191 + 192 + return stats; 193 + } 194 + 195 + /** @param {json[]} likes, @returns {LikeStatHash} */ 196 + 197 + sumUpGivenLikes(likes) { 198 + /** @type {LikeStatHash} */ 199 + let stats = {}; 200 + 201 + for (let like of likes) { 202 + let did = atURI(like.value.subject.uri).repo; 203 + 204 + if (!stats[did]) { 205 + stats[did] = { did: did, count: 0 }; 206 + } 207 + 208 + stats[did].count += 1; 209 + } 210 + 211 + return stats; 212 + } 213 + 214 + /** @param {LikeStatHash} counts, @returns {LikeStat[]} */ 215 + 216 + getTopEntries(counts) { 217 + return Object.entries(counts).sort(this.sortResults).map(x => x[1]).slice(0, 25); 218 + } 219 + 220 + /** @param {LikeStat[]} topUsers, @param {HTMLTableElement} table, @returns {Promise<void>} */ 221 + 222 + async renderResults(topUsers, table) { 223 + let tableBody = $(table.querySelector('tbody')); 224 + tableBody.innerHTML = ''; 225 + 226 + for (let [i, user] of topUsers.entries()) { 227 + let tr = $tag('tr'); 228 + tr.append( 229 + $tag('td.no', { text: i + 1 }), 230 + $tag('td.handle', { 231 + html: `<img class="avatar" src="${user.avatar}"> ` + 232 + `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>` 233 + }), 234 + $tag('td.count', { text: user.count }) 235 + ); 236 + 237 + tableBody.append(tr); 238 + }; 239 + } 240 + 241 + resetProgress() { 242 + this.progressBar.value = 0; 243 + this.progressPosts = 0; 244 + this.progressLikeRecords = 0; 245 + this.progressPostLikes = 0; 246 + } 247 + 248 + /** @param {{ posts?: number, likeRecords?: number, postLikes?: number }} data */ 249 + 250 + updateProgress(data) { 251 + if (data.posts) { 252 + this.progressPosts = data.posts; 253 + } 254 + 255 + if (data.likeRecords) { 256 + this.progressLikeRecords = data.likeRecords; 257 + } 258 + 259 + if (data.postLikes) { 260 + this.progressPostLikes = data.postLikes; 261 + } 262 + 263 + let totalProgress = ( 264 + 0.1 * this.progressPosts + 265 + 0.65 * this.progressLikeRecords + 266 + 0.25 * this.progressPostLikes 267 + ); 268 + 269 + this.progressBar.value = totalProgress; 270 + } 271 + 272 + /** @param {[string, LikeStat]} a, @param {[string, LikeStat]} b, @returns {-1|1|0} */ 273 + 274 + sortResults(a, b) { 275 + if (a[1].count < b[1].count) { 276 + return 1; 277 + } else if (a[1].count > b[1].count) { 278 + return -1; 279 + } else { 280 + return 0; 281 + } 282 + } 283 + 284 + stopScan() { 285 + this.submitButton.value = 'Start scan'; 286 + this.progressBar.style.display = 'none'; 287 + this.scanStartTime = undefined; 288 + } 289 + }
+140
menu.js
···
··· 1 + class Menu { 2 + constructor() { 3 + this.menuElement = $id('account_menu'); 4 + this.icon = $id('account'); 5 + 6 + this.setupEvents(); 7 + } 8 + 9 + setupEvents() { 10 + let html = $(document.body.parentNode); 11 + 12 + html.addEventListener('click', (e) => { 13 + this.menuElement.style.visibility = 'hidden'; 14 + this.icon.classList.remove('active'); 15 + }); 16 + 17 + let homeLink = $(this.menuElement.querySelector('a[href="?"]'), HTMLLinkElement); 18 + homeLink.href = location.origin + location.pathname; 19 + 20 + this.icon.addEventListener('click', (e) => { 21 + e.stopPropagation(); 22 + this.toggleAccountMenu(); 23 + }); 24 + 25 + this.menuElement.addEventListener('click', (e) => { 26 + e.stopPropagation(); 27 + }); 28 + 29 + $(this.menuElement.querySelector('a[data-action=biohazard]')).addEventListener('click', (e) => { 30 + e.preventDefault(); 31 + 32 + let hazards = document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post'); 33 + 34 + if (window.biohazardEnabled === false) { 35 + window.biohazardEnabled = true; 36 + localStorage.setItem('biohazard', 'true'); 37 + this.toggleMenuButtonCheck('biohazard', true); 38 + Array.from(hazards).forEach(p => { $(p).style.display = 'block' }); 39 + } else { 40 + window.biohazardEnabled = false; 41 + localStorage.setItem('biohazard', 'false'); 42 + this.toggleMenuButtonCheck('biohazard', false); 43 + Array.from(hazards).forEach(p => { $(p).style.display = 'none' }); 44 + } 45 + }); 46 + 47 + $(this.menuElement.querySelector('a[data-action=incognito]')).addEventListener('click', (e) => { 48 + e.preventDefault(); 49 + 50 + if (window.isIncognito) { 51 + localStorage.removeItem('incognito'); 52 + } else { 53 + localStorage.setItem('incognito', '1'); 54 + } 55 + 56 + location.reload(); 57 + }); 58 + 59 + $(this.menuElement.querySelector('a[data-action=login]')).addEventListener('click', (e) => { 60 + e.preventDefault(); 61 + 62 + showDialog(loginDialog); 63 + this.menuElement.style.visibility = 'hidden'; 64 + }); 65 + 66 + $(this.menuElement.querySelector('a[data-action=logout]')).addEventListener('click', (e) => { 67 + e.preventDefault(); 68 + logOut(); 69 + }); 70 + } 71 + 72 + toggleAccountMenu() { 73 + let isVisible = (this.menuElement.style.visibility == 'visible'); 74 + 75 + this.menuElement.style.visibility = isVisible ? 'hidden' : 'visible'; 76 + this.icon.classList.toggle('active', !isVisible); 77 + } 78 + 79 + /** @param {string} buttonName */ 80 + 81 + showMenuButton(buttonName) { 82 + let button = $(this.menuElement.querySelector(`a[data-action=${buttonName}]`)); 83 + let item = $(button.parentNode); 84 + item.style.display = 'list-item'; 85 + } 86 + 87 + /** @param {string} buttonName */ 88 + 89 + hideMenuButton(buttonName) { 90 + let button = $(this.menuElement.querySelector(`a[data-action=${buttonName}]`)); 91 + let item = $(button.parentNode); 92 + item.style.display = 'none'; 93 + } 94 + 95 + /** @param {string} buttonName, @param {boolean} state */ 96 + 97 + toggleMenuButtonCheck(buttonName, state) { 98 + let button = $(this.menuElement.querySelector(`a[data-action=${buttonName}]`)); 99 + let check = $(button.querySelector('.check')); 100 + check.style.display = (state) ? 'inline' : 'none'; 101 + } 102 + 103 + /** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */ 104 + 105 + showLoggedInStatus(loggedIn, avatar) { 106 + if (loggedIn === true && avatar) { 107 + let button = $(this.icon.querySelector('i')); 108 + 109 + let img = $tag('img.avatar', { src: avatar }); 110 + img.style.display = 'none'; 111 + img.addEventListener('load', () => { 112 + button.remove(); 113 + img.style.display = 'inline'; 114 + }); 115 + img.addEventListener('error', () => { 116 + this.showLoggedInStatus(true, null); 117 + }) 118 + 119 + this.icon.append(img); 120 + } else if (loggedIn === false) { 121 + this.icon.innerHTML = `<i class="fa-regular fa-user-circle fa-xl"></i>`; 122 + } else if (loggedIn === 'incognito') { 123 + this.icon.innerHTML = `<i class="fa-solid fa-user-secret fa-lg"></i>`; 124 + } else { 125 + this.icon.innerHTML = `<i class="fa-solid fa-user-circle fa-xl"></i>`; 126 + } 127 + } 128 + 129 + /** @returns {Promise<void>} */ 130 + 131 + async loadCurrentUserAvatar() { 132 + try { 133 + let url = await api.loadCurrentUserAvatar(); 134 + this.showLoggedInStatus(true, url); 135 + } catch (error) { 136 + console.log(error); 137 + this.showLoggedInStatus(true, null); 138 + } 139 + } 140 + }
+85 -2
minisky.js
··· 14 15 16 /** 17 * Thrown when authentication is needed, but access token is invalid or missing. 18 */ 19 ··· 97 let host = (this.host.includes('://')) ? this.host : `https://${this.host}`; 98 return host + '/xrpc'; 99 } else { 100 - throw new AuthError('Hostname not set'); 101 } 102 } 103 ··· 172 return await this.parseResponse(response); 173 } 174 175 /** @param {string | boolean} auth, @returns {Record<string, string>} */ 176 177 authHeaders(auth) { ··· 188 } 189 } 190 191 /** @param {string} token, @returns {number} */ 192 193 tokenExpirationTimestamp(token) { ··· 218 let text = await response.text(); 219 let json = text.trim().length > 0 ? JSON.parse(text) : undefined; 220 221 - if (response.status == 200) { 222 return json; 223 } else { 224 throw new APIError(response.status, json);
··· 14 15 16 /** 17 + * Thrown when passed arguments/options are invalid or missing. 18 + */ 19 + 20 + class RequestError extends Error {} 21 + 22 + 23 + /** 24 * Thrown when authentication is needed, but access token is invalid or missing. 25 */ 26 ··· 104 let host = (this.host.includes('://')) ? this.host : `https://${this.host}`; 105 return host + '/xrpc'; 106 } else { 107 + throw new RequestError('Hostname not set'); 108 } 109 } 110 ··· 179 return await this.parseResponse(response); 180 } 181 182 + /** 183 + * @typedef {(obj: json[]) => { cancel: true } | void} FetchAllOnPageLoad 184 + * 185 + * @typedef {MiniskyOptions & { 186 + * field: string, 187 + * params?: json, 188 + * breakWhen?: (obj: json) => boolean, 189 + * keepLastPage?: boolean | undefined, 190 + * onPageLoad?: FetchAllOnPageLoad | undefined 191 + * }} FetchAllOptions 192 + * 193 + * @param {string} method 194 + * @param {FetchAllOptions} [options] 195 + * @returns {Promise<json[]>} 196 + */ 197 + 198 + async fetchAll(method, options) { 199 + if (!options || !options.field) { 200 + throw new RequestError("'field' option is required"); 201 + } 202 + 203 + let data = []; 204 + let reqParams = options.params ?? {}; 205 + let reqOptions = this.sliceOptions(options, ['auth', 'headers']); 206 + 207 + for (;;) { 208 + let response = await this.getRequest(method, reqParams, reqOptions); 209 + 210 + let items = response[options.field]; 211 + let cursor = response.cursor; 212 + 213 + if (options.breakWhen) { 214 + let test = options.breakWhen; 215 + 216 + if (items.some(x => test(x))) { 217 + if (!options.keepLastPage) { 218 + items = items.filter(x => !test(x)); 219 + } 220 + 221 + cursor = null; 222 + } 223 + } 224 + 225 + data = data.concat(items); 226 + reqParams.cursor = cursor; 227 + 228 + if (options.onPageLoad) { 229 + let result = options.onPageLoad(items); 230 + 231 + if (result?.cancel) { 232 + break; 233 + } 234 + } 235 + 236 + if (!cursor) { 237 + break; 238 + } 239 + } 240 + 241 + return data; 242 + } 243 + 244 /** @param {string | boolean} auth, @returns {Record<string, string>} */ 245 246 authHeaders(auth) { ··· 257 } 258 } 259 260 + /** @param {json} options, @param {string[]} list, @returns {json} */ 261 + 262 + sliceOptions(options, list) { 263 + let newOptions = {}; 264 + 265 + for (let i of list) { 266 + if (i in options) { 267 + newOptions[i] = options[i]; 268 + } 269 + } 270 + 271 + return newOptions; 272 + } 273 + 274 /** @param {string} token, @returns {number} */ 275 276 tokenExpirationTimestamp(token) { ··· 301 let text = await response.text(); 302 let json = text.trim().length > 0 ? JSON.parse(text) : undefined; 303 304 + if (response.status >= 200 && response.status < 300) { 305 return json; 306 } else { 307 throw new APIError(response.status, json);
+25
models.js
··· 287 return -1; 288 } else if (a.author.did != this.author.did && b.author.did == this.author.did) { 289 return 1; 290 } else if (a.createdAt.getTime() < b.createdAt.getTime()) { 291 return -1; 292 } else if (a.createdAt.getTime() > b.createdAt.getTime()) { ··· 318 return this.record.bridgyOriginalText; 319 } 320 321 /** @returns {boolean} */ 322 get isRoot() { 323 // I AM ROOOT ··· 338 return this.record.text; 339 } 340 341 /** @returns {json} */ 342 get facets() { 343 return this.record.facets; ··· 380 let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length); 381 382 return shouldHaveMoreReplies && (this.replies.length > 0 || (this.level !== undefined && this.level <= 4)); 383 } 384 385 /** @returns {number} */ ··· 650 651 this.url = json.external.uri; 652 this.title = json.external.title; 653 } 654 } 655 ··· 718 this.url = json.external.uri; 719 this.title = json.external.title; 720 this.description = json.external.description; 721 } 722 } 723
··· 287 return -1; 288 } else if (a.author.did != this.author.did && b.author.did == this.author.did) { 289 return 1; 290 + } else if (a.text != "๐Ÿ“Œ" && b.text == "๐Ÿ“Œ") { 291 + return -1; 292 + } else if (a.text == "๐Ÿ“Œ" && b.text != "๐Ÿ“Œ") { 293 + return 1; 294 } else if (a.createdAt.getTime() < b.createdAt.getTime()) { 295 return -1; 296 } else if (a.createdAt.getTime() > b.createdAt.getTime()) { ··· 322 return this.record.bridgyOriginalText; 323 } 324 325 + /** @returns {string | undefined} */ 326 + get originalFediURL() { 327 + return this.record.bridgyOriginalUrl; 328 + } 329 + 330 /** @returns {boolean} */ 331 get isRoot() { 332 // I AM ROOOT ··· 347 return this.record.text; 348 } 349 350 + /** @returns {string} */ 351 + get lowercaseText() { 352 + if (!this._lowercaseText) { 353 + this._lowercaseText = this.record.text.toLowerCase(); 354 + } 355 + 356 + return this._lowercaseText; 357 + } 358 + 359 /** @returns {json} */ 360 get facets() { 361 return this.record.facets; ··· 398 let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length); 399 400 return shouldHaveMoreReplies && (this.replies.length > 0 || (this.level !== undefined && this.level <= 4)); 401 + } 402 + 403 + /** @returns {boolean} */ 404 + get isRestrictingReplies() { 405 + return !!(this.data.threadgate && this.data.threadgate.record.allow); 406 } 407 408 /** @returns {number} */ ··· 673 674 this.url = json.external.uri; 675 this.title = json.external.title; 676 + this.thumb = json.external.thumb; 677 } 678 } 679 ··· 742 this.url = json.external.uri; 743 this.title = json.external.title; 744 this.description = json.external.description; 745 + this.thumb = json.external.thumb; 746 } 747 } 748
+78
notifications_page.js
···
··· 1 + class NotificationsPage { 2 + 3 + constructor() { 4 + this.pageElement = $id('thread'); 5 + } 6 + 7 + show() { 8 + document.title = `Notifications - Skythread`; 9 + showLoader(); 10 + 11 + let isLoading = false; 12 + let firstPageLoaded = false; 13 + let finished = false; 14 + let cursor; 15 + 16 + Paginator.loadInPages((next) => { 17 + if (isLoading || finished) { return; } 18 + isLoading = true; 19 + 20 + accountAPI.loadMentions(cursor).then(data => { 21 + let posts = data.posts.map(x => new Post(x)); 22 + 23 + if (posts.length > 0) { 24 + if (!firstPageLoaded) { 25 + hideLoader(); 26 + firstPageLoaded = true; 27 + 28 + let header = $tag('header'); 29 + let h2 = $tag('h2', { text: "Replies & Mentions:" }); 30 + header.append(h2); 31 + 32 + this.pageElement.appendChild(header); 33 + this.pageElement.classList.add('notifications'); 34 + } 35 + 36 + for (let post of posts) { 37 + if (post.parentReference) { 38 + let p = $tag('p.back'); 39 + p.innerHTML = `<i class="fa-solid fa-reply"></i> `; 40 + 41 + let { repo, rkey } = atURI(post.parentReference.uri); 42 + let url = linkToPostById(repo, rkey); 43 + let parentLink = $tag('a', { href: url }); 44 + p.append(parentLink); 45 + 46 + if (repo == accountAPI.user.did) { 47 + parentLink.innerText = 'Reply to you'; 48 + } else { 49 + parentLink.innerText = 'Reply'; 50 + api.fetchHandleForDid(repo).then(handle => { 51 + parentLink.innerText = `Reply to @${handle}`; 52 + }); 53 + } 54 + 55 + this.pageElement.appendChild(p); 56 + } 57 + 58 + let postView = new PostComponent(post, 'feed').buildElement(); 59 + this.pageElement.appendChild(postView); 60 + } 61 + } 62 + 63 + isLoading = false; 64 + cursor = data.cursor; 65 + 66 + if (!cursor) { 67 + finished = true; 68 + } else if (posts.length == 0) { 69 + next(); 70 + } 71 + }).catch(error => { 72 + hideLoader(); 73 + console.log(error); 74 + isLoading = false; 75 + }); 76 + }); 77 + } 78 + }
+253 -58
post_component.js
··· 5 class PostComponent { 6 /** 7 * Post component's root HTML element, if built. 8 - * @type {AnyElement | undefined} 9 */ 10 _rootElement; 11 ··· 26 } 27 28 /** 29 - * @returns {AnyElement} 30 */ 31 get rootElement() { 32 if (!this._rootElement) { ··· 91 } 92 } 93 94 - /** @param {AnyElement} nodeToUpdate */ 95 installIntoElement(nodeToUpdate) { 96 let view = this.buildElement(); 97 98 - nodeToUpdate.querySelector('.content').replaceWith(view.querySelector('.content')); 99 this._rootElement = nodeToUpdate; 100 } 101 102 - /** @returns {AnyElement} */ 103 buildElement() { 104 if (this._rootElement) { 105 return this._rootElement; 106 } 107 108 - let div = $tag('div.post'); 109 this._rootElement = div; 110 111 if (this.post.muted) { ··· 159 if (this.post.embed) { 160 let embed = new EmbedComponent(this.post, this.post.embed).buildElement(); 161 wrapper.appendChild(embed); 162 } 163 164 if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) { ··· 196 return div; 197 } 198 199 - /** @returns {AnyElement} */ 200 201 buildPostHeader() { 202 let timeFormat = this.timeFormatForTimestamp; ··· 208 h.innerHTML = `${escapeHTML(this.authorName)} `; 209 210 if (this.post.isFediPost) { 211 - let handle = this.post.authorFediHandle; 212 - h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">@${handle}</a> ` + 213 `<img src="icons/mastodon.svg" class="mastodon"> `; 214 } else { 215 - h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">@${this.post.author.handle}</a> `; 216 } 217 218 h.innerHTML += `<span class="separator">&bull;</span> ` + ··· 260 /** @param {string} url, @returns {HTMLImageElement} */ 261 262 buildUserAvatar(url) { 263 - let avatar = $tag('img.avatar', { loading: 'lazy' }); // needs to be set before src! 264 avatar.src = url; 265 window.avatarPreloader.observe(avatar); 266 return avatar; 267 } 268 269 - /** @returns {AnyElement} */ 270 271 buildPostBody() { 272 if (this.post.originalFediContent) { ··· 297 return p; 298 } 299 300 - /** @param {string[]} tags, @returns {AnyElement} */ 301 302 buildTagsRow(tags) { 303 let p = $tag('p.tags'); ··· 313 return p; 314 } 315 316 - /** @returns {AnyElement} */ 317 318 buildStatsFooter() { 319 let stats = $tag('p.stats'); ··· 330 stats.append(span); 331 } 332 333 if (!this.isRoot && this.context != 'quote' && this.post.quoteCount) { 334 - let quotesLink = this.buildQuotesIconLink(this.post.quoteCount, false); 335 stats.append(quotesLink); 336 } 337 338 return stats; 339 } 340 341 - /** @param {number} count, @param {boolean} expanded, @returns {AnyElement} */ 342 343 buildQuotesIconLink(count, expanded) { 344 let q = new URL(getLocation()); 345 q.searchParams.set('quotes', this.linkToPost); 346 347 let url = q.toString(); 348 - let icon = `<i class="fa-regular ${count > 1 ? 'fa-comments' : 'fa-comment'}"></i>`; 349 350 if (expanded) { 351 let span = $tag('span', { html: `${icon} ` }); ··· 360 /** @param {number} quoteCount, @param {boolean} expanded */ 361 362 appendQuotesIconLink(quoteCount, expanded) { 363 - let stats = this.rootElement.querySelector(':scope > .content > p.stats'); 364 let quotesLink = this.buildQuotesIconLink(quoteCount, expanded); 365 stats.append(quotesLink); 366 } 367 368 - /** @returns {AnyElement} */ 369 370 buildLoadMoreLink() { 371 let loadMore = $tag('p'); ··· 378 link.addEventListener('click', (e) => { 379 e.preventDefault(); 380 loadMore.innerHTML = `<img class="loader" src="icons/sunny.png">`; 381 - loadSubtree(this.post, this.rootElement); 382 }); 383 384 loadMore.appendChild(link); 385 return loadMore; 386 } 387 388 - /** @returns {AnyElement} */ 389 390 buildHiddenRepliesLink() { 391 let loadMore = $tag('p.hidden-replies'); ··· 410 return loadMore; 411 } 412 413 - /** @param {HTMLLinkElement} loadMoreButton */ 414 415 loadHiddenReplies(loadMoreButton) { 416 loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`; 417 - loadHiddenSubtree(this.post, this.rootElement); 418 } 419 420 /** @param {HTMLLinkElement} authorLink */ ··· 434 }); 435 } 436 437 - /** @param {AnyElement} div, @returns {AnyElement} */ 438 439 buildBlockedPostElement(div) { 440 let p = $tag('p.blocked-header'); ··· 449 let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : ''; 450 blockStatus = blockStatus ? `, ${blockStatus}` : ''; 451 452 - let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }); 453 p.append(' (', authorLink, blockStatus, ') '); 454 div.appendChild(p); 455 ··· 470 return div; 471 } 472 473 - /** @param {AnyElement} div, @returns {AnyElement} */ 474 475 buildDetachedQuoteElement(div) { 476 let p = $tag('p.blocked-header'); ··· 482 return p; 483 } 484 485 - let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }); 486 p.append(' (', authorLink, ') '); 487 div.appendChild(p); 488 ··· 503 return div; 504 } 505 506 - /** @param {AnyElement} div, @returns {AnyElement} */ 507 508 buildMissingPostElement(div) { 509 let p = $tag('p.blocked-header'); 510 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`; 511 512 - let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }); 513 p.append(' (', authorLink, ') '); 514 515 this.loadReferencedPostAuthor(authorLink); ··· 519 return div; 520 } 521 522 - /** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */ 523 524 async loadBlockedPost(uri, div) { 525 let record = await appView.loadPostIfExists(this.post.uri); ··· 545 html: `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i>` 546 }); 547 548 - let header = div.querySelector('p.blocked-header'); 549 let separator = $tag('span.separator', { html: '&bull;' }); 550 header.append(separator, ' ', a); 551 } 552 553 - div.querySelector('p.load-post').remove(); 554 555 if (this.isRoot && this.post.parentReference) { 556 let { repo, rkey } = atURI(this.post.parentReference.uri); ··· 575 } 576 } 577 578 /** @returns {boolean} */ 579 isCollapsed() { 580 return this.rootElement.classList.contains('collapsed'); 581 } 582 583 toggleSectionFold() { 584 - let plus = this.rootElement.querySelector(':scope > .margin .plus'); 585 586 if (this.isCollapsed()) { 587 this.rootElement.classList.remove('collapsed'); ··· 592 } 593 } 594 595 - /** @param {AnyElement} heart */ 596 597 - onHeartClick(heart) { 598 - if (!this.post.hasViewerInfo) { 599 - if (accountAPI.isLoggedIn) { 600 - accountAPI.loadPostIfExists(this.post.uri).then(data => { 601 - if (data) { 602 - this.post = new Post(data); 603 604 if (this.post.liked) { 605 heart.classList.add('liked'); 606 } else { 607 - this.onHeartClick(heart); 608 } 609 } else { 610 - alert("Sorry, this post is blocked or was deleted."); 611 } 612 - }).catch(error => { 613 - alert(error); 614 - }); 615 - } else { 616 - showDialog(loginDialog); 617 } 618 - return; 619 - } 620 621 - let count = heart.nextElementSibling; 622 623 - if (!heart.classList.contains('liked')) { 624 - accountAPI.likePost(this.post).then((like) => { 625 this.post.viewerLike = like.uri; 626 heart.classList.add('liked'); 627 - count.innerText = String(parseInt(count.innerText, 10) + 1); 628 - }).catch(showError); 629 - } else { 630 - accountAPI.removeLike(this.post.viewerLike).then(() => { 631 this.post.viewerLike = undefined; 632 heart.classList.remove('liked'); 633 - count.innerText = String(parseInt(count.innerText, 10) - 1); 634 - }).catch(showError); 635 } 636 } 637 }
··· 5 class PostComponent { 6 /** 7 * Post component's root HTML element, if built. 8 + * @type {HTMLElement | undefined} 9 */ 10 _rootElement; 11 ··· 26 } 27 28 /** 29 + * @returns {HTMLElement} 30 */ 31 get rootElement() { 32 if (!this._rootElement) { ··· 91 } 92 } 93 94 + /** @param {HTMLElement} nodeToUpdate */ 95 installIntoElement(nodeToUpdate) { 96 let view = this.buildElement(); 97 98 + let oldContent = $(nodeToUpdate.querySelector('.content')); 99 + let newContent = $(view.querySelector('.content')); 100 + oldContent.replaceWith(newContent); 101 + 102 this._rootElement = nodeToUpdate; 103 } 104 105 + /** @returns {HTMLElement} */ 106 buildElement() { 107 if (this._rootElement) { 108 return this._rootElement; 109 } 110 111 + let div = $tag('div.post', `post-${this.context}`); 112 this._rootElement = div; 113 114 if (this.post.muted) { ··· 162 if (this.post.embed) { 163 let embed = new EmbedComponent(this.post, this.post.embed).buildElement(); 164 wrapper.appendChild(embed); 165 + 166 + if (this.post.originalFediURL) { 167 + if (this.post.embed instanceof InlineLinkEmbed && this.post.embed.title.startsWith('Original post on ')) { 168 + embed.remove(); 169 + } 170 + } 171 + } 172 + 173 + if (this.post.originalFediURL) { 174 + let link = this.buildFediSourceLink(this.post.originalFediURL); 175 + if (link) { 176 + wrapper.appendChild(link); 177 + } 178 } 179 180 if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) { ··· 212 return div; 213 } 214 215 + /** @returns {HTMLElement} */ 216 217 buildPostHeader() { 218 let timeFormat = this.timeFormatForTimestamp; ··· 224 h.innerHTML = `${escapeHTML(this.authorName)} `; 225 226 if (this.post.isFediPost) { 227 + let handle = `@${this.post.authorFediHandle}`; 228 + h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">${handle}</a> ` + 229 `<img src="icons/mastodon.svg" class="mastodon"> `; 230 } else { 231 + let handle = (this.post.author.handle != 'handle.invalid') ? `@${this.post.author.handle}` : '[invalid handle]'; 232 + h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">${handle}</a> `; 233 } 234 235 h.innerHTML += `<span class="separator">&bull;</span> ` + ··· 277 /** @param {string} url, @returns {HTMLImageElement} */ 278 279 buildUserAvatar(url) { 280 + let avatar = $tag('img.avatar', { loading: 'lazy' }, HTMLImageElement); // needs to be set before src! 281 avatar.src = url; 282 window.avatarPreloader.observe(avatar); 283 return avatar; 284 } 285 286 + /** @returns {HTMLElement} */ 287 288 buildPostBody() { 289 if (this.post.originalFediContent) { ··· 314 return p; 315 } 316 317 + /** @param {string[]} terms */ 318 + 319 + highlightSearchResults(terms) { 320 + let regexp = new RegExp(`\\b(${terms.join('|')})\\b`, 'gi'); 321 + 322 + let root = this.rootElement; 323 + let body = $(root.querySelector(':scope > .content > .body, :scope > .content > details .body')); 324 + let walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); 325 + let textNodes = []; 326 + 327 + while (walker.nextNode()) { 328 + textNodes.push(walker.currentNode); 329 + } 330 + 331 + for (let node of textNodes) { 332 + if (!node.textContent) { continue; } 333 + 334 + let markedText = document.createDocumentFragment(); 335 + let currentPosition = 0; 336 + 337 + for (;;) { 338 + let match = regexp.exec(node.textContent); 339 + if (match === null) break; 340 + 341 + if (match.index > currentPosition) { 342 + let earlierText = node.textContent.slice(currentPosition, match.index); 343 + markedText.appendChild(document.createTextNode(earlierText)); 344 + } 345 + 346 + let span = $tag('span.highlight', { text: match[0] }); 347 + markedText.appendChild(span); 348 + 349 + currentPosition = match.index + match[0].length; 350 + } 351 + 352 + if (currentPosition < node.textContent.length) { 353 + let remainingText = node.textContent.slice(currentPosition); 354 + markedText.appendChild(document.createTextNode(remainingText)); 355 + } 356 + 357 + $(node.parentNode).replaceChild(markedText, node); 358 + } 359 + } 360 + 361 + /** @param {string[]} tags, @returns {HTMLElement} */ 362 363 buildTagsRow(tags) { 364 let p = $tag('p.tags'); ··· 374 return p; 375 } 376 377 + /** @returns {HTMLElement} */ 378 379 buildStatsFooter() { 380 let stats = $tag('p.stats'); ··· 391 stats.append(span); 392 } 393 394 + if (this.post.replyCount > 0 && (this.context == 'quotes' || this.context == 'feed')) { 395 + let pluralizedCount = (this.post.replyCount > 1) ? `${this.post.replyCount} replies` : '1 reply'; 396 + let span = $tag('span', { 397 + html: `<i class="fa-regular fa-message"></i> <a href="${linkToPostThread(this.post)}">${pluralizedCount}</a>` 398 + }); 399 + stats.append(span); 400 + } 401 + 402 if (!this.isRoot && this.context != 'quote' && this.post.quoteCount) { 403 + let expanded = this.context == 'quotes' || this.context == 'feed'; 404 + let quotesLink = this.buildQuotesIconLink(this.post.quoteCount, expanded); 405 stats.append(quotesLink); 406 } 407 408 + if (this.context == 'thread' && this.post.isRestrictingReplies) { 409 + let span = $tag('span', { html: `<i class="fa-solid fa-ban"></i> Limited replies` }); 410 + stats.append(span); 411 + } 412 + 413 return stats; 414 } 415 416 + /** @param {number} count, @param {boolean} expanded, @returns {HTMLElement} */ 417 418 buildQuotesIconLink(count, expanded) { 419 let q = new URL(getLocation()); 420 q.searchParams.set('quotes', this.linkToPost); 421 422 let url = q.toString(); 423 + let icon = `<i class="fa-regular fa-comments"></i>`; 424 425 if (expanded) { 426 let span = $tag('span', { html: `${icon} ` }); ··· 435 /** @param {number} quoteCount, @param {boolean} expanded */ 436 437 appendQuotesIconLink(quoteCount, expanded) { 438 + let stats = $(this.rootElement.querySelector(':scope > .content > p.stats')); 439 let quotesLink = this.buildQuotesIconLink(quoteCount, expanded); 440 stats.append(quotesLink); 441 } 442 443 + /** @returns {HTMLElement} */ 444 445 buildLoadMoreLink() { 446 let loadMore = $tag('p'); ··· 453 link.addEventListener('click', (e) => { 454 e.preventDefault(); 455 loadMore.innerHTML = `<img class="loader" src="icons/sunny.png">`; 456 + this.loadSubtree(this.post, this.rootElement); 457 }); 458 459 loadMore.appendChild(link); 460 return loadMore; 461 } 462 463 + /** @returns {HTMLElement} */ 464 465 buildHiddenRepliesLink() { 466 let loadMore = $tag('p.hidden-replies'); ··· 485 return loadMore; 486 } 487 488 + /** @param {HTMLElement} loadMoreButton */ 489 490 loadHiddenReplies(loadMoreButton) { 491 loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`; 492 + this.loadHiddenSubtree(this.post, this.rootElement); 493 + } 494 + 495 + /** @param {string} url, @returns {HTMLElement | undefined} */ 496 + 497 + buildFediSourceLink(url) { 498 + try { 499 + let hostname = new URL(url).hostname; 500 + let a = $tag('a.fedi-link', { href: url, target: '_blank' }); 501 + 502 + let box = $tag('div', { html: `<i class="fa-solid fa-arrow-up-right-from-square fa-sm"></i> View on ${hostname}` }); 503 + a.append(box); 504 + return a; 505 + } catch (error) { 506 + console.log("Invalid Fedi URL:" + error); 507 + return undefined; 508 + } 509 } 510 511 /** @param {HTMLLinkElement} authorLink */ ··· 525 }); 526 } 527 528 + /** @param {HTMLElement} div, @returns {HTMLElement} */ 529 530 buildBlockedPostElement(div) { 531 let p = $tag('p.blocked-header'); ··· 540 let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : ''; 541 blockStatus = blockStatus ? `, ${blockStatus}` : ''; 542 543 + let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement); 544 p.append(' (', authorLink, blockStatus, ') '); 545 div.appendChild(p); 546 ··· 561 return div; 562 } 563 564 + /** @param {HTMLElement} div, @returns {HTMLElement} */ 565 566 buildDetachedQuoteElement(div) { 567 let p = $tag('p.blocked-header'); ··· 573 return p; 574 } 575 576 + let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement); 577 p.append(' (', authorLink, ') '); 578 div.appendChild(p); 579 ··· 594 return div; 595 } 596 597 + /** @param {HTMLElement} div, @returns {HTMLElement} */ 598 599 buildMissingPostElement(div) { 600 let p = $tag('p.blocked-header'); 601 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`; 602 603 + let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement); 604 p.append(' (', authorLink, ') '); 605 606 this.loadReferencedPostAuthor(authorLink); ··· 610 return div; 611 } 612 613 + /** @param {string} uri, @param {HTMLElement} div, @returns Promise<void> */ 614 615 async loadBlockedPost(uri, div) { 616 let record = await appView.loadPostIfExists(this.post.uri); ··· 636 html: `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i>` 637 }); 638 639 + let header = $(div.querySelector('p.blocked-header')); 640 let separator = $tag('span.separator', { html: '&bull;' }); 641 header.append(separator, ' ', a); 642 } 643 644 + let loadPost = $(div.querySelector('p.load-post')); 645 + loadPost.remove(); 646 647 if (this.isRoot && this.post.parentReference) { 648 let { repo, rkey } = atURI(this.post.parentReference.uri); ··· 667 } 668 } 669 670 + /** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise<void>} */ 671 + 672 + async loadSubtree(post, nodeToUpdate) { 673 + try { 674 + let json = await api.loadThreadByAtURI(post.uri); 675 + 676 + let root = Post.parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel); 677 + post.updateDataFromPost(root); 678 + window.subtreeRoot = post; 679 + 680 + let component = new PostComponent(post, 'thread'); 681 + component.installIntoElement(nodeToUpdate); 682 + } catch (error) { 683 + showError(error); 684 + } 685 + } 686 + 687 + 688 + /** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise<void>} */ 689 + 690 + async loadHiddenSubtree(post, nodeToUpdate) { 691 + let content = $(nodeToUpdate.querySelector('.content')); 692 + let hiddenRepliesDiv = $(content.querySelector(':scope > .hidden-replies')); 693 + 694 + try { 695 + var expectedReplyURIs = await blueAPI.getReplies(post.uri); 696 + } catch (error) { 697 + hiddenRepliesDiv.remove(); 698 + 699 + if (error instanceof APIError && error.code == 404) { 700 + let info = $tag('p.missing-replies-info', { 701 + html: `<i class="fa-solid fa-ban"></i> Hidden replies not available (post too old)` 702 + }); 703 + content.append(info); 704 + } else { 705 + setTimeout(() => showError(error), 1); 706 + } 707 + 708 + return; 709 + } 710 + 711 + let missingReplyURIs = expectedReplyURIs.filter(r => !post.replies.some(x => x.uri === r)); 712 + let promises = missingReplyURIs.map(uri => api.loadThreadByAtURI(uri)); 713 + 714 + try { 715 + // TODO 716 + var responses = await Promise.allSettled(promises); 717 + } catch (error) { 718 + hiddenRepliesDiv.remove(); 719 + setTimeout(() => showError(error), 1); 720 + return; 721 + } 722 + 723 + let replies = responses 724 + .map(r => r.status == 'fulfilled' ? r.value : undefined) 725 + .filter(v => v) 726 + .map(json => Post.parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1)); 727 + 728 + post.setReplies(replies); 729 + hiddenRepliesDiv.remove(); 730 + 731 + for (let reply of post.replies) { 732 + let component = new PostComponent(reply, 'thread'); 733 + let view = component.buildElement(); 734 + content.append(view); 735 + } 736 + 737 + if (replies.length < responses.length) { 738 + let notFoundCount = responses.length - replies.length; 739 + let pluralizedCount = (notFoundCount > 1) ? `${notFoundCount} replies are` : '1 reply is'; 740 + 741 + let info = $tag('p.missing-replies-info', { 742 + html: `<i class="fa-solid fa-ban"></i> ${pluralizedCount} missing (likely taken down by moderation)` 743 + }); 744 + content.append(info); 745 + } 746 + } 747 + 748 /** @returns {boolean} */ 749 isCollapsed() { 750 return this.rootElement.classList.contains('collapsed'); 751 } 752 753 toggleSectionFold() { 754 + let plus = $(this.rootElement.querySelector(':scope > .margin .plus'), HTMLImageElement); 755 756 if (this.isCollapsed()) { 757 this.rootElement.classList.remove('collapsed'); ··· 762 } 763 } 764 765 + /** @param {HTMLElement} heart, @returns {Promise<void>} */ 766 767 + async onHeartClick(heart) { 768 + try { 769 + if (!this.post.hasViewerInfo) { 770 + if (accountAPI.isLoggedIn) { 771 + let data = await this.loadViewerInfo(); 772 773 + if (data) { 774 if (this.post.liked) { 775 heart.classList.add('liked'); 776 + return; 777 } else { 778 + // continue down 779 } 780 } else { 781 + this.showPostAsBlocked(); 782 + return; 783 } 784 + } else { 785 + // not logged in 786 + showDialog(loginDialog); 787 + return; 788 + } 789 } 790 791 + let countField = $(heart.nextElementSibling); 792 + let likeCount = parseInt(countField.innerText, 10); 793 794 + if (!heart.classList.contains('liked')) { 795 + let like = await accountAPI.likePost(this.post); 796 this.post.viewerLike = like.uri; 797 heart.classList.add('liked'); 798 + countField.innerText = String(likeCount + 1); 799 + } else { 800 + await accountAPI.removeLike(this.post.viewerLike); 801 this.post.viewerLike = undefined; 802 heart.classList.remove('liked'); 803 + countField.innerText = String(likeCount - 1); 804 + } 805 + } catch (error) { 806 + showError(error); 807 } 808 + } 809 + 810 + showPostAsBlocked() { 811 + let stats = $(this.rootElement.querySelector(':scope > .content > p.stats')); 812 + 813 + if (!stats.querySelector('.blocked-info')) { 814 + let span = $tag('span.blocked-info', { text: '๐Ÿšซ Post unavailable' }); 815 + stats.append(span); 816 + } 817 + } 818 + 819 + /** @returns {Promise<json | undefined>} */ 820 + 821 + async loadViewerInfo() { 822 + let data = await accountAPI.loadPostIfExists(this.post.uri); 823 + 824 + if (data) { 825 + this.post.author = data.author; 826 + this.post.viewerData = data.viewer; 827 + this.post.viewerLike = data.viewer?.like; 828 + } 829 + 830 + return data; 831 } 832 }
+617
posting_stats_page.js
···
··· 1 + /** 2 + * Manages the Posting Stats page. 3 + */ 4 + 5 + class PostingStatsPage { 6 + 7 + /** @type {number | undefined} */ 8 + scanStartTime; 9 + 10 + /** @type {Record<string, { pages: number, progress: number }>} */ 11 + userProgress; 12 + 13 + /** @type {number | undefined} */ 14 + autocompleteTimer; 15 + 16 + /** @type {number} */ 17 + autocompleteIndex = -1; 18 + 19 + /** @type {json[]} */ 20 + autocompleteResults = []; 21 + 22 + /** @type {Record<string, json>} */ 23 + selectedUsers = {}; 24 + 25 + constructor() { 26 + this.pageElement = $id('posting_stats_page'); 27 + this.form = $(this.pageElement.querySelector('form'), HTMLFormElement); 28 + 29 + this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement); 30 + this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement); 31 + this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement); 32 + this.table = $(this.pageElement.querySelector('table.scan-result')); 33 + this.tableHead = $(this.table.querySelector('thead')); 34 + this.tableBody = $(this.table.querySelector('tbody')); 35 + this.listSelect = $(this.pageElement.querySelector('.list-choice select'), HTMLSelectElement); 36 + this.scanInfo = $(this.pageElement.querySelector('.scan-info')); 37 + this.scanType = this.form.elements['scan_type']; 38 + 39 + this.userField = $(this.pageElement.querySelector('.user-choice input'), HTMLInputElement); 40 + this.userList = $(this.pageElement.querySelector('.selected-users')); 41 + this.autocomplete = $(this.pageElement.querySelector('.autocomplete')); 42 + 43 + this.userProgress = {}; 44 + this.appView = new BlueskyAPI('public.api.bsky.app', false); 45 + 46 + this.setupEvents(); 47 + } 48 + 49 + setupEvents() { 50 + let html = $(document.body.parentNode); 51 + 52 + html.addEventListener('click', (e) => { 53 + this.hideAutocomplete(); 54 + }); 55 + 56 + this.form.addEventListener('submit', (e) => { 57 + e.preventDefault(); 58 + 59 + if (!this.scanStartTime) { 60 + this.scanPostingStats(); 61 + } else { 62 + this.stopScan(); 63 + } 64 + }); 65 + 66 + this.rangeInput.addEventListener('input', (e) => { 67 + let days = parseInt(this.rangeInput.value, 10); 68 + let label = $(this.pageElement.querySelector('input[type=range] + label')); 69 + label.innerText = (days == 1) ? '1 day' : `${days} days`; 70 + }); 71 + 72 + this.scanType.forEach(r => { 73 + r.addEventListener('click', (e) => { 74 + let value = $(r, HTMLInputElement).value; 75 + 76 + $(this.pageElement.querySelector('.list-choice')).style.display = (value == 'list') ? 'block' : 'none'; 77 + $(this.pageElement.querySelector('.user-choice')).style.display = (value == 'users') ? 'block' : 'none'; 78 + 79 + if (value == 'users') { 80 + this.userField.focus(); 81 + } 82 + 83 + this.table.style.display = 'none'; 84 + }); 85 + }); 86 + 87 + this.userField.addEventListener('input', () => { 88 + this.onUserInput(); 89 + }); 90 + 91 + this.userField.addEventListener('keydown', (e) => { 92 + this.onUserKeyDown(e); 93 + }); 94 + } 95 + 96 + show() { 97 + this.pageElement.style.display = 'block'; 98 + this.fetchLists(); 99 + } 100 + 101 + /** @returns {number} */ 102 + 103 + selectedDaysRange() { 104 + return parseInt(this.rangeInput.value, 10); 105 + } 106 + 107 + /** @returns {Promise<void>} */ 108 + 109 + async fetchLists() { 110 + let lists = await accountAPI.loadUserLists(); 111 + 112 + let sorted = lists.sort((a, b) => { 113 + let aName = a.name.toLocaleLowerCase(); 114 + let bName = b.name.toLocaleLowerCase(); 115 + 116 + return aName.localeCompare(bName); 117 + }); 118 + 119 + for (let list of lists) { 120 + this.listSelect.append( 121 + $tag('option', { value: list.uri, text: list.name + 'ย ' }) 122 + ); 123 + } 124 + } 125 + 126 + onUserInput() { 127 + if (this.autocompleteTimer) { 128 + clearTimeout(this.autocompleteTimer); 129 + } 130 + 131 + let query = this.userField.value.trim(); 132 + 133 + if (query.length == 0) { 134 + this.hideAutocomplete(); 135 + this.autocompleteTimer = undefined; 136 + return; 137 + } 138 + 139 + this.autocompleteTimer = setTimeout(() => this.fetchAutocomplete(query), 100); 140 + } 141 + 142 + /** @param {KeyboardEvent} e */ 143 + 144 + onUserKeyDown(e) { 145 + if (e.key == 'Enter') { 146 + e.preventDefault(); 147 + 148 + if (this.autocompleteIndex >= 0) { 149 + this.selectUser(this.autocompleteIndex); 150 + } 151 + } else if (e.key == 'Escape') { 152 + this.hideAutocomplete(); 153 + } else if (e.key == 'ArrowDown' && this.autocompleteResults.length > 0) { 154 + e.preventDefault(); 155 + this.moveAutocomplete(1); 156 + } else if (e.key == 'ArrowUp' && this.autocompleteResults.length > 0) { 157 + e.preventDefault(); 158 + this.moveAutocomplete(-1); 159 + } 160 + } 161 + 162 + /** @param {string} query, @returns {Promise<void>} */ 163 + 164 + async fetchAutocomplete(query) { 165 + let users = await accountAPI.autocompleteUsers(query); 166 + 167 + let selectedDIDs = new Set(Object.keys(this.selectedUsers)); 168 + users = users.filter(u => !selectedDIDs.has(u.did)); 169 + 170 + this.autocompleteResults = users; 171 + this.autocompleteIndex = -1; 172 + this.showAutocomplete(); 173 + } 174 + 175 + showAutocomplete() { 176 + this.autocomplete.innerHTML = ''; 177 + this.autocomplete.scrollTop = 0; 178 + 179 + if (this.autocompleteResults.length == 0) { 180 + this.hideAutocomplete(); 181 + return; 182 + } 183 + 184 + for (let [i, user] of this.autocompleteResults.entries()) { 185 + let row = this.makeUserRow(user); 186 + 187 + row.addEventListener('mouseenter', () => { 188 + this.highlightAutocomplete(i); 189 + }); 190 + 191 + row.addEventListener('mousedown', (e) => { 192 + e.preventDefault(); 193 + this.selectUser(i); 194 + }); 195 + 196 + this.autocomplete.append(row); 197 + }; 198 + 199 + this.autocomplete.style.top = this.userField.offsetHeight + 'px'; 200 + this.autocomplete.style.display = 'block'; 201 + this.highlightAutocomplete(0); 202 + } 203 + 204 + hideAutocomplete() { 205 + this.autocomplete.style.display = 'none'; 206 + this.autocompleteResults = []; 207 + this.autocompleteIndex = -1; 208 + } 209 + 210 + /** @param {number} change */ 211 + 212 + moveAutocomplete(change) { 213 + if (this.autocompleteResults.length == 0) { 214 + return; 215 + } 216 + 217 + let newIndex = this.autocompleteIndex + change; 218 + 219 + if (newIndex < 0) { 220 + newIndex = this.autocompleteResults.length - 1; 221 + } else if (newIndex >= this.autocompleteResults.length) { 222 + newIndex = 0; 223 + } 224 + 225 + this.highlightAutocomplete(newIndex); 226 + } 227 + 228 + /** @param {number} index */ 229 + 230 + highlightAutocomplete(index) { 231 + this.autocompleteIndex = index; 232 + 233 + let rows = this.autocomplete.querySelectorAll('.user-row'); 234 + 235 + rows.forEach((row, i) => { 236 + row.classList.toggle('hover', i == index); 237 + }); 238 + } 239 + 240 + /** @param {number} index */ 241 + 242 + selectUser(index) { 243 + let user = this.autocompleteResults[index]; 244 + 245 + if (!user) { 246 + return; 247 + } 248 + 249 + this.selectedUsers[user.did] = user; 250 + 251 + let row = this.makeUserRow(user, true); 252 + this.userList.append(row); 253 + 254 + this.userField.value = ''; 255 + this.hideAutocomplete(); 256 + } 257 + 258 + /** @param {json} user, @param {boolean} [withRemove], @returns HTMLElement */ 259 + 260 + makeUserRow(user, withRemove = false) { 261 + let row = $tag('div.user-row'); 262 + row.dataset.did = user.did; 263 + row.append( 264 + $tag('img.avatar', { src: user.avatar }), 265 + $tag('span.name', { text: user.displayName || 'โ€“' }), 266 + $tag('span.handle', { text: user.handle }) 267 + ); 268 + 269 + if (withRemove) { 270 + let remove = $tag('a.remove', { href: '#', text: 'โœ•' }); 271 + 272 + remove.addEventListener('click', (e) => { 273 + e.preventDefault(); 274 + row.remove(); 275 + delete this.selectedUsers[user.did]; 276 + }); 277 + 278 + row.append(remove); 279 + } 280 + 281 + return row; 282 + } 283 + 284 + /** @returns {Promise<void>} */ 285 + 286 + async scanPostingStats() { 287 + let startTime = new Date().getTime(); 288 + let requestedDays = this.selectedDaysRange(); 289 + let scanType = this.scanType.value; 290 + 291 + /** @type {FetchAllOnPageLoad} */ 292 + let onPageLoad = (data) => { 293 + if (this.scanStartTime != startTime) { 294 + return { cancel: true }; 295 + } 296 + 297 + this.updateProgress(data, startTime); 298 + }; 299 + 300 + if (scanType == 'home') { 301 + this.startScan(startTime, requestedDays); 302 + 303 + let posts = await accountAPI.loadHomeTimeline(requestedDays, { 304 + onPageLoad: onPageLoad, 305 + keepLastPage: true 306 + }); 307 + 308 + this.updateResultsTable(posts, startTime, requestedDays); 309 + } else if (scanType == 'list') { 310 + let list = this.listSelect.value; 311 + 312 + if (!list) { 313 + return; 314 + } 315 + 316 + this.startScan(startTime, requestedDays); 317 + 318 + let posts = await accountAPI.loadListTimeline(list, requestedDays, { 319 + onPageLoad: onPageLoad, 320 + keepLastPage: true 321 + }); 322 + 323 + this.updateResultsTable(posts, startTime, requestedDays, { showReposts: false }); 324 + } else if (scanType == 'users') { 325 + let dids = Object.keys(this.selectedUsers); 326 + 327 + if (dids.length == 0) { 328 + return; 329 + } 330 + 331 + this.startScan(startTime, requestedDays); 332 + this.resetUserProgress(dids); 333 + 334 + let requests = dids.map(did => this.appView.loadUserTimeline(did, requestedDays, { 335 + filter: 'posts_and_author_threads', 336 + onPageLoad: (data) => { 337 + if (this.scanStartTime != startTime) { 338 + return { cancel: true }; 339 + } 340 + 341 + this.updateUserProgress(did, data, startTime, requestedDays); 342 + }, 343 + keepLastPage: true 344 + })); 345 + 346 + let datasets = await Promise.all(requests); 347 + let posts = datasets.flat(); 348 + 349 + this.updateResultsTable(posts, startTime, requestedDays, { 350 + showTotal: false, 351 + showPercentages: false, 352 + countFetchedDays: false, 353 + users: Object.values(this.selectedUsers) 354 + }); 355 + } else { 356 + this.startScan(startTime, requestedDays); 357 + 358 + let posts = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, { 359 + filter: 'posts_no_replies', 360 + onPageLoad: onPageLoad, 361 + keepLastPage: true 362 + }); 363 + 364 + this.updateResultsTable(posts, startTime, requestedDays, { showTotal: false, showPercentages: false }); 365 + } 366 + } 367 + 368 + /** @param {json[]} dataPage, @param {number} startTime */ 369 + 370 + updateProgress(dataPage, startTime) { 371 + let last = dataPage.at(-1); 372 + 373 + if (!last) { return } 374 + 375 + let lastDate = feedPostTime(last); 376 + let daysBack = (startTime - lastDate) / 86400 / 1000; 377 + 378 + this.progressBar.value = daysBack; 379 + } 380 + 381 + /** @param {string[]} dids */ 382 + 383 + resetUserProgress(dids) { 384 + this.userProgress = {}; 385 + 386 + for (let did of dids) { 387 + this.userProgress[did] = { pages: 0, progress: 0 }; 388 + } 389 + } 390 + 391 + /** @param {string} did, @param {json[]} dataPage, @param {number} startTime, @param {number} requestedDays */ 392 + 393 + updateUserProgress(did, dataPage, startTime, requestedDays) { 394 + let last = dataPage.at(-1); 395 + 396 + if (!last) { return } 397 + 398 + let lastDate = feedPostTime(last); 399 + let daysBack = (startTime - lastDate) / 86400 / 1000; 400 + 401 + this.userProgress[did].pages += 1; 402 + this.userProgress[did].progress = Math.min(daysBack / requestedDays, 1.0); 403 + 404 + let expectedPages = Object.values(this.userProgress).map(x => x.pages / x.progress); 405 + let known = expectedPages.filter(x => !isNaN(x)); 406 + let expectedTotalPages = known.reduce((a, b) => a + b) / known.length * expectedPages.length; 407 + let fetchedPages = Object.values(this.userProgress).map(x => x.pages).reduce((a, b) => a + b); 408 + 409 + this.progressBar.value = Math.max(this.progressBar.value, (fetchedPages / expectedTotalPages) * requestedDays); 410 + } 411 + 412 + /** @param {json} a, @param {json} b, @returns {number} */ 413 + 414 + sortUserRows(a, b) { 415 + let asum = a.own + a.reposts; 416 + let bsum = b.own + b.reposts; 417 + 418 + if (asum < bsum) { 419 + return 1; 420 + } else if (asum > bsum) { 421 + return -1; 422 + } else { 423 + return 0; 424 + } 425 + } 426 + 427 + /** 428 + * @param {json[]} posts 429 + * @param {number} startTime 430 + * @param {number} requestedDays 431 + * @param {{ 432 + * showTotal?: boolean, 433 + * showPercentages?: boolean, 434 + * showReposts?: boolean, 435 + * countFetchedDays?: boolean, 436 + * users?: json[] 437 + * }} [options] 438 + * @returns {Promise<void>} 439 + */ 440 + 441 + async updateResultsTable(posts, startTime, requestedDays, options = {}) { 442 + if (this.scanStartTime != startTime) { 443 + return; 444 + } 445 + 446 + let now = new Date().getTime(); 447 + 448 + if (now - startTime < 100) { 449 + // artificial UI delay in case scan finishes immediately 450 + await new Promise(resolve => setTimeout(resolve, 100)); 451 + } 452 + 453 + let users = {}; 454 + let total = 0; 455 + let allReposts = 0; 456 + let allNormalPosts = 0; 457 + 458 + let last = posts.at(-1); 459 + 460 + if (!last) { 461 + this.stopScan(); 462 + return; 463 + } 464 + 465 + let daysBack; 466 + 467 + if (options.countFetchedDays !== false) { 468 + let lastDate = feedPostTime(last); 469 + let fetchedDays = (startTime - lastDate) / 86400 / 1000; 470 + 471 + if (Math.ceil(fetchedDays) < requestedDays) { 472 + this.scanInfo.innerText = `๐Ÿ•“ Showing data from ${Math.round(fetchedDays)} days (the timeline only goes that far):`; 473 + this.scanInfo.style.display = 'block'; 474 + } 475 + 476 + daysBack = Math.min(requestedDays, fetchedDays); 477 + } else { 478 + daysBack = requestedDays; 479 + } 480 + 481 + let timeLimit = startTime - requestedDays * 86400 * 1000; 482 + posts = posts.filter(x => (feedPostTime(x) > timeLimit)); 483 + posts.reverse(); 484 + 485 + if (options.users) { 486 + for (let user of options.users) { 487 + users[user.handle] = { handle: user.handle, own: 0, reposts: 0, avatar: user.avatar }; 488 + } 489 + } 490 + 491 + let ownThreads = new Set(); 492 + 493 + for (let item of posts) { 494 + if (item.reply) { 495 + if (!ownThreads.has(item.reply.parent.uri)) { 496 + continue; 497 + } 498 + } 499 + 500 + let user = item.reason ? item.reason.by : item.post.author; 501 + let handle = user.handle; 502 + users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar }; 503 + total += 1; 504 + 505 + if (item.reason) { 506 + users[handle].reposts += 1; 507 + allReposts += 1; 508 + } else { 509 + users[handle].own += 1; 510 + allNormalPosts += 1; 511 + ownThreads.add(item.post.uri); 512 + } 513 + } 514 + 515 + let headRow = $tag('tr'); 516 + 517 + if (options.showReposts !== false) { 518 + headRow.append( 519 + $tag('th', { text: '#' }), 520 + $tag('th', { text: 'Handle' }), 521 + $tag('th', { text: 'All posts /d' }), 522 + $tag('th', { text: 'Own posts /d' }), 523 + $tag('th', { text: 'Reposts /d' }) 524 + ); 525 + } else { 526 + headRow.append( 527 + $tag('th', { text: '#' }), 528 + $tag('th', { text: 'Handle' }), 529 + $tag('th', { text: 'Posts /d' }), 530 + ); 531 + } 532 + 533 + if (options.showPercentages !== false) { 534 + headRow.append($tag('th', { text: '% of timeline' })); 535 + } 536 + 537 + this.tableHead.append(headRow); 538 + 539 + if (options.showTotal !== false) { 540 + let tr = $tag('tr.total'); 541 + 542 + tr.append( 543 + $tag('td.no', { text: '' }), 544 + $tag('td.handle', { text: 'Total:' }), 545 + 546 + (options.showReposts !== false) ? 547 + $tag('td', { text: (total / daysBack).toFixed(1) }) : '', 548 + 549 + $tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }), 550 + 551 + (options.showReposts !== false) ? 552 + $tag('td', { text: (allReposts / daysBack).toFixed(1) }) : '' 553 + ); 554 + 555 + if (options.showPercentages !== false) { 556 + tr.append($tag('td.percent', { text: '' })); 557 + } 558 + 559 + this.tableBody.append(tr); 560 + } 561 + 562 + let sorted = Object.values(users).sort(this.sortUserRows); 563 + 564 + for (let i = 0; i < sorted.length; i++) { 565 + let user = sorted[i]; 566 + let tr = $tag('tr'); 567 + 568 + tr.append( 569 + $tag('td.no', { text: i + 1 }), 570 + $tag('td.handle', { 571 + html: `<img class="avatar" src="${user.avatar}"> ` + 572 + `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>` 573 + }), 574 + 575 + (options.showReposts !== false) ? 576 + $tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '', 577 + 578 + $tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : 'โ€“' }), 579 + 580 + (options.showReposts !== false) ? 581 + $tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : 'โ€“' }) : '' 582 + ); 583 + 584 + if (options.showPercentages !== false) { 585 + tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' })); 586 + } 587 + 588 + this.tableBody.append(tr); 589 + } 590 + 591 + this.table.style.display = 'table'; 592 + this.stopScan(); 593 + } 594 + 595 + /** @param {number} startTime, @param {number} requestedDays */ 596 + 597 + startScan(startTime, requestedDays) { 598 + this.submitButton.value = 'Cancel'; 599 + 600 + this.progressBar.max = requestedDays; 601 + this.progressBar.value = 0; 602 + this.progressBar.style.display = 'inline'; 603 + 604 + this.table.style.display = 'none'; 605 + this.tableHead.innerHTML = ''; 606 + this.tableBody.innerHTML = ''; 607 + 608 + this.scanStartTime = startTime; 609 + this.scanInfo.style.display = 'none'; 610 + } 611 + 612 + stopScan() { 613 + this.submitButton.value = 'Start scan'; 614 + this.scanStartTime = undefined; 615 + this.progressBar.style.display = 'none'; 616 + } 617 + }
+423
private_search_page.js
···
··· 1 + class PrivateSearchPage { 2 + 3 + /** @type {number | undefined} */ 4 + fetchStartTime; 5 + 6 + /** @type {number | undefined} */ 7 + importTimer; 8 + 9 + /** @type {string | undefined} */ 10 + lycanImportStatus; 11 + 12 + constructor() { 13 + this.pageElement = $id('private_search_page'); 14 + 15 + this.header = $(this.pageElement.querySelector('h2')); 16 + 17 + this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement); 18 + this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement); 19 + this.progressBar = $(this.pageElement.querySelector('input[type="submit"] + progress'), HTMLProgressElement); 20 + this.archiveStatus = $(this.pageElement.querySelector('.archive-status')); 21 + 22 + this.searchLine = $(this.pageElement.querySelector('.search')); 23 + this.searchField = $(this.pageElement.querySelector('.search-query'), HTMLInputElement); 24 + this.searchForm = $(this.pageElement.querySelector('.search-form'), HTMLFormElement); 25 + this.results = $(this.pageElement.querySelector('.results')); 26 + 27 + this.timelineSearch = $(this.pageElement.querySelector('.timeline-search')); 28 + this.timelineSearchForm = $(this.pageElement.querySelector('.timeline-search form'), HTMLFormElement); 29 + this.searchCollections = $(this.pageElement.querySelector('.search-collections')); 30 + 31 + this.lycanImportSection = $(this.pageElement.querySelector('.lycan-import')); 32 + this.lycanImportForm = $(this.pageElement.querySelector('.lycan-import form'), HTMLFormElement); 33 + this.importProgress = $(this.pageElement.querySelector('.import-progress')); 34 + this.importProgressBar = $(this.pageElement.querySelector('.import-progress progress'), HTMLProgressElement); 35 + this.importStatusLabel = $(this.pageElement.querySelector('.import-status')); 36 + this.importStatusPosition = $(this.pageElement.querySelector('.import-progress output')); 37 + 38 + this.isCheckingStatus = false; 39 + this.timelinePosts = []; 40 + 41 + this.setupEvents(); 42 + 43 + let params = new URLSearchParams(location.search); 44 + this.mode = params.get('mode'); 45 + let lycan = params.get('lycan'); 46 + 47 + if (lycan == 'local') { 48 + this.localLycan = new BlueskyAPI('http://localhost:3000', false); 49 + } else if (lycan) { 50 + this.lycanAddress = `did:web:${lycan}#lycan`; 51 + } else { 52 + this.lycanAddress = 'did:web:lycan.feeds.blue#lycan'; 53 + } 54 + } 55 + 56 + setupEvents() { 57 + this.timelineSearchForm.addEventListener('submit', (e) => { 58 + e.preventDefault(); 59 + 60 + if (!this.fetchStartTime) { 61 + this.fetchTimeline(); 62 + } else { 63 + this.stopFetch(); 64 + } 65 + }); 66 + 67 + this.rangeInput.addEventListener('input', (e) => { 68 + let days = parseInt(this.rangeInput.value, 10); 69 + let label = $(this.pageElement.querySelector('input[type=range] + label')); 70 + label.innerText = (days == 1) ? '1 day' : `${days} days`; 71 + }); 72 + 73 + this.searchField.addEventListener('keydown', (e) => { 74 + if (e.key == 'Enter') { 75 + e.preventDefault(); 76 + 77 + let query = this.searchField.value.trim().toLowerCase(); 78 + 79 + if (this.mode == 'likes') { 80 + this.searchInLycan(query); 81 + } else { 82 + this.searchInTimeline(query); 83 + } 84 + } 85 + }); 86 + 87 + this.lycanImportForm.addEventListener('submit', (e) => { 88 + e.preventDefault(); 89 + this.startLycanImport(); 90 + }); 91 + } 92 + 93 + /** @returns {number} */ 94 + 95 + selectedDaysRange() { 96 + return parseInt(this.rangeInput.value, 10); 97 + } 98 + 99 + show() { 100 + this.pageElement.style.display = 'block'; 101 + 102 + if (this.mode == 'likes') { 103 + this.header.innerText = 'Archive search'; 104 + this.timelineSearch.style.display = 'none'; 105 + this.searchCollections.style.display = 'block'; 106 + this.searchLine.style.display = 'block'; 107 + this.lycanImportSection.style.display = 'none'; 108 + this.checkLycanImportStatus(); 109 + } else { 110 + this.header.innerText = 'Timeline search'; 111 + this.timelineSearch.style.display = 'block'; 112 + this.searchCollections.style.display = 'none'; 113 + this.lycanImportSection.style.display = 'none'; 114 + } 115 + } 116 + 117 + /** @returns {Promise<void>} */ 118 + 119 + async checkLycanImportStatus() { 120 + if (this.isCheckingStatus) { 121 + return; 122 + } 123 + 124 + this.isCheckingStatus = true; 125 + 126 + try { 127 + let response = await this.getImportStatus(); 128 + this.showImportStatus(response); 129 + } catch (error) { 130 + this.showImportError(`Couldn't check import status: ${error}`); 131 + } finally { 132 + this.isCheckingStatus = false; 133 + } 134 + } 135 + 136 + /** @returns {Promise<json>} */ 137 + 138 + async getImportStatus() { 139 + if (this.localLycan) { 140 + return await this.localLycan.getRequest('blue.feeds.lycan.getImportStatus', { user: accountAPI.user.did }); 141 + } else { 142 + return await accountAPI.getRequest('blue.feeds.lycan.getImportStatus', null, { 143 + headers: { 'atproto-proxy': this.lycanAddress } 144 + }); 145 + } 146 + } 147 + 148 + /** @param {json} info */ 149 + 150 + showImportStatus(info) { 151 + console.log(info); 152 + 153 + if (!info.status) { 154 + this.showImportError("Error checking import status"); 155 + return; 156 + } 157 + 158 + this.lycanImportStatus = info.status; 159 + 160 + if (info.status == 'not_started') { 161 + this.lycanImportSection.style.display = 'block'; 162 + this.lycanImportForm.style.display = 'block'; 163 + this.importProgress.style.display = 'none'; 164 + this.searchField.disabled = true; 165 + 166 + this.stopImportTimer(); 167 + } else if (info.status == 'in_progress' || info.status == 'scheduled' || info.status == 'requested') { 168 + this.lycanImportSection.style.display = 'block'; 169 + this.lycanImportForm.style.display = 'none'; 170 + this.importProgress.style.display = 'block'; 171 + this.searchField.disabled = true; 172 + 173 + this.showImportProgress(info); 174 + this.startImportTimer(); 175 + } else if (info.status == 'finished') { 176 + this.lycanImportForm.style.display = 'none'; 177 + this.importProgress.style.display = 'block'; 178 + this.searchField.disabled = false; 179 + 180 + this.showImportProgress({ status: 'finished', progress: 1.0 }); 181 + this.stopImportTimer(); 182 + } else { 183 + this.showImportError("Error checking import status"); 184 + this.stopImportTimer(); 185 + } 186 + } 187 + 188 + /** @param {json} info */ 189 + 190 + showImportProgress(info) { 191 + let progress = Math.max(0, Math.min(info.progress || 0)); 192 + this.importProgressBar.value = progress; 193 + this.importProgressBar.style.display = 'inline'; 194 + 195 + let percent = Math.round(progress * 100); 196 + this.importStatusPosition.innerText = `${percent}%`; 197 + 198 + if (info.progress == 1.0) { 199 + this.importStatusLabel.innerText = `Import complete โœ“`; 200 + } else if (info.position) { 201 + let date = new Date(info.position).toLocaleString(window.dateLocale, { day: 'numeric', month: 'short', year: 'numeric' }); 202 + this.importStatusLabel.innerText = `Downloaded data until: ${date}`; 203 + } else if (info.status == 'requested') { 204 + this.importStatusLabel.innerText = 'Requesting importโ€ฆ'; 205 + } else { 206 + this.importStatusLabel.innerText = 'Import startedโ€ฆ'; 207 + } 208 + } 209 + 210 + /** @param {string} message */ 211 + 212 + showImportError(message) { 213 + this.lycanImportSection.style.display = 'block'; 214 + this.lycanImportForm.style.display = 'none'; 215 + this.importProgress.style.display = 'block'; 216 + this.searchField.disabled = true; 217 + 218 + this.importStatusLabel.innerText = message; 219 + this.stopImportTimer(); 220 + } 221 + 222 + startImportTimer() { 223 + if (this.importTimer) { 224 + return; 225 + } 226 + 227 + this.importTimer = setInterval(() => { 228 + this.checkLycanImportStatus(); 229 + }, 3000); 230 + } 231 + 232 + stopImportTimer() { 233 + if (this.importTimer) { 234 + clearInterval(this.importTimer); 235 + this.importTimer = undefined; 236 + } 237 + } 238 + 239 + /** @returns {Promise<void>} */ 240 + 241 + async startLycanImport() { 242 + this.showImportStatus({ status: 'requested' }); 243 + 244 + try { 245 + if (this.localLycan) { 246 + await this.localLycan.postRequest('blue.feeds.lycan.startImport', { 247 + user: accountAPI.user.did 248 + }); 249 + } else { 250 + await accountAPI.postRequest('blue.feeds.lycan.startImport', null, { 251 + headers: { 'atproto-proxy': this.lycanAddress } 252 + }); 253 + } 254 + 255 + this.startImportTimer(); 256 + } catch (err) { 257 + console.error('Failed to start Lycan import', err); 258 + this.showImportError(`Import failed: ${err}`); 259 + } 260 + } 261 + 262 + /** @returns {Promise<void>} */ 263 + 264 + async fetchTimeline() { 265 + this.submitButton.value = 'Cancel'; 266 + 267 + let requestedDays = this.selectedDaysRange(); 268 + 269 + this.progressBar.max = requestedDays; 270 + this.progressBar.value = 0; 271 + this.progressBar.style.display = 'inline'; 272 + 273 + let startTime = new Date().getTime(); 274 + this.fetchStartTime = startTime; 275 + 276 + let timeline = await accountAPI.loadHomeTimeline(requestedDays, { 277 + onPageLoad: (data) => { 278 + if (this.fetchStartTime != startTime) { 279 + return { cancel: true }; 280 + } 281 + 282 + this.updateProgress(data, startTime); 283 + } 284 + }); 285 + 286 + if (this.fetchStartTime != startTime) { 287 + return; 288 + } 289 + 290 + let last = timeline.at(-1); 291 + let daysBack; 292 + 293 + if (last) { 294 + let lastDate = feedPostTime(last); 295 + daysBack = Math.round((startTime - lastDate) / 86400 / 1000); 296 + } else { 297 + daysBack = 0; 298 + } 299 + 300 + this.timelinePosts = timeline; 301 + 302 + this.archiveStatus.innerText = "Timeline archive fetched: " + ((daysBack == 1) ? '1 day' : `${daysBack} days`); 303 + this.searchLine.style.display = 'block'; 304 + 305 + this.submitButton.value = 'Fetch timeline'; 306 + this.progressBar.style.display = 'none'; 307 + this.fetchStartTime = undefined; 308 + } 309 + 310 + /** @param {string} query */ 311 + 312 + searchInTimeline(query) { 313 + this.results.innerHTML = ''; 314 + 315 + if (query.length == 0) { 316 + return; 317 + } 318 + 319 + let matching = this.timelinePosts 320 + .filter(x => x.post.record.text.toLowerCase().includes(query)) 321 + .map(x => Post.parseFeedPost(x)); 322 + 323 + for (let post of matching) { 324 + let postView = new PostComponent(post, 'feed').buildElement(); 325 + this.results.appendChild(postView); 326 + } 327 + } 328 + 329 + /** @param {string} query */ 330 + 331 + searchInLycan(query) { 332 + if (query.length == 0 || this.lycanImportStatus != 'finished') { 333 + return; 334 + } 335 + 336 + this.results.innerHTML = ''; 337 + this.lycanImportSection.style.display = 'none'; 338 + 339 + let collection = this.searchForm.elements['collection'].value; 340 + 341 + let loading = $tag('p', { text: "..." }); 342 + this.results.append(loading); 343 + 344 + let isLoading = false; 345 + let firstPageLoaded = false; 346 + let cursor; 347 + let finished = false; 348 + 349 + Paginator.loadInPages(async () => { 350 + if (isLoading || finished) { return; } 351 + isLoading = true; 352 + 353 + let response; 354 + 355 + if (this.localLycan) { 356 + let params = { collection, query, user: accountAPI.user.did }; 357 + if (cursor) params.cursor = cursor; 358 + 359 + response = await this.localLycan.getRequest('blue.feeds.lycan.searchPosts', params); 360 + } else { 361 + let params = { collection, query }; 362 + if (cursor) params.cursor = cursor; 363 + 364 + response = await accountAPI.getRequest('blue.feeds.lycan.searchPosts', params, { 365 + headers: { 'atproto-proxy': this.lycanAddress } 366 + }); 367 + } 368 + 369 + if (response.posts.length == 0) { 370 + let p = $tag('p.results-end', { text: firstPageLoaded ? "No more results." : "No results." }); 371 + loading.remove(); 372 + this.results.append(p); 373 + 374 + isLoading = false; 375 + finished = true; 376 + return; 377 + } 378 + 379 + let records = await accountAPI.loadPosts(response.posts); 380 + let posts = records.map(x => new Post(x)); 381 + 382 + if (!firstPageLoaded) { 383 + loading.remove(); 384 + firstPageLoaded = true; 385 + } 386 + 387 + for (let post of posts) { 388 + let component = new PostComponent(post, 'feed'); 389 + let postView = component.buildElement(); 390 + this.results.appendChild(postView); 391 + 392 + component.highlightSearchResults(response.terms); 393 + } 394 + 395 + isLoading = false; 396 + cursor = response.cursor; 397 + 398 + if (!cursor) { 399 + finished = true; 400 + this.results.append("No more results."); 401 + } 402 + }); 403 + } 404 + 405 + /** @param {json[]} dataPage, @param {number} startTime */ 406 + 407 + updateProgress(dataPage, startTime) { 408 + let last = dataPage.at(-1); 409 + 410 + if (!last) { return } 411 + 412 + let lastDate = feedPostTime(last); 413 + let daysBack = (startTime - lastDate) / 86400 / 1000; 414 + 415 + this.progressBar.value = daysBack; 416 + } 417 + 418 + stopFetch() { 419 + this.submitButton.value = 'Fetch timeline'; 420 + this.progressBar.style.display = 'none'; 421 + this.fetchStartTime = undefined; 422 + } 423 + }
+68 -393
skythread.js
··· 1 function init() { 2 - let document = /** @type {AnyElement} */ (/** @type {unknown} */ (window.document)); 3 - let html = /** @type {AnyElement} */ (/** @type {unknown} */ (window.document.body.parentNode)); 4 - 5 window.dateLocale = localStorage.getItem('locale') || undefined; 6 window.isIncognito = !!localStorage.getItem('incognito'); 7 window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null'); 8 9 - window.loginDialog = document.querySelector('#login'); 10 - window.accountMenu = document.querySelector('#account_menu'); 11 12 window.avatarPreloader = buildAvatarPreloader(); 13 14 - html.addEventListener('click', (e) => { 15 - $id('account_menu').style.visibility = 'hidden'; 16 - }); 17 18 - document.querySelector('#search form').addEventListener('submit', (e) => { 19 e.preventDefault(); 20 submitSearch(); 21 }); 22 23 for (let dialog of document.querySelectorAll('.dialog')) { 24 dialog.addEventListener('click', (e) => { 25 - if (e.target === e.currentTarget) { 26 hideDialog(dialog); 27 } else { 28 e.stopPropagation(); 29 } 30 }); 31 32 - dialog.querySelector('.close')?.addEventListener('click', (e) => { 33 hideDialog(dialog); 34 }); 35 } 36 37 - document.querySelector('#login .info a').addEventListener('click', (e) => { 38 e.preventDefault(); 39 toggleLoginInfo(); 40 }); 41 42 - document.querySelector('#login form').addEventListener('submit', (e) => { 43 e.preventDefault(); 44 submitLogin(); 45 }); 46 47 - document.querySelector('#biohazard_show').addEventListener('click', (e) => { 48 e.preventDefault(); 49 50 window.biohazardEnabled = true; ··· 55 window.loadInfohazard = undefined; 56 } 57 58 - let target = /** @type {AnyElement} */ (/** @type {unknown} */ (e.target)); 59 60 hideDialog(target.closest('.dialog')); 61 }); 62 63 - document.querySelector('#biohazard_hide').addEventListener('click', (e) => { 64 e.preventDefault(); 65 66 window.biohazardEnabled = false; 67 localStorage.setItem('biohazard', 'false'); 68 - toggleMenuButton('biohazard', false); 69 70 for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) { 71 - p.style.display = 'none'; 72 } 73 74 - let target = /** @type {AnyElement} */ (/** @type {unknown} */ (e.target)); 75 76 hideDialog(target.closest('.dialog')); 77 }); 78 79 - document.querySelector('#account').addEventListener('click', (e) => { 80 - toggleAccountMenu(); 81 - e.stopPropagation(); 82 - }); 83 - 84 - accountMenu.addEventListener('click', (e) => { 85 - e.stopPropagation(); 86 - }); 87 - 88 - accountMenu.querySelector('a[data-action=biohazard]').addEventListener('click', (e) => { 89 - e.preventDefault(); 90 - 91 - let hazards = document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post'); 92 - 93 - if (window.biohazardEnabled === false) { 94 - window.biohazardEnabled = true; 95 - localStorage.setItem('biohazard', 'true'); 96 - toggleMenuButton('biohazard', true); 97 - Array.from(hazards).forEach(p => { p.style.display = 'block' }); 98 - } else { 99 - window.biohazardEnabled = false; 100 - localStorage.setItem('biohazard', 'false'); 101 - toggleMenuButton('biohazard', false); 102 - Array.from(hazards).forEach(p => { p.style.display = 'none' }); 103 - } 104 - }); 105 - 106 - accountMenu.querySelector('a[data-action=incognito]').addEventListener('click', (e) => { 107 - e.preventDefault(); 108 - 109 - if (isIncognito) { 110 - localStorage.removeItem('incognito'); 111 - } else { 112 - localStorage.setItem('incognito', '1'); 113 - } 114 - 115 - location.reload(); 116 - }); 117 - 118 - accountMenu.querySelector('a[data-action=login]').addEventListener('click', (e) => { 119 - e.preventDefault(); 120 - toggleDialog(loginDialog); 121 - $id('account_menu').style.visibility = 'hidden'; 122 - }); 123 - 124 - accountMenu.querySelector('a[data-action=logout]').addEventListener('click', (e) => { 125 - e.preventDefault(); 126 - logOut(); 127 - }); 128 - 129 window.appView = new BlueskyAPI('api.bsky.app', false); 130 window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false); 131 window.accountAPI = new BlueskyAPI(undefined, true); 132 133 if (accountAPI.isLoggedIn) { 134 accountAPI.host = accountAPI.user.pdsEndpoint; 135 - hideMenuButton('login'); 136 137 if (!isIncognito) { 138 window.api = accountAPI; 139 - showLoggedInStatus(true, api.user.avatar); 140 } else { 141 window.api = appView; 142 - showLoggedInStatus('incognito'); 143 - toggleMenuButton('incognito', true); 144 } 145 } else { 146 window.api = appView; 147 - hideMenuButton('logout'); 148 - hideMenuButton('incognito'); 149 } 150 151 - toggleMenuButton('biohazard', window.biohazardEnabled !== false); 152 153 parseQueryParams(); 154 } 155 156 function parseQueryParams() { 157 let params = new URLSearchParams(location.search); 158 - let query = params.get('q'); 159 - let author = params.get('author'); 160 - let post = params.get('post'); 161 - let quotes = params.get('quotes'); 162 - let hash = params.get('hash'); 163 - let page = params.get('page'); 164 165 if (quotes) { 166 showLoader(); ··· 168 } else if (hash) { 169 showLoader(); 170 loadHashtagPage(decodeURIComponent(hash)); 171 - } else if (query) { 172 showLoader(); 173 - loadThreadByURL(decodeURIComponent(query)); 174 } else if (author && post) { 175 showLoader(); 176 - loadThreadById(decodeURIComponent(author), decodeURIComponent(post)); 177 } else if (page) { 178 openPage(page); 179 } else { ··· 181 } 182 } 183 184 - /** @param {AnyPost} post, @returns {AnyElement} */ 185 - 186 - function buildParentLink(post) { 187 - let p = $tag('p.back'); 188 - 189 - if (post instanceof BlockedPost) { 190 - let element = new PostComponent(post, 'parent').buildElement(); 191 - element.className = 'back'; 192 - element.querySelector('p.blocked-header span').innerText = 'Parent post blocked'; 193 - return element; 194 - } else if (post instanceof MissingPost) { 195 - p.innerHTML = `<i class="fa-solid fa-ban"></i> parent post has been deleted`; 196 - } else { 197 - let url = linkToPostThread(post); 198 - p.innerHTML = `<i class="fa-solid fa-reply"></i><a href="${url}">See parent post (@${post.author.handle})</a>`; 199 - } 200 - 201 - return p; 202 - } 203 - 204 /** @returns {IntersectionObserver} */ 205 206 function buildAvatarPreloader() { ··· 226 } 227 228 function showSearch() { 229 - $id('search').style.visibility = 'visible'; 230 - $id('search').querySelector('input[type=text]').focus(); 231 } 232 233 function hideSearch() { ··· 259 } 260 } 261 262 - function toggleLoginInfo(event) { 263 $id('login').classList.toggle('expanded'); 264 } 265 266 - function toggleAccountMenu() { 267 - let menu = $id('account_menu'); 268 - menu.style.visibility = (menu.style.visibility == 'visible') ? 'hidden' : 'visible'; 269 - } 270 - 271 - /** @param {string} buttonName */ 272 - 273 - function showMenuButton(buttonName) { 274 - let button = accountMenu.querySelector(`a[data-action=${buttonName}]`); 275 - button.parentNode.style.display = 'list-item'; 276 - } 277 - 278 - /** @param {string} buttonName */ 279 - 280 - function hideMenuButton(buttonName) { 281 - let button = accountMenu.querySelector(`a[data-action=${buttonName}]`); 282 - button.parentNode.style.display = 'none'; 283 - } 284 - 285 - /** @param {string} buttonName, @param {boolean} state */ 286 - 287 - function toggleMenuButton(buttonName, state) { 288 - let button = accountMenu.querySelector(`a[data-action=${buttonName}]`); 289 - button.querySelector('.check').style.display = (state) ? 'inline' : 'none'; 290 - } 291 - 292 - /** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */ 293 - 294 - function showLoggedInStatus(loggedIn, avatar) { 295 - let account = $id('account'); 296 - 297 - if (loggedIn === true && avatar) { 298 - let button = account.querySelector('i'); 299 - 300 - let img = $tag('img.avatar', { src: avatar }); 301 - img.style.display = 'none'; 302 - img.addEventListener('load', () => { 303 - button.remove(); 304 - img.style.display = 'inline'; 305 - }); 306 - img.addEventListener('error', () => { 307 - showLoggedInStatus(true, null); 308 - }) 309 - 310 - account.append(img); 311 - } else if (loggedIn === false) { 312 - $id('account').innerHTML = `<i class="fa-regular fa-user-circle fa-xl"></i>`; 313 - } else if (loggedIn === 'incognito') { 314 - $id('account').innerHTML = `<i class="fa-solid fa-user-secret fa-lg"></i>`; 315 - } else { 316 - account.innerHTML = `<i class="fa-solid fa-user-circle fa-xl"></i>`; 317 - } 318 - } 319 - 320 function submitLogin() { 321 - let handle = $id('login_handle'); 322 - let password = $id('login_password'); 323 let submit = $id('login_submit'); 324 let cloudy = $id('cloudy'); 325 326 if (submit.style.display == 'none') { return } 327 328 - handle.blur(); 329 - password.blur(); 330 331 submit.style.display = 'none'; 332 cloudy.style.display = 'inline-block'; 333 334 - logIn(handle.value, password.value).then((pds) => { 335 window.api = pds; 336 window.accountAPI = pds; 337 338 hideDialog(loginDialog); 339 submit.style.display = 'inline'; 340 cloudy.style.display = 'none'; 341 342 - loadCurrentUserAvatar(); 343 - showMenuButton('logout'); 344 - showMenuButton('incognito'); 345 - hideMenuButton('login'); 346 347 let params = new URLSearchParams(location.search); 348 let page = params.get('page'); ··· 385 return pds; 386 } 387 388 - function loadCurrentUserAvatar() { 389 - api.loadCurrentUserAvatar().then((url) => { 390 - showLoggedInStatus(true, url); 391 - }).catch((error) => { 392 - console.log(error); 393 - showLoggedInStatus(true, null); 394 - }); 395 - } 396 - 397 function logOut() { 398 accountAPI.resetTokens(); 399 localStorage.removeItem('incognito'); ··· 401 } 402 403 function submitSearch() { 404 - let url = $id('search').querySelector('input[name=q]').value.trim(); 405 406 if (!url) { return } 407 ··· 430 } 431 } 432 433 function openPage(page) { 434 if (!accountAPI.isLoggedIn) { 435 - toggleDialog(loginDialog); 436 return; 437 } 438 439 if (page == 'notif') { 440 - showLoader(); 441 - showNotificationsPage(); 442 } 443 } 444 445 - function showNotificationsPage() { 446 - document.title = `Notifications - Skythread`; 447 - 448 - let isLoading = false; 449 - let firstPageLoaded = false; 450 - let finished = false; 451 - let cursor; 452 - 453 - loadInPages((next) => { 454 - if (isLoading || finished) { return; } 455 - isLoading = true; 456 - 457 - accountAPI.loadMentions(cursor).then(data => { 458 - let posts = data.posts.map(x => new Post(x)); 459 - 460 - if (posts.length > 0) { 461 - if (!firstPageLoaded) { 462 - hideLoader(); 463 - firstPageLoaded = true; 464 - 465 - let header = $tag('header'); 466 - let h2 = $tag('h2', { text: "Replies & Mentions:" }); 467 - header.append(h2); 468 - $id('thread').appendChild(header); 469 - $id('thread').classList.add('notifications'); 470 - } 471 - 472 - for (let post of posts) { 473 - if (post.parentReference) { 474 - let p = $tag('p.back'); 475 - p.innerHTML = `<i class="fa-solid fa-reply"></i> `; 476 - 477 - let { repo, rkey } = atURI(post.parentReference.uri); 478 - let url = linkToPostById(repo, rkey); 479 - let parentLink = $tag('a', { href: url }); 480 - p.append(parentLink); 481 - 482 - if (repo == api.user.did) { 483 - parentLink.innerText = 'Reply to you'; 484 - } else { 485 - parentLink.innerText = 'Reply'; 486 - api.fetchHandleForDid(repo).then(handle => { 487 - parentLink.innerText = `Reply to @${handle}`; 488 - }); 489 - } 490 - 491 - $id('thread').appendChild(p); 492 - } 493 - 494 - let postView = new PostComponent(post, 'feed').buildElement(); 495 - $id('thread').appendChild(postView); 496 - } 497 - } 498 - 499 - isLoading = false; 500 - cursor = data.cursor; 501 - 502 - if (!cursor) { 503 - finished = true; 504 - } else if (posts.length == 0) { 505 - next(); 506 - } 507 - }).catch(error => { 508 - hideLoader(); 509 - console.log(error); 510 - isLoading = false; 511 - }); 512 - }); 513 - } 514 - 515 /** @param {Post} post */ 516 517 function setPageTitle(post) { ··· 529 let finished = false; 530 let cursor; 531 532 - loadInPages(() => { 533 if (isLoading || finished) { return; } 534 isLoading = true; 535 ··· 577 let cursor; 578 let finished = false; 579 580 - loadInPages(() => { 581 if (isLoading || finished) { return; } 582 isLoading = true; 583 ··· 628 }); 629 }); 630 } 631 - 632 - /** @param {Function} callback */ 633 - 634 - function loadInPages(callback) { 635 - let loadIfNeeded = () => { 636 - if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 500) { 637 - callback(loadIfNeeded); 638 - } 639 - }; 640 - 641 - callback(loadIfNeeded); 642 - 643 - document.addEventListener('scroll', loadIfNeeded); 644 - const resizeObserver = new ResizeObserver(loadIfNeeded); 645 - resizeObserver.observe(document.body); 646 - } 647 - 648 - /** @param {string} url */ 649 - 650 - function loadThreadByURL(url) { 651 - let loadThread = url.startsWith('at://') ? api.loadThreadByAtURI(url) : api.loadThreadByURL(url); 652 - 653 - loadThread.then(json => { 654 - displayThread(json); 655 - }).catch(error => { 656 - hideLoader(); 657 - showError(error); 658 - }); 659 - } 660 - 661 - /** @param {string} author, @param {string} rkey */ 662 - 663 - function loadThreadById(author, rkey) { 664 - api.loadThreadById(author, rkey).then(json => { 665 - displayThread(json); 666 - }).catch(error => { 667 - hideLoader(); 668 - showError(error); 669 - }); 670 - } 671 - 672 - /** @param {json} json */ 673 - 674 - function displayThread(json) { 675 - let root = Post.parseThreadPost(json.thread); 676 - window.root = root; 677 - window.subtreeRoot = root; 678 - 679 - let loadQuoteCount; 680 - 681 - if (root instanceof Post) { 682 - setPageTitle(root); 683 - loadQuoteCount = blueAPI.getQuoteCount(root.uri); 684 - 685 - if (root.parent) { 686 - let p = buildParentLink(root.parent); 687 - $id('thread').appendChild(p); 688 - } 689 - } 690 - 691 - let component = new PostComponent(root, 'thread'); 692 - let view = component.buildElement(); 693 - hideLoader(); 694 - $id('thread').appendChild(view); 695 - 696 - loadQuoteCount?.then(count => { 697 - if (count > 0) { 698 - component.appendQuotesIconLink(count, true); 699 - } 700 - }).catch(error => { 701 - console.warn("Couldn't load quote count: " + error); 702 - }); 703 - } 704 - 705 - /** @param {Post} post, @param {AnyElement} nodeToUpdate */ 706 - 707 - function loadSubtree(post, nodeToUpdate) { 708 - api.loadThreadByAtURI(post.uri).then(json => { 709 - let root = Post.parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel); 710 - post.updateDataFromPost(root); 711 - window.subtreeRoot = post; 712 - 713 - let component = new PostComponent(post, 'thread'); 714 - component.installIntoElement(nodeToUpdate); 715 - }).catch(showError); 716 - } 717 - 718 - /** @param {Post} post, @param {AnyElement} nodeToUpdate */ 719 - 720 - function loadHiddenSubtree(post, nodeToUpdate) { 721 - let content = nodeToUpdate.querySelector('.content'); 722 - let hiddenRepliesDiv = content.querySelector(':scope > .hidden-replies'); 723 - 724 - blueAPI.getReplies(post.uri).then(replies => { 725 - let missingReplies = replies.filter(r => !post.replies.some(x => x.uri === r)); 726 - 727 - Promise.allSettled(missingReplies.map(uri => api.loadThreadByAtURI(uri))).then(responses => { 728 - let replies = responses 729 - .map(r => r.status == 'fulfilled' ? r.value : undefined) 730 - .filter(v => v) 731 - .map(json => Post.parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1)); 732 - 733 - post.setReplies(replies); 734 - hiddenRepliesDiv.remove(); 735 - 736 - for (let reply of post.replies) { 737 - let component = new PostComponent(reply, 'thread'); 738 - let view = component.buildElement(); 739 - content.append(view); 740 - } 741 - 742 - if (replies.length < responses.length) { 743 - let notFoundCount = responses.length - replies.length; 744 - let pluralizedCount = notFoundCount + ' ' + ((notFoundCount > 1) ? 'replies are' : 'reply is'); 745 - 746 - let info = $tag('p.missing-replies-info', { 747 - html: `<i class="fa-solid fa-ban"></i> ${pluralizedCount} missing (likely taken down by moderation)` 748 - }); 749 - content.append(info); 750 - } 751 - }).catch(error => { 752 - hiddenRepliesDiv.remove(); 753 - setTimeout(() => showError(error), 1); 754 - }); 755 - }).catch(error => { 756 - hiddenRepliesDiv.remove(); 757 - 758 - if (error instanceof APIError && error.code == 404) { 759 - let info = $tag('p.missing-replies-info', { 760 - html: `<i class="fa-solid fa-ban"></i> Hidden replies not available (post too old)` 761 - }); 762 - content.append(info); 763 - } else { 764 - setTimeout(() => showError(error), 1); 765 - } 766 - }); 767 - }
··· 1 function init() { 2 window.dateLocale = localStorage.getItem('locale') || undefined; 3 window.isIncognito = !!localStorage.getItem('incognito'); 4 window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null'); 5 6 + window.loginDialog = $(document.querySelector('#login')); 7 8 window.avatarPreloader = buildAvatarPreloader(); 9 10 + window.accountMenu = new Menu(); 11 + window.threadPage = new ThreadPage(); 12 + window.postingStatsPage = new PostingStatsPage(); 13 + window.likeStatsPage = new LikeStatsPage(); 14 + window.notificationsPage = new NotificationsPage(); 15 + window.privateSearchPage = new PrivateSearchPage(); 16 17 + $(document.querySelector('#search form')).addEventListener('submit', (e) => { 18 e.preventDefault(); 19 submitSearch(); 20 }); 21 22 for (let dialog of document.querySelectorAll('.dialog')) { 23 + let close = $(dialog.querySelector('.close')); 24 + 25 dialog.addEventListener('click', (e) => { 26 + if (e.target === e.currentTarget && close && close.offsetHeight > 0) { 27 hideDialog(dialog); 28 } else { 29 e.stopPropagation(); 30 } 31 }); 32 33 + close?.addEventListener('click', (e) => { 34 hideDialog(dialog); 35 }); 36 } 37 38 + $(document.querySelector('#login .info a')).addEventListener('click', (e) => { 39 e.preventDefault(); 40 toggleLoginInfo(); 41 }); 42 43 + $(document.querySelector('#login form')).addEventListener('submit', (e) => { 44 e.preventDefault(); 45 submitLogin(); 46 }); 47 48 + $(document.querySelector('#biohazard_show')).addEventListener('click', (e) => { 49 e.preventDefault(); 50 51 window.biohazardEnabled = true; ··· 56 window.loadInfohazard = undefined; 57 } 58 59 + let target = $(e.target); 60 61 hideDialog(target.closest('.dialog')); 62 }); 63 64 + $(document.querySelector('#biohazard_hide')).addEventListener('click', (e) => { 65 e.preventDefault(); 66 67 window.biohazardEnabled = false; 68 localStorage.setItem('biohazard', 'false'); 69 + accountMenu.toggleMenuButtonCheck('biohazard', false); 70 71 for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) { 72 + $(p).style.display = 'none'; 73 } 74 75 + let target = $(e.target); 76 77 hideDialog(target.closest('.dialog')); 78 }); 79 80 window.appView = new BlueskyAPI('api.bsky.app', false); 81 window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false); 82 window.accountAPI = new BlueskyAPI(undefined, true); 83 84 if (accountAPI.isLoggedIn) { 85 accountAPI.host = accountAPI.user.pdsEndpoint; 86 + accountMenu.hideMenuButton('login'); 87 88 if (!isIncognito) { 89 window.api = accountAPI; 90 + accountMenu.showLoggedInStatus(true, api.user.avatar); 91 } else { 92 window.api = appView; 93 + accountMenu.showLoggedInStatus('incognito'); 94 + accountMenu.toggleMenuButtonCheck('incognito', true); 95 } 96 } else { 97 window.api = appView; 98 + accountMenu.hideMenuButton('logout'); 99 + accountMenu.hideMenuButton('incognito'); 100 } 101 102 + accountMenu.toggleMenuButtonCheck('biohazard', window.biohazardEnabled !== false); 103 104 parseQueryParams(); 105 } 106 107 function parseQueryParams() { 108 let params = new URLSearchParams(location.search); 109 + let { q, author, post, quotes, hash, page } = Object.fromEntries(params); 110 111 if (quotes) { 112 showLoader(); ··· 114 } else if (hash) { 115 showLoader(); 116 loadHashtagPage(decodeURIComponent(hash)); 117 + } else if (q) { 118 showLoader(); 119 + threadPage.loadThreadByURL(decodeURIComponent(q)); 120 } else if (author && post) { 121 showLoader(); 122 + threadPage.loadThreadById(decodeURIComponent(author), decodeURIComponent(post)); 123 } else if (page) { 124 openPage(page); 125 } else { ··· 127 } 128 } 129 130 /** @returns {IntersectionObserver} */ 131 132 function buildAvatarPreloader() { ··· 152 } 153 154 function showSearch() { 155 + let search = $id('search'); 156 + let searchField = $(search.querySelector('input[type=text]')); 157 + 158 + search.style.visibility = 'visible'; 159 + searchField.focus(); 160 } 161 162 function hideSearch() { ··· 188 } 189 } 190 191 + function toggleLoginInfo() { 192 $id('login').classList.toggle('expanded'); 193 } 194 195 function submitLogin() { 196 + let handleField = $id('login_handle', HTMLInputElement); 197 + let passwordField = $id('login_password', HTMLInputElement); 198 let submit = $id('login_submit'); 199 let cloudy = $id('cloudy'); 200 + let close = $(loginDialog.querySelector('.close')); 201 202 if (submit.style.display == 'none') { return } 203 204 + handleField.blur(); 205 + passwordField.blur(); 206 207 submit.style.display = 'none'; 208 cloudy.style.display = 'inline-block'; 209 210 + let handle = handleField.value.trim(); 211 + let password = passwordField.value.trim(); 212 + 213 + logIn(handle, password).then((pds) => { 214 window.api = pds; 215 window.accountAPI = pds; 216 217 hideDialog(loginDialog); 218 submit.style.display = 'inline'; 219 cloudy.style.display = 'none'; 220 + close.style.display = 'inline'; 221 222 + accountMenu.loadCurrentUserAvatar(); 223 + 224 + accountMenu.showMenuButton('logout'); 225 + accountMenu.showMenuButton('incognito'); 226 + accountMenu.hideMenuButton('login'); 227 228 let params = new URLSearchParams(location.search); 229 let page = params.get('page'); ··· 266 return pds; 267 } 268 269 function logOut() { 270 accountAPI.resetTokens(); 271 localStorage.removeItem('incognito'); ··· 273 } 274 275 function submitSearch() { 276 + let search = $id('search'); 277 + let searchField = $(search.querySelector('input[name=q]'), HTMLInputElement); 278 + let url = searchField.value.trim(); 279 280 if (!url) { return } 281 ··· 304 } 305 } 306 307 + /** @param {string} page */ 308 + 309 function openPage(page) { 310 if (!accountAPI.isLoggedIn) { 311 + showDialog(loginDialog); 312 + $(loginDialog.querySelector('.close')).style.display = 'none'; 313 return; 314 } 315 316 if (page == 'notif') { 317 + window.notificationsPage.show(); 318 + } else if (page == 'posting_stats') { 319 + window.postingStatsPage.show(); 320 + } else if (page == 'like_stats') { 321 + window.likeStatsPage.show(); 322 + } else if (page == 'search') { 323 + window.privateSearchPage.show(); 324 } 325 } 326 327 /** @param {Post} post */ 328 329 function setPageTitle(post) { ··· 341 let finished = false; 342 let cursor; 343 344 + Paginator.loadInPages(() => { 345 if (isLoading || finished) { return; } 346 isLoading = true; 347 ··· 389 let cursor; 390 let finished = false; 391 392 + Paginator.loadInPages(() => { 393 if (isLoading || finished) { return; } 394 isLoading = true; 395 ··· 440 }); 441 }); 442 }
+545 -4
style.css
··· 127 padding: 6px 11px; 128 } 129 130 - #account_menu li a { 131 display: inline-block; 132 color: #333; 133 font-size: 11pt; ··· 138 background-color: hsla(210, 100%, 4%, 0.12); 139 } 140 141 - #account_menu li a:hover { 142 background-color: hsla(210, 100%, 4%, 0.2); 143 text-decoration: none; 144 } 145 146 #account_menu li .check { ··· 486 margin-top: 18px; 487 } 488 489 .post .quote-embed { 490 border: 1px solid #ddd; 491 border-radius: 8px; ··· 507 font-style: italic; 508 font-size: 11pt; 509 color: #888; 510 } 511 512 .post .image-alt { ··· 632 color: #aaa; 633 } 634 635 .post .stats { 636 font-size: 10pt; 637 color: #666; ··· 672 margin-right: 10px; 673 } 674 675 .post img.loader { 676 width: 24px; 677 animation: rotation 3s infinite linear; ··· 709 margin-top: 25px; 710 } 711 712 @media (prefers-color-scheme: dark) { 713 body { 714 background-color: rgb(39, 39, 37); ··· 735 background-color: transparent; 736 } 737 738 #account_menu { 739 background: hsl(210, 33.33%, 94.0%); 740 border-color: #ccc; 741 } 742 743 - #account_menu li a { 744 color: #333; 745 border-color: #bbb; 746 background-color: hsla(210, 100%, 4%, 0.12); 747 } 748 749 - #account_menu li a:hover { 750 background-color: hsla(210, 100%, 4%, 0.2); 751 } 752 ··· 828 color: #888; 829 } 830 831 .post .quote-embed { 832 background-color: #303030; 833 border-color: #606060; ··· 875 876 .post .stats i.fa-heart.liked:hover { 877 color: #ff7070; 878 } 879 }
··· 127 padding: 6px 11px; 128 } 129 130 + #account_menu li a[data-action] { 131 display: inline-block; 132 color: #333; 133 font-size: 11pt; ··· 138 background-color: hsla(210, 100%, 4%, 0.12); 139 } 140 141 + #account_menu li a[data-action]:hover { 142 background-color: hsla(210, 100%, 4%, 0.2); 143 text-decoration: none; 144 + } 145 + 146 + #account_menu li:not(.link) + li.link { 147 + margin-top: 16px; 148 + padding-top: 10px; 149 + border-top: 1px solid #ccc; 150 + } 151 + 152 + #account_menu li.link { 153 + margin-top: 8px; 154 + margin-left: 2px; 155 + } 156 + 157 + #account_menu li.link a { 158 + font-size: 11pt; 159 + color: #333; 160 } 161 162 #account_menu li .check { ··· 502 margin-top: 18px; 503 } 504 505 + .post .body .highlight { 506 + background-color: rgba(255, 255, 0, 0.75); 507 + padding: 1px 2px; 508 + margin-left: -1px; 509 + margin-right: -1px; 510 + } 511 + 512 .post .quote-embed { 513 border: 1px solid #ddd; 514 border-radius: 8px; ··· 530 font-style: italic; 531 font-size: 11pt; 532 color: #888; 533 + } 534 + 535 + .post-quotes .post-quote .quote-embed { 536 + display: none; 537 + } 538 + 539 + .post-quotes .post-quote p.stats { 540 + display: none; 541 } 542 543 .post .image-alt { ··· 663 color: #aaa; 664 } 665 666 + .post a.fedi-link { 667 + display: inline-block; 668 + margin-bottom: 6px; 669 + margin-top: 2px; 670 + } 671 + 672 + .post a.fedi-link:hover { 673 + text-decoration: none; 674 + } 675 + 676 + .post a.fedi-link > div { 677 + border: 1px solid #d0d0d0; 678 + border-radius: 8px; 679 + padding: 5px 9px; 680 + color: #555; 681 + font-size: 10pt; 682 + } 683 + 684 + .post a.fedi-link i { 685 + margin-right: 3px; 686 + } 687 + 688 + .post a.fedi-link:hover > div { 689 + background-color: #f6f7f8; 690 + border: 1px solid #c8c8c8; 691 + } 692 + 693 + .post div.gif img { 694 + user-select: none; 695 + -webkit-user-select: none; 696 + } 697 + 698 + .post div.gif img.static { 699 + opacity: 0.75; 700 + } 701 + 702 .post .stats { 703 font-size: 10pt; 704 color: #666; ··· 739 margin-right: 10px; 740 } 741 742 + .post .stats .blocked-info { 743 + color: #a02020; 744 + font-weight: bold; 745 + margin-left: 5px; 746 + } 747 + 748 .post img.loader { 749 width: 24px; 750 animation: rotation 3s infinite linear; ··· 782 margin-top: 25px; 783 } 784 785 + #posting_stats_page { 786 + display: none; 787 + } 788 + 789 + #posting_stats_page input[type="radio"] { 790 + position: relative; 791 + top: -1px; 792 + margin-left: 5px; 793 + } 794 + 795 + #posting_stats_page input[type="radio"] + label { 796 + user-select: none; 797 + -webkit-user-select: none; 798 + } 799 + 800 + #posting_stats_page input[type="radio"]:disabled + label { 801 + color: #999; 802 + } 803 + 804 + #posting_stats_page input[type="range"] { 805 + width: 250px; 806 + vertical-align: middle; 807 + } 808 + 809 + #posting_stats_page input[type="submit"] { 810 + font-size: 12pt; 811 + margin: 5px 0px; 812 + padding: 5px 10px; 813 + } 814 + 815 + #posting_stats_page select { 816 + font-size: 12pt; 817 + margin-left: 5px; 818 + } 819 + 820 + #posting_stats_page progress { 821 + width: 300px; 822 + margin-left: 10px; 823 + vertical-align: middle; 824 + display: none; 825 + } 826 + 827 + #posting_stats_page .list-choice { 828 + display: none; 829 + } 830 + 831 + #posting_stats_page .user-choice { 832 + display: none; 833 + position: relative; 834 + } 835 + 836 + #posting_stats_page .user-choice input { 837 + width: 260px; 838 + font-size: 11pt; 839 + } 840 + 841 + #posting_stats_page .user-choice .autocomplete { 842 + display: none; 843 + position: absolute; 844 + left: 0; 845 + top: 0; 846 + margin-top: 4px; 847 + width: 350px; 848 + max-height: 250px; 849 + overflow-y: auto; 850 + background-color: white; 851 + border: 1px solid #ccc; 852 + z-index: 10; 853 + } 854 + 855 + #posting_stats_page .user-choice .selected-users { 856 + width: 275px; 857 + height: 150px; 858 + overflow-y: auto; 859 + border: 1px solid #aaa; 860 + padding: 4px; 861 + margin-top: 20px; 862 + } 863 + 864 + #posting_stats_page .user-choice .user-row { 865 + position: relative; 866 + padding: 2px 4px 2px 37px; 867 + cursor: pointer; 868 + } 869 + 870 + #posting_stats_page .user-choice .user-row .avatar { 871 + position: absolute; 872 + left: 6px; 873 + top: 8px; 874 + width: 24px; 875 + border-radius: 12px; 876 + } 877 + 878 + #posting_stats_page .user-choice .user-row span { 879 + display: block; 880 + overflow-x: hidden; 881 + text-overflow: ellipsis; 882 + } 883 + 884 + #posting_stats_page .user-choice .user-row .name { 885 + font-size: 11pt; 886 + margin-top: 1px; 887 + margin-bottom: 1px; 888 + } 889 + 890 + #posting_stats_page .user-choice .user-row .handle { 891 + font-size: 10pt; 892 + margin-bottom: 2px; 893 + color: #666; 894 + } 895 + 896 + #posting_stats_page .user-choice .autocomplete .user-row { 897 + cursor: pointer; 898 + } 899 + 900 + #posting_stats_page .user-choice .autocomplete .user-row.hover { 901 + background-color: hsl(207, 100%, 85%); 902 + } 903 + 904 + #posting_stats_page .user-choice .selected-users .user-row span { 905 + padding-right: 14px; 906 + } 907 + 908 + #posting_stats_page .user-choice .selected-users .user-row .remove { 909 + position: absolute; 910 + right: 4px; 911 + top: 11px; 912 + padding: 0px 4px; 913 + color: #333; 914 + line-height: 17px; 915 + } 916 + 917 + #posting_stats_page .user-choice .selected-users .user-row .remove:hover { 918 + text-decoration: none; 919 + background-color: #ddd; 920 + border-radius: 8px; 921 + } 922 + 923 + #posting_stats_page .scan-info { 924 + display: none; 925 + font-weight: 600; 926 + line-height: 125%; 927 + margin: 20px 0px; 928 + } 929 + 930 + #posting_stats_page .scan-result { 931 + border: 1px solid #333; 932 + border-collapse: collapse; 933 + display: none; 934 + } 935 + 936 + #posting_stats_page .scan-result td, #posting_stats_page .scan-result th { 937 + border: 1px solid #333; 938 + } 939 + 940 + #posting_stats_page .scan-result td { 941 + text-align: right; 942 + padding: 5px 8px; 943 + } 944 + 945 + #posting_stats_page .scan-result th { 946 + text-align: center; 947 + background-color: hsl(207, 100%, 86%); 948 + padding: 7px 10px; 949 + } 950 + 951 + #posting_stats_page .scan-result td.handle { 952 + text-align: left; 953 + max-width: 450px; 954 + overflow: hidden; 955 + text-overflow: ellipsis; 956 + white-space: nowrap; 957 + } 958 + 959 + #posting_stats_page .scan-result tr.total td { 960 + font-weight: bold; 961 + font-size: 11pt; 962 + background-color: hsla(207, 100%, 86%, 0.4); 963 + } 964 + 965 + #posting_stats_page .scan-result tr.total td.handle { 966 + text-align: left; 967 + padding: 10px 12px; 968 + } 969 + 970 + #posting_stats_page .scan-result .avatar { 971 + width: 24px; 972 + height: 24px; 973 + border-radius: 14px; 974 + vertical-align: middle; 975 + margin-right: 2px; 976 + padding: 2px; 977 + } 978 + 979 + #posting_stats_page .scan-result td.no { 980 + font-weight: bold; 981 + } 982 + 983 + #posting_stats_page .scan-result td.percent { 984 + min-width: 70px; 985 + } 986 + 987 + #like_stats_page { 988 + display: none; 989 + } 990 + 991 + #like_stats_page input[type="range"] { 992 + width: 250px; 993 + vertical-align: middle; 994 + } 995 + 996 + #like_stats_page input[type="submit"] { 997 + font-size: 12pt; 998 + margin: 5px 0px; 999 + padding: 5px 10px; 1000 + } 1001 + 1002 + #like_stats_page progress { 1003 + width: 300px; 1004 + margin-left: 10px; 1005 + vertical-align: middle; 1006 + display: none; 1007 + } 1008 + 1009 + #like_stats_page .scan-result { 1010 + border: 1px solid #333; 1011 + border-collapse: collapse; 1012 + display: none; 1013 + float: left; 1014 + margin-top: 20px; 1015 + margin-bottom: 40px; 1016 + } 1017 + 1018 + #like_stats_page .given-likes { 1019 + margin-right: 100px; 1020 + } 1021 + 1022 + #like_stats_page .scan-result td, #like_stats_page .scan-result th { 1023 + border: 1px solid #333; 1024 + padding: 5px 10px; 1025 + } 1026 + 1027 + #like_stats_page .scan-result th { 1028 + text-align: center; 1029 + background-color: hsl(207, 100%, 86%); 1030 + padding: 12px 10px; 1031 + } 1032 + 1033 + #like_stats_page .scan-result td.no { 1034 + font-weight: bold; 1035 + text-align: right; 1036 + } 1037 + 1038 + #like_stats_page .scan-result td.handle { 1039 + width: 280px; 1040 + } 1041 + 1042 + #like_stats_page .scan-result td.count { 1043 + padding: 5px 15px; 1044 + } 1045 + 1046 + #like_stats_page .scan-result .avatar { 1047 + width: 24px; 1048 + height: 24px; 1049 + border-radius: 14px; 1050 + vertical-align: middle; 1051 + margin-right: 2px; 1052 + padding: 2px; 1053 + } 1054 + 1055 + #private_search_page { 1056 + display: none; 1057 + } 1058 + 1059 + #private_search_page input[type="range"] { 1060 + width: 250px; 1061 + vertical-align: middle; 1062 + } 1063 + 1064 + #private_search_page input[type="submit"] { 1065 + font-size: 12pt; 1066 + margin: 5px 0px; 1067 + padding: 5px 10px; 1068 + } 1069 + 1070 + #private_search_page progress { 1071 + width: 300px; 1072 + margin-left: 10px; 1073 + vertical-align: middle; 1074 + display: none; 1075 + } 1076 + 1077 + #private_search_page .search { 1078 + display: none; 1079 + } 1080 + 1081 + #private_search_page .search-query { 1082 + font-size: 12pt; 1083 + border: 1px solid #ccc; 1084 + border-radius: 6px; 1085 + padding: 5px 6px; 1086 + margin-left: 8px; 1087 + } 1088 + 1089 + #private_search_page .search-collections label { 1090 + vertical-align: middle; 1091 + } 1092 + 1093 + #private_search_page .lycan-import { 1094 + display: none; 1095 + 1096 + margin-top: 30px; 1097 + border-top: 1px solid #ccc; 1098 + padding-top: 5px; 1099 + } 1100 + 1101 + #private_search_page .lycan-import form p { 1102 + line-height: 135%; 1103 + } 1104 + 1105 + #private_search_page .lycan-import .import-progress progress { 1106 + margin-left: 0; 1107 + margin-right: 6px; 1108 + } 1109 + 1110 + #private_search_page .lycan-import .import-progress progress + output { 1111 + font-size: 11pt; 1112 + } 1113 + 1114 + #private_search_page .results { 1115 + margin-top: 30px; 1116 + } 1117 + 1118 + #private_search_page .results > .post { 1119 + margin-left: -15px; 1120 + padding-left: 15px; 1121 + border-bottom: 1px solid #ddd; 1122 + padding-bottom: 10px; 1123 + margin-top: 24px; 1124 + } 1125 + 1126 + #private_search_page .results-end { 1127 + font-size: 12pt; 1128 + color: #333; 1129 + } 1130 + 1131 + #private_search_page .post + .results-end { 1132 + font-size: 11pt; 1133 + } 1134 + 1135 @media (prefers-color-scheme: dark) { 1136 body { 1137 background-color: rgb(39, 39, 37); ··· 1158 background-color: transparent; 1159 } 1160 1161 + #account.active { 1162 + color: #333; 1163 + } 1164 + 1165 #account_menu { 1166 background: hsl(210, 33.33%, 94.0%); 1167 border-color: #ccc; 1168 } 1169 1170 + #account_menu li a[data-action] { 1171 color: #333; 1172 border-color: #bbb; 1173 background-color: hsla(210, 100%, 4%, 0.12); 1174 } 1175 1176 + #account_menu li a[data-action]:hover { 1177 background-color: hsla(210, 100%, 4%, 0.2); 1178 } 1179 ··· 1255 color: #888; 1256 } 1257 1258 + .post .body .highlight { 1259 + background-color: rgba(255, 255, 0, 0.35); 1260 + } 1261 + 1262 .post .quote-embed { 1263 background-color: #303030; 1264 border-color: #606060; ··· 1306 1307 .post .stats i.fa-heart.liked:hover { 1308 color: #ff7070; 1309 + } 1310 + 1311 + .post a.link-card > div { 1312 + background-color: #303030; 1313 + border-color: #606060; 1314 + } 1315 + 1316 + .post a.link-card:hover > div { 1317 + background-color: #383838; 1318 + border-color: #707070; 1319 + } 1320 + 1321 + .post a.link-card p.domain { 1322 + color: #666; 1323 + } 1324 + 1325 + .post a.link-card h2 { 1326 + color: #ccc; 1327 + } 1328 + 1329 + .post a.link-card p.description { 1330 + color: #888; 1331 + } 1332 + 1333 + .post a.link-card.record .handle { 1334 + color: #666; 1335 + } 1336 + 1337 + .post a.link-card.record .avatar { 1338 + border-color: #888; 1339 + } 1340 + 1341 + .post a.link-card.record .stats i.fa-heart:hover { 1342 + color: #eee; 1343 + } 1344 + 1345 + .post a.fedi-link > div { 1346 + border-color: #606060; 1347 + color: #909090; 1348 + } 1349 + 1350 + .post a.fedi-link:hover > div { 1351 + background-color: #444; 1352 + border-color: #909090; 1353 + } 1354 + 1355 + #posting_stats_page input:disabled + label { 1356 + color: #777; 1357 + } 1358 + 1359 + #posting_stats_page .user-choice .autocomplete { 1360 + background-color: hsl(210, 5%, 18%); 1361 + border-color: #4b4b4b; 1362 + } 1363 + 1364 + #posting_stats_page .user-choice .selected-users { 1365 + border-color: #666; 1366 + } 1367 + 1368 + #posting_stats_page .user-choice .user-row .handle { 1369 + color: #888; 1370 + } 1371 + 1372 + #posting_stats_page .user-choice .autocomplete .user-row.hover { 1373 + background-color: hsl(207, 90%, 25%); 1374 + } 1375 + 1376 + #posting_stats_page .user-choice .selected-users .user-row .remove { 1377 + color: #aaa; 1378 + } 1379 + 1380 + #posting_stats_page .user-choice .selected-users .user-row .remove:hover { 1381 + background-color: #555; 1382 + color: #bbb; 1383 + } 1384 + 1385 + #posting_stats_page .scan-result, #posting_stats_page .scan-result td, #posting_stats_page .scan-result th { 1386 + border-color: #888; 1387 + } 1388 + 1389 + #posting_stats_page .scan-result th { 1390 + background-color: hsl(207, 90%, 25%); 1391 + } 1392 + 1393 + #posting_stats_page .scan-result tr.total td { 1394 + background-color: hsla(207, 90%, 25%, 0.4); 1395 + } 1396 + 1397 + #like_stats_page .scan-result, #like_stats_page .scan-result td, #like_stats_page .scan-result th { 1398 + border-color: #888; 1399 + } 1400 + 1401 + #like_stats_page .scan-result th { 1402 + background-color: hsl(207, 90%, 25%); 1403 + } 1404 + 1405 + #private_search_page .search-query { 1406 + border: 1px solid #666; 1407 + } 1408 + 1409 + #private_search_page .lycan-import { 1410 + border-top-color: #888; 1411 + } 1412 + 1413 + #private_search_page .results-end { 1414 + color: #888; 1415 + } 1416 + 1417 + #private_search_page .results > .post { 1418 + border-bottom: 1px solid #555; 1419 } 1420 }
+60
test/ts_test.js
···
··· 1 + // @ts-nocheck 2 + 3 + // "Test suite" for TypeScript checking in $(), $id() and $tag() 4 + 5 + function test() { 6 + 7 + let panel = $(document.querySelector('.panel')); // HTMLElement 8 + panel.style.display = 'none'; 9 + 10 + /** @type {never} */ let x1 = panel; 11 + 12 + let link = $(document.querySelector('a.more'), HTMLLinkElement); // HTMLLinkElement 13 + link.href = 'about:blank'; 14 + 15 + /** @type {never} */ let x2 = link; 16 + 17 + let html = $(document.parentNode); 18 + 19 + /** @type {never} */ let x3 = html; 20 + 21 + document.addEventListener('click', (e) => { 22 + let target = $(e.target); 23 + /** @type {never} */ let x4 = target; 24 + }); 25 + 26 + let text = $(link.innerText); 27 + 28 + /** @type {never} */ let x5 = text; 29 + 30 + let login = $id('login'); // HTMLElement 31 + login.remove(); 32 + 33 + /** @type {never} */ let x6 = login; 34 + 35 + let loginField = $id('login_field', HTMLInputElement); // HTMLInputElement 36 + loginField.value = ''; 37 + 38 + /** @type {never} */ let x7 = loginField; 39 + 40 + let p = $tag('p.details'); // HTMLElement 41 + p.innerText = 'About'; 42 + 43 + /** @type {never} */ let x8 = p; 44 + 45 + let p2 = $tag('p.details', { text: 'Info' }); // HTMLElement 46 + p2.innerText = 'About'; 47 + 48 + /** @type {never} */ let x9 = p2; 49 + 50 + let img = $tag('img.icon', HTMLImageElement); // HTMLImageElement 51 + img.loading = 'lazy'; 52 + 53 + /** @type {never} */ let x10 = img; 54 + 55 + let img2 = $tag('img.icon', { src: accountAPI.user.avatar }, HTMLImageElement); // HTMLImageElement 56 + img2.loading = 'lazy'; 57 + 58 + /** @type {never} */ let x11 = img2; 59 + 60 + }
+93
thread_page.js
···
··· 1 + /** 2 + * Manages the page that displays a thread, as a whole. 3 + */ 4 + 5 + class ThreadPage { 6 + 7 + /** @param {AnyPost} post, @returns {HTMLElement} */ 8 + 9 + buildParentLink(post) { 10 + let p = $tag('p.back'); 11 + 12 + if (post instanceof BlockedPost) { 13 + let element = new PostComponent(post, 'parent').buildElement(); 14 + element.className = 'back'; 15 + let span = $(element.querySelector('p.blocked-header span')); 16 + span.innerText = 'Parent post blocked'; 17 + return element; 18 + } else if (post instanceof MissingPost) { 19 + p.innerHTML = `<i class="fa-solid fa-ban"></i> parent post has been deleted`; 20 + } else { 21 + let url = linkToPostThread(post); 22 + p.innerHTML = `<i class="fa-solid fa-reply"></i><a href="${url}">See parent post (@${post.author.handle})</a>`; 23 + } 24 + 25 + return p; 26 + } 27 + 28 + /** @param {string} url, @returns {Promise<void>} */ 29 + 30 + async loadThreadByURL(url) { 31 + try { 32 + let json = url.startsWith('at://') ? await api.loadThreadByAtURI(url) : await api.loadThreadByURL(url); 33 + this.displayThread(json); 34 + } catch (error) { 35 + hideLoader(); 36 + showError(error); 37 + } 38 + } 39 + 40 + /** @param {string} author, @param {string} rkey, @returns {Promise<void>} */ 41 + 42 + async loadThreadById(author, rkey) { 43 + try { 44 + let json = await api.loadThreadById(author, rkey); 45 + this.displayThread(json); 46 + } catch (error) { 47 + hideLoader(); 48 + showError(error); 49 + } 50 + } 51 + 52 + /** @param {json} json */ 53 + 54 + displayThread(json) { 55 + let root = Post.parseThreadPost(json.thread); 56 + window.root = root; 57 + window.subtreeRoot = root; 58 + 59 + let loadQuoteCount; 60 + 61 + if (root instanceof Post) { 62 + setPageTitle(root); 63 + loadQuoteCount = blueAPI.getQuoteCount(root.uri); 64 + 65 + if (root.parent) { 66 + let p = this.buildParentLink(root.parent); 67 + $id('thread').appendChild(p); 68 + } else if (root.parentReference) { 69 + let { repo, rkey } = atURI(root.parentReference.uri); 70 + let url = linkToPostById(repo, rkey); 71 + 72 + let handle = api.findHandleByDid(repo); 73 + let link = handle ? `See parent post (@${handle})` : "See parent post"; 74 + 75 + let p = $tag('p.back', { html: `<i class="fa-solid fa-reply"></i><a href="${url}">${link}</a>` }); 76 + $id('thread').appendChild(p); 77 + } 78 + } 79 + 80 + let component = new PostComponent(root, 'thread'); 81 + let view = component.buildElement(); 82 + hideLoader(); 83 + $id('thread').appendChild(view); 84 + 85 + loadQuoteCount?.then(count => { 86 + if (count > 0) { 87 + component.appendQuotesIconLink(count, true); 88 + } 89 + }).catch(error => { 90 + console.warn("Couldn't load quote count: " + error); 91 + }); 92 + } 93 + }
+13 -27
types.d.ts
··· 11 declare var api: BlueskyAPI; 12 declare var isIncognito: boolean; 13 declare var biohazardEnabled: boolean; 14 - declare var loginDialog: AnyElement; 15 - declare var accountMenu: AnyElement; 16 declare var avatarPreloader: IntersectionObserver; 17 - 18 - type SomeElement = Element | HTMLElement | AnyElement; 19 - type json = Record<string, any>; 20 21 - interface AnyElement { 22 - classList: CSSClassList; 23 - className: string; 24 - innerText: string; 25 - innerHTML: string; 26 - nextElementSibling: AnyElement; 27 - parentNode: AnyElement; 28 - src: string; 29 - style: CSSStyleDeclaration; 30 31 - addEventListener<K extends keyof DocumentEventMap>( 32 - type: K, listener: EventListenerOrEventListenerObject 33 - ): void; 34 35 - append(...e: Array<string | SomeElement>): void; 36 - appendChild(e: SomeElement): void; 37 - closest(q: string): AnyElement; 38 - querySelector(q: string): AnyElement; 39 - querySelectorAll(q: string): AnyElement[]; 40 - prepend(...e: Array<string | SomeElement>): void; 41 - remove(): void; 42 - replaceChildren(e: SomeElement): void; 43 - replaceWith(e: SomeElement): void; 44 - }
··· 11 declare var api: BlueskyAPI; 12 declare var isIncognito: boolean; 13 declare var biohazardEnabled: boolean; 14 + declare var loginDialog: HTMLElement; 15 + declare var accountMenu: Menu; 16 declare var avatarPreloader: IntersectionObserver; 17 + declare var threadPage: ThreadPage; 18 + declare var postingStatsPage: PostingStatsPage; 19 + declare var likeStatsPage: LikeStatsPage; 20 + declare var notificationsPage: NotificationsPage; 21 + declare var privateSearchPage: PrivateSearchPage; 22 23 + declare var Paginator: PaginatorType; 24 25 + type json = Record<string, any>; 26 27 + function $tag(tag: string): HTMLElement; 28 + function $tag<T extends HTMLElement>(tag: string, type: new (...args: any[]) => T): T; 29 + function $tag(tag: string, params: string | object): HTMLElement; 30 + function $tag<T extends HTMLElement>(tag: string, params: string | object, type: new (...args: any[]) => T): T;
+69 -6
utils.js
··· 17 } 18 } 19 20 - /** @param {string} tag, @param {string | object} [params], @returns {any} */ 21 22 - function $tag(tag, params) { 23 let element; 24 let parts = tag.split('.'); 25 ··· 45 } 46 } 47 48 - return element; 49 } 50 51 - /** @param {string} name, @returns {any} */ 52 53 - function $id(name) { 54 - return document.getElementById(name); 55 } 56 57 /** @param {string} uri, @returns {AtURI} */ ··· 74 return html.replace(/&/g, '&amp;') 75 .replace(/</g, '&lt;') 76 .replace(/>/g,'&gt;'); 77 } 78 79 /** @param {string} html, @returns {string} */
··· 17 } 18 } 19 20 + /** 21 + * @typedef {object} PaginatorType 22 + * @property {(callback: (boolean) => void) => void} loadInPages 23 + * @property {(() => void)=} scrollHandler 24 + * @property {ResizeObserver=} resizeObserver 25 + */ 26 + 27 + window.Paginator = { 28 + loadInPages(callback) { 29 + if (this.scrollHandler) { 30 + document.removeEventListener('scroll', this.scrollHandler); 31 + } 32 + 33 + if (this.resizeObserver) { 34 + this.resizeObserver.disconnect(); 35 + } 36 + 37 + let loadIfNeeded = () => { 38 + if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 500) { 39 + callback(loadIfNeeded); 40 + } 41 + }; 42 + 43 + callback(loadIfNeeded); 44 + 45 + document.addEventListener('scroll', loadIfNeeded); 46 + const resizeObserver = new ResizeObserver(loadIfNeeded); 47 + resizeObserver.observe(document.body); 48 + 49 + this.scrollHandler = loadIfNeeded; 50 + this.resizeObserver = resizeObserver; 51 + } 52 + }; 53 + 54 + /** 55 + * @template T 56 + * @param {string} tag 57 + * @param {string | object} params 58 + * @param {new (...args: any[]) => T} type 59 + * @returns {T} 60 + */ 61 62 + function $tag(tag, params, type) { 63 let element; 64 let parts = tag.split('.'); 65 ··· 85 } 86 } 87 88 + return /** @type {T} */ (element); 89 + } 90 + 91 + /** 92 + * @template {HTMLElement} T 93 + * @param {string} name 94 + * @param {new (...args: any[]) => T} [type] 95 + * @returns {T} 96 + */ 97 + 98 + function $id(name, type) { 99 + return /** @type {T} */ (document.getElementById(name)); 100 } 101 102 + /** 103 + * @template {HTMLElement} T 104 + * @param {Node | EventTarget | null} element 105 + * @param {new (...args: any[]) => T} [type] 106 + * @returns {T} 107 + */ 108 109 + function $(element, type) { 110 + return /** @type {T} */ (element); 111 } 112 113 /** @param {string} uri, @returns {AtURI} */ ··· 130 return html.replace(/&/g, '&amp;') 131 .replace(/</g, '&lt;') 132 .replace(/>/g,'&gt;'); 133 + } 134 + 135 + /** @param {json} feedPost, @returns {number} */ 136 + 137 + function feedPostTime(feedPost) { 138 + let timestamp = feedPost.reason ? feedPost.reason.indexedAt : feedPost.post.record.createdAt; 139 + return Date.parse(timestamp); 140 } 141 142 /** @param {string} html, @returns {string} */