Thread viewer for Bluesky

Compare changes

Choose any two refs to compare.

+9 -2
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('/'); ··· 197 this.cacheProfile(profile); 198 return profile; 199 } 200 } 201 202 /** @returns {Promise<json | undefined>} */
··· 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('/'); ··· 197 this.cacheProfile(profile); 198 return profile; 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>} */
+61 -19
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 ··· 116 <select name="scan_list"></select> 117 </p> 118 119 - <p class="user-choice"> 120 - <textarea name="scan_users"></textarea> 121 - </p> 122 123 <p> 124 <input type="submit" value="Start scan"> <progress></progress> ··· 128 <p class="scan-info"></p> 129 130 <table class="scan-result"> 131 - <thead> 132 - </thead> 133 - <tbody> 134 - </tbody> 135 </table> 136 </div> 137 ··· 164 </div> 165 166 <div id="private_search_page"> 167 - <h2>Archive search *Beta*</h2> 168 169 - <form> 170 - <p> 171 - Fetch timeline posts: <input type="range" min="1" max="60" value="7"> <label>7 days</label> 172 - </p> 173 174 - <p> 175 - <input type="submit" value="Fetch timeline"> <progress></progress> 176 - </p> 177 </form> 178 179 - <p class="archive-status"></p> 180 181 - <hr> 182 183 - <p class="search">Search: <input type="text" class="search-query"></p> 184 185 <div class="results"> 186 </div>
··· 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 ··· 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> ··· 136 <p class="scan-info"></p> 137 138 <table class="scan-result"> 139 + <thead></thead> 140 + <tbody></tbody> 141 </table> 142 </div> 143 ··· 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>
+2 -2
like_stats_page.js
··· 27 e.preventDefault(); 28 29 if (!this.scanStartTime) { 30 - this.findLikes(); 31 } else { 32 this.stopScan(); 33 } ··· 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 })
··· 27 e.preventDefault(); 28 29 if (!this.scanStartTime) { 30 + this.findLikes(); 31 } else { 32 this.stopScan(); 33 } ··· 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 })
+9 -2
menu.js
··· 11 12 html.addEventListener('click', (e) => { 13 this.menuElement.style.visibility = 'hidden'; 14 }); 15 16 this.icon.addEventListener('click', (e) => { 17 e.stopPropagation(); ··· 66 } 67 68 toggleAccountMenu() { 69 - this.menuElement.style.visibility = (this.menuElement.style.visibility == 'visible') ? 'hidden' : 'visible'; 70 } 71 72 /** @param {string} buttonName */ ··· 124 async loadCurrentUserAvatar() { 125 try { 126 let url = await api.loadCurrentUserAvatar(); 127 - this.showLoggedInStatus(true, url); 128 } catch (error) { 129 console.log(error); 130 this.showLoggedInStatus(true, null);
··· 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(); ··· 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 */ ··· 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);
+1 -1
minisky.js
··· 301 let text = await response.text(); 302 let json = text.trim().length > 0 ? JSON.parse(text) : undefined; 303 304 - if (response.status == 200) { 305 return json; 306 } else { 307 throw new APIError(response.status, json);
··· 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);
+5
models.js
··· 322 return this.record.bridgyOriginalText; 323 } 324 325 /** @returns {boolean} */ 326 get isRoot() { 327 // I AM ROOOT
··· 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
+1 -1
notifications_page.js
··· 13 let finished = false; 14 let cursor; 15 16 - loadInPages((next) => { 17 if (isLoading || finished) { return; } 18 isLoading = true; 19
··· 13 let finished = false; 14 let cursor; 15 16 + Paginator.loadInPages((next) => { 17 if (isLoading || finished) { return; } 18 isLoading = true; 19
+73
post_component.js
··· 162 if (this.post.embed) { 163 let embed = new EmbedComponent(this.post, this.post.embed).buildElement(); 164 wrapper.appendChild(embed); 165 } 166 167 if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) { ··· 301 return p; 302 } 303 304 /** @param {string[]} tags, @returns {HTMLElement} */ 305 306 buildTagsRow(tags) { ··· 433 loadHiddenReplies(loadMoreButton) { 434 loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`; 435 this.loadHiddenSubtree(this.post, this.rootElement); 436 } 437 438 /** @param {HTMLLinkElement} authorLink */
··· 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) { ··· 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) { ··· 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 */
+264 -27
posting_stats_page.js
··· 10 /** @type {Record<string, { pages: number, progress: number }>} */ 11 userProgress; 12 13 constructor() { 14 this.pageElement = $id('posting_stats_page'); 15 this.form = $(this.pageElement.querySelector('form'), HTMLFormElement); ··· 24 this.scanInfo = $(this.pageElement.querySelector('.scan-info')); 25 this.scanType = this.form.elements['scan_type']; 26 27 - this.setupEvents(); 28 29 this.userProgress = {}; 30 this.appView = new BlueskyAPI('public.api.bsky.app', false); 31 } 32 33 setupEvents() { 34 this.form.addEventListener('submit', (e) => { 35 e.preventDefault(); 36 37 if (!this.scanStartTime) { 38 - this.scanPostingStats(); 39 } else { 40 this.stopScan(); 41 } ··· 54 $(this.pageElement.querySelector('.list-choice')).style.display = (value == 'list') ? 'block' : 'none'; 55 $(this.pageElement.querySelector('.user-choice')).style.display = (value == 'users') ? 'block' : 'none'; 56 57 this.table.style.display = 'none'; 58 }); 59 }); 60 } 61 62 show() { ··· 89 } 90 } 91 92 /** @returns {Promise<void>} */ 93 94 async scanPostingStats() { ··· 96 let requestedDays = this.selectedDaysRange(); 97 let scanType = this.scanType.value; 98 99 - this.startScan(startTime, requestedDays); 100 - 101 /** @type {FetchAllOnPageLoad} */ 102 let onPageLoad = (data) => { 103 if (this.scanStartTime != startTime) { ··· 108 }; 109 110 if (scanType == 'home') { 111 - let items = await accountAPI.loadHomeTimeline(requestedDays, { 112 onPageLoad: onPageLoad, 113 keepLastPage: true 114 }); 115 116 - this.updateResultsTable(items, startTime, requestedDays); 117 } else if (scanType == 'list') { 118 let list = this.listSelect.value; 119 - let items = await accountAPI.loadListTimeline(list, requestedDays, { 120 onPageLoad: onPageLoad, 121 keepLastPage: true 122 }); 123 124 - this.updateResultsTable(items, startTime, requestedDays, { showReposts: false }); 125 } else if (scanType == 'users') { 126 - let textarea = $(this.pageElement.querySelector('textarea'), HTMLTextAreaElement); 127 - let users = textarea.value.split(/\n/).map(x => x.trim()).filter(x => x.length > 0); 128 - let dids = await Promise.all(users.map(u => accountAPI.resolveHandle(u))); 129 130 this.resetUserProgress(dids); 131 132 let requests = dids.map(did => this.appView.loadUserTimeline(did, requestedDays, { 133 - filter: 'posts_no_replies', 134 onPageLoad: (data) => { 135 if (this.scanStartTime != startTime) { 136 return { cancel: true }; ··· 142 })); 143 144 let datasets = await Promise.all(requests); 145 - let items = datasets.flat(); 146 147 - this.updateResultsTable(items, startTime, requestedDays, { 148 - showTotal: false, showPercentages: false, countFetchedDays: false 149 }); 150 } else { 151 - let items = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, { 152 filter: 'posts_no_replies', 153 onPageLoad: onPageLoad, 154 keepLastPage: true 155 }); 156 157 - this.updateResultsTable(items, startTime, requestedDays, { showTotal: false, showPercentages: false }); 158 } 159 } 160 ··· 168 let lastDate = feedPostTime(last); 169 let daysBack = (startTime - lastDate) / 86400 / 1000; 170 171 - this.progressBar.value = daysBack; 172 } 173 174 /** @param {string[]} dids */ ··· 218 } 219 220 /** 221 - * @param {json[]} items 222 * @param {number} startTime 223 * @param {number} requestedDays 224 - * @param {{ showTotal?: boolean, showPercentages?: boolean, showReposts?: boolean, countFetchedDays?: boolean }} [options] 225 */ 226 227 - updateResultsTable(items, startTime, requestedDays, options = {}) { 228 if (this.scanStartTime != startTime) { 229 return; 230 } 231 232 let users = {}; 233 let total = 0; 234 let allReposts = 0; 235 let allNormalPosts = 0; 236 237 - let last = items.at(-1); 238 239 if (!last) { 240 this.stopScan(); ··· 258 } 259 260 let timeLimit = startTime - requestedDays * 86400 * 1000; 261 - items = items.filter(x => (feedPostTime(x) > timeLimit)); 262 263 - for (let item of items) { 264 - if (item.reply) { continue; } 265 266 let user = item.reason ? item.reason.by : item.post.author; 267 let handle = user.handle; ··· 274 } else { 275 users[handle].own += 1; 276 allNormalPosts += 1; 277 } 278 } 279 ··· 321 tr.append($tag('td.percent', { text: '' })); 322 } 323 324 - this.tableBody.append(tr); 325 } 326 327 let sorted = Object.values(users).sort(this.sortUserRows); ··· 333 tr.append( 334 $tag('td.no', { text: i + 1 }), 335 $tag('td.handle', { 336 - html: `<img class="avatar" src="${user.avatar}"> ` + 337 `<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>` 338 }), 339 ··· 356 this.table.style.display = 'table'; 357 this.stopScan(); 358 } 359 360 startScan(startTime, requestedDays) { 361 this.submitButton.value = 'Cancel';
··· 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); ··· 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 } ··· 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() { ··· 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() { ··· 288 let requestedDays = this.selectedDaysRange(); 289 let scanType = this.scanType.value; 290 291 /** @type {FetchAllOnPageLoad} */ 292 let onPageLoad = (data) => { 293 if (this.scanStartTime != 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 }; ··· 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 ··· 375 let lastDate = feedPostTime(last); 376 let daysBack = (startTime - lastDate) / 86400 / 1000; 377 378 + this.progressBar.value = daysBack; 379 } 380 381 /** @param {string[]} dids */ ··· 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(); ··· 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; ··· 508 } else { 509 users[handle].own += 1; 510 allNormalPosts += 1; 511 + ownThreads.add(item.post.uri); 512 } 513 } 514 ··· 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); ··· 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 ··· 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';
+291 -10
private_search_page.js
··· 3 /** @type {number | undefined} */ 4 fetchStartTime; 5 6 constructor() { 7 this.pageElement = $id('private_search_page'); 8 9 this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement); 10 this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement); ··· 13 14 this.searchLine = $(this.pageElement.querySelector('.search')); 15 this.searchField = $(this.pageElement.querySelector('.search-query'), HTMLInputElement); 16 this.results = $(this.pageElement.querySelector('.results')); 17 18 this.timelinePosts = []; 19 20 this.setupEvents(); 21 } 22 23 setupEvents() { 24 - $(this.pageElement.querySelector('form')).addEventListener('submit', (e) => { 25 e.preventDefault(); 26 27 if (!this.fetchStartTime) { 28 - this.fetchTimeline(); 29 } else { 30 this.stopFetch(); 31 } ··· 37 label.innerText = (days == 1) ? '1 day' : `${days} days`; 38 }); 39 40 - this.searchField.addEventListener('input', (e) => { 41 - let query = this.searchField.value.trim().toLowerCase(); 42 43 - if (this.searchTimer) { 44 - clearTimeout(this.searchTimer); 45 } 46 47 - this.searchTimer = setTimeout(() => this.searchInTimeline(query), 100); 48 }); 49 } 50 ··· 56 57 show() { 58 this.pageElement.style.display = 'block'; 59 } 60 61 /** @returns {Promise<void>} */ ··· 96 daysBack = 0; 97 } 98 99 - this.timelinePosts = timeline.map(x => Post.parseFeedPost(x)); 100 101 this.archiveStatus.innerText = "Timeline archive fetched: " + ((daysBack == 1) ? '1 day' : `${daysBack} days`); 102 this.searchLine.style.display = 'block'; ··· 106 this.fetchStartTime = undefined; 107 } 108 109 searchInTimeline(query) { 110 this.results.innerHTML = ''; 111 ··· 113 return; 114 } 115 116 - let matching = this.timelinePosts.filter(x => x.lowercaseText.includes(query)); 117 118 for (let post of matching) { 119 let postView = new PostComponent(post, 'feed').buildElement(); ··· 121 } 122 } 123 124 /** @param {json[]} dataPage, @param {number} startTime */ 125 126 updateProgress(dataPage, startTime) { ··· 131 let lastDate = feedPostTime(last); 132 let daysBack = (startTime - lastDate) / 86400 / 1000; 133 134 - this.progressBar.value = daysBack; 135 } 136 137 stopFetch() {
··· 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); ··· 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 } ··· 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 ··· 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>} */ ··· 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'; ··· 307 this.fetchStartTime = undefined; 308 } 309 310 + /** @param {string} query */ 311 + 312 searchInTimeline(query) { 313 this.results.innerHTML = ''; 314 ··· 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(); ··· 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) { ··· 412 let lastDate = feedPostTime(last); 413 let daysBack = (startTime - lastDate) / 86400 / 1000; 414 415 + this.progressBar.value = daysBack; 416 } 417 418 stopFetch() {
+5 -19
skythread.js
··· 188 } 189 } 190 191 - function toggleLoginInfo(event) { 192 $id('login').classList.toggle('expanded'); 193 } 194 ··· 304 } 305 } 306 307 function openPage(page) { 308 if (!accountAPI.isLoggedIn) { 309 showDialog(loginDialog); ··· 339 let finished = false; 340 let cursor; 341 342 - loadInPages(() => { 343 if (isLoading || finished) { return; } 344 isLoading = true; 345 ··· 387 let cursor; 388 let finished = false; 389 390 - loadInPages(() => { 391 if (isLoading || finished) { return; } 392 isLoading = true; 393 ··· 438 }); 439 }); 440 } 441 - 442 - /** @param {Function} callback */ 443 - 444 - function loadInPages(callback) { 445 - let loadIfNeeded = () => { 446 - if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 500) { 447 - callback(loadIfNeeded); 448 - } 449 - }; 450 - 451 - callback(loadIfNeeded); 452 - 453 - document.addEventListener('scroll', loadIfNeeded); 454 - const resizeObserver = new ResizeObserver(loadIfNeeded); 455 - resizeObserver.observe(document.body); 456 - }
··· 188 } 189 } 190 191 + function toggleLoginInfo() { 192 $id('login').classList.toggle('expanded'); 193 } 194 ··· 304 } 305 } 306 307 + /** @param {string} page */ 308 + 309 function openPage(page) { 310 if (!accountAPI.isLoggedIn) { 311 showDialog(loginDialog); ··· 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 }
+279 -9
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; ··· 640 color: #aaa; 641 } 642 643 .post div.gif img { 644 user-select: none; 645 -webkit-user-select: none; ··· 780 781 #posting_stats_page .user-choice { 782 display: none; 783 } 784 785 - #posting_stats_page .user-choice textarea { 786 - width: 250px; 787 - height: 100px; 788 font-size: 10pt; 789 } 790 791 #posting_stats_page .scan-info { ··· 837 838 #posting_stats_page .scan-result .avatar { 839 width: 24px; 840 border-radius: 14px; 841 vertical-align: middle; 842 margin-right: 2px; 843 - padding: 2px; 844 } 845 846 #posting_stats_page .scan-result td.no { ··· 912 913 #like_stats_page .scan-result .avatar { 914 width: 24px; 915 border-radius: 14px; 916 vertical-align: middle; 917 margin-right: 2px; 918 - padding: 2px; 919 } 920 921 #private_search_page { ··· 952 margin-left: 8px; 953 } 954 955 @media (prefers-color-scheme: dark) { 956 body { 957 background-color: rgb(39, 39, 37); ··· 976 977 #search form input { 978 background-color: transparent; 979 } 980 981 #account_menu { ··· 983 border-color: #ccc; 984 } 985 986 - #account_menu li a { 987 color: #333; 988 border-color: #bbb; 989 background-color: hsla(210, 100%, 4%, 0.12); 990 } 991 992 - #account_menu li a:hover { 993 background-color: hsla(210, 100%, 4%, 0.2); 994 } 995 ··· 1071 color: #888; 1072 } 1073 1074 .post .quote-embed { 1075 background-color: #303030; 1076 border-color: #606060; ··· 1120 color: #ff7070; 1121 } 1122 1123 #posting_stats_page input:disabled + label { 1124 color: #777; 1125 } 1126 1127 #posting_stats_page .scan-result, #posting_stats_page .scan-result td, #posting_stats_page .scan-result th { 1128 border-color: #888; 1129 } ··· 1146 1147 #private_search_page .search-query { 1148 border: 1px solid #666; 1149 } 1150 }
··· 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; ··· 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; ··· 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 { ··· 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 { ··· 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 { ··· 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); ··· 1156 1157 #search form input { 1158 background-color: transparent; 1159 + } 1160 + 1161 + #account.active { 1162 + color: #333; 1163 } 1164 1165 #account_menu { ··· 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; ··· 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 } ··· 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 }
+1 -1
test/ts_test.js
··· 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
··· 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
+10 -1
thread_page.js
··· 23 } 24 25 return p; 26 - } 27 28 /** @param {string} url, @returns {Promise<void>} */ 29 ··· 64 65 if (root.parent) { 66 let p = this.buildParentLink(root.parent); 67 $id('thread').appendChild(p); 68 } 69 }
··· 23 } 24 25 return p; 26 + } 27 28 /** @param {string} url, @returns {Promise<void>} */ 29 ··· 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 }
+2
types.d.ts
··· 20 declare var notificationsPage: NotificationsPage; 21 declare var privateSearchPage: PrivateSearchPage; 22 23 type json = Record<string, any>; 24 25 function $tag(tag: string): HTMLElement;
··· 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;
+34
utils.js
··· 18 } 19 20 /** 21 * @template T 22 * @param {string} tag 23 * @param {string | object} params
··· 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