Thread viewer for Bluesky

Compare changes

Choose any two refs to compare.

+42 -2
index.html
··· 49 49 50 50 <li><a href="#" data-action="login">Log in</a></li> 51 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> 52 58 </ul> 53 59 </div> 54 60 ··· 164 170 </div> 165 171 166 172 <div id="private_search_page"> 167 - <h2>Archive search *Beta*</h2> 173 + <h2>Archive search</h2> 168 174 169 175 <div class="timeline-search"> 170 176 <form> ··· 182 188 <hr> 183 189 </div> 184 190 185 - <p class="search">Search: <input type="text" class="search-query" autocomplete="off"></p> 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> 186 226 187 227 <div class="results"> 188 228 </div>
+8 -1
menu.js
··· 11 11 12 12 html.addEventListener('click', (e) => { 13 13 this.menuElement.style.visibility = 'hidden'; 14 + this.icon.classList.remove('active'); 14 15 }); 16 + 17 + let homeLink = $(this.menuElement.querySelector('a[href="?"]'), HTMLLinkElement); 18 + homeLink.href = location.origin + location.pathname; 15 19 16 20 this.icon.addEventListener('click', (e) => { 17 21 e.stopPropagation(); ··· 66 70 } 67 71 68 72 toggleAccountMenu() { 69 - this.menuElement.style.visibility = (this.menuElement.style.visibility == 'visible') ? 'hidden' : 'visible'; 73 + let isVisible = (this.menuElement.style.visibility == 'visible'); 74 + 75 + this.menuElement.style.visibility = isVisible ? 'hidden' : 'visible'; 76 + this.icon.classList.toggle('active', !isVisible); 70 77 } 71 78 72 79 /** @param {string} buttonName */
+1 -1
minisky.js
··· 301 301 let text = await response.text(); 302 302 let json = text.trim().length > 0 ? JSON.parse(text) : undefined; 303 303 304 - if (response.status == 200) { 304 + if (response.status >= 200 && response.status < 300) { 305 305 return json; 306 306 } else { 307 307 throw new APIError(response.status, json);
+5
models.js
··· 322 322 return this.record.bridgyOriginalText; 323 323 } 324 324 325 + /** @returns {string | undefined} */ 326 + get originalFediURL() { 327 + return this.record.bridgyOriginalUrl; 328 + } 329 + 325 330 /** @returns {boolean} */ 326 331 get isRoot() { 327 332 // I AM ROOOT
+73
post_component.js
··· 162 162 if (this.post.embed) { 163 163 let embed = new EmbedComponent(this.post, this.post.embed).buildElement(); 164 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 + } 165 178 } 166 179 167 180 if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) { ··· 301 314 return p; 302 315 } 303 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 + 304 361 /** @param {string[]} tags, @returns {HTMLElement} */ 305 362 306 363 buildTagsRow(tags) { ··· 433 490 loadHiddenReplies(loadMoreButton) { 434 491 loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`; 435 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 + } 436 509 } 437 510 438 511 /** @param {HTMLLinkElement} authorLink */
+215 -20
private_search_page.js
··· 3 3 /** @type {number | undefined} */ 4 4 fetchStartTime; 5 5 6 + /** @type {number | undefined} */ 7 + importTimer; 8 + 9 + /** @type {string | undefined} */ 10 + lycanImportStatus; 11 + 6 12 constructor() { 7 13 this.pageElement = $id('private_search_page'); 14 + 15 + this.header = $(this.pageElement.querySelector('h2')); 8 16 9 17 this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement); 10 18 this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement); ··· 13 21 14 22 this.searchLine = $(this.pageElement.querySelector('.search')); 15 23 this.searchField = $(this.pageElement.querySelector('.search-query'), HTMLInputElement); 24 + this.searchForm = $(this.pageElement.querySelector('.search-form'), HTMLFormElement); 16 25 this.results = $(this.pageElement.querySelector('.results')); 17 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; 18 39 this.timelinePosts = []; 19 40 20 41 this.setupEvents(); 21 42 22 43 let params = new URLSearchParams(location.search); 23 44 this.mode = params.get('mode'); 24 - this.lycanMode = params.get('lycan'); 45 + let lycan = params.get('lycan'); 25 46 26 - if (this.lycanMode == 'local') { 27 - this.lycan = new BlueskyAPI('http://localhost:3000', false); 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'; 28 53 } 29 54 } 30 55 31 56 setupEvents() { 32 - $(this.pageElement.querySelector('form')).addEventListener('submit', (e) => { 57 + this.timelineSearchForm.addEventListener('submit', (e) => { 33 58 e.preventDefault(); 34 59 35 60 if (!this.fetchStartTime) { ··· 57 82 this.searchInTimeline(query); 58 83 } 59 84 } 85 + }); 86 + 87 + this.lycanImportForm.addEventListener('submit', (e) => { 88 + e.preventDefault(); 89 + this.startLycanImport(); 60 90 }); 61 91 } 62 92 ··· 70 100 this.pageElement.style.display = 'block'; 71 101 72 102 if (this.mode == 'likes') { 73 - this.pageElement.querySelector('.timeline-search').style.display = 'none'; 103 + this.header.innerText = 'Archive search'; 104 + this.timelineSearch.style.display = 'none'; 105 + this.searchCollections.style.display = 'block'; 74 106 this.searchLine.style.display = 'block'; 107 + this.lycanImportSection.style.display = 'none'; 108 + this.checkLycanImportStatus(); 75 109 } else { 76 - this.pageElement.querySelector('.timeline-search').style.display = 'block'; 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}`); 77 259 } 78 260 } 79 261 ··· 115 297 daysBack = 0; 116 298 } 117 299 118 - this.timelinePosts = timeline.map(x => Post.parseFeedPost(x)); 300 + this.timelinePosts = timeline; 119 301 120 302 this.archiveStatus.innerText = "Timeline archive fetched: " + ((daysBack == 1) ? '1 day' : `${daysBack} days`); 121 303 this.searchLine.style.display = 'block'; ··· 134 316 return; 135 317 } 136 318 137 - let matching = this.timelinePosts.filter(x => x.lowercaseText.includes(query)); 319 + let matching = this.timelinePosts 320 + .filter(x => x.post.record.text.toLowerCase().includes(query)) 321 + .map(x => Post.parseFeedPost(x)); 138 322 139 323 for (let post of matching) { 140 324 let postView = new PostComponent(post, 'feed').buildElement(); ··· 145 329 /** @param {string} query */ 146 330 147 331 searchInLycan(query) { 148 - if (query.length == 0) { 149 - this.results.innerHTML = ''; 332 + if (query.length == 0 || this.lycanImportStatus != 'finished') { 150 333 return; 151 334 } 152 335 153 - this.results.innerHTML = '...'; 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); 154 343 155 344 let isLoading = false; 156 345 let firstPageLoaded = false; ··· 163 352 164 353 let response; 165 354 166 - if (this.lycanMode == 'local') { 167 - let params = { query: query, user: window.accountAPI.user.did }; 355 + if (this.localLycan) { 356 + let params = { collection, query, user: accountAPI.user.did }; 168 357 if (cursor) params.cursor = cursor; 169 358 170 - response = await this.lycan.getRequest('blue.feeds.lycan.searchPosts', params); 359 + response = await this.localLycan.getRequest('blue.feeds.lycan.searchPosts', params); 171 360 } else { 172 - let params = { query: query }; 361 + let params = { collection, query }; 173 362 if (cursor) params.cursor = cursor; 174 363 175 364 response = await accountAPI.getRequest('blue.feeds.lycan.searchPosts', params, { 176 - headers: { 'atproto-proxy': 'did:web:lycan.feeds.blue#lycan' } 365 + headers: { 'atproto-proxy': this.lycanAddress } 177 366 }); 178 367 } 179 368 180 369 if (response.posts.length == 0) { 181 - this.results.append(firstPageLoaded ? "No more results." : "No results."); 370 + let p = $tag('p.results-end', { text: firstPageLoaded ? "No more results." : "No results." }); 371 + loading.remove(); 372 + this.results.append(p); 373 + 182 374 isLoading = false; 183 375 finished = true; 184 376 return; 185 377 } 186 378 187 - let records = await window.accountAPI.loadPosts(response.posts); 379 + let records = await accountAPI.loadPosts(response.posts); 188 380 let posts = records.map(x => new Post(x)); 189 381 190 382 if (!firstPageLoaded) { 191 - this.results.innerHTML = ''; 383 + loading.remove(); 192 384 firstPageLoaded = true; 193 385 } 194 386 195 387 for (let post of posts) { 196 - let postView = new PostComponent(post, 'feed').buildElement(); 388 + let component = new PostComponent(post, 'feed'); 389 + let postView = component.buildElement(); 197 390 this.results.appendChild(postView); 391 + 392 + component.highlightSearchResults(response.terms); 198 393 } 199 394 200 395 isLoading = false;
+161 -5
style.css
··· 127 127 padding: 6px 11px; 128 128 } 129 129 130 - #account_menu li a { 130 + #account_menu li a[data-action] { 131 131 display: inline-block; 132 132 color: #333; 133 133 font-size: 11pt; ··· 138 138 background-color: hsla(210, 100%, 4%, 0.12); 139 139 } 140 140 141 - #account_menu li a:hover { 141 + #account_menu li a[data-action]:hover { 142 142 background-color: hsla(210, 100%, 4%, 0.2); 143 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; 144 160 } 145 161 146 162 #account_menu li .check { ··· 486 502 margin-top: 18px; 487 503 } 488 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 + 489 512 .post .quote-embed { 490 513 border: 1px solid #ddd; 491 514 border-radius: 8px; ··· 640 663 color: #aaa; 641 664 } 642 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 + 643 693 .post div.gif img { 644 694 user-select: none; 645 695 -webkit-user-select: none; ··· 1034 1084 border-radius: 6px; 1035 1085 padding: 5px 6px; 1036 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; 1037 1116 } 1038 1117 1039 1118 #private_search_page .results > .post { 1040 - padding-left: 0; 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; 1041 1133 } 1042 1134 1043 1135 @media (prefers-color-scheme: dark) { ··· 1066 1158 background-color: transparent; 1067 1159 } 1068 1160 1161 + #account.active { 1162 + color: #333; 1163 + } 1164 + 1069 1165 #account_menu { 1070 1166 background: hsl(210, 33.33%, 94.0%); 1071 1167 border-color: #ccc; 1072 1168 } 1073 1169 1074 - #account_menu li a { 1170 + #account_menu li a[data-action] { 1075 1171 color: #333; 1076 1172 border-color: #bbb; 1077 1173 background-color: hsla(210, 100%, 4%, 0.12); 1078 1174 } 1079 1175 1080 - #account_menu li a:hover { 1176 + #account_menu li a[data-action]:hover { 1081 1177 background-color: hsla(210, 100%, 4%, 0.2); 1082 1178 } 1083 1179 ··· 1159 1255 color: #888; 1160 1256 } 1161 1257 1258 + .post .body .highlight { 1259 + background-color: rgba(255, 255, 0, 0.35); 1260 + } 1261 + 1162 1262 .post .quote-embed { 1163 1263 background-color: #303030; 1164 1264 border-color: #606060; ··· 1208 1308 color: #ff7070; 1209 1309 } 1210 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 + 1211 1355 #posting_stats_page input:disabled + label { 1212 1356 color: #777; 1213 1357 } ··· 1260 1404 1261 1405 #private_search_page .search-query { 1262 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; 1263 1419 } 1264 1420 }
+9
thread_page.js
··· 65 65 if (root.parent) { 66 66 let p = this.buildParentLink(root.parent); 67 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); 68 77 } 69 78 } 70 79
+2
types.d.ts
··· 20 20 declare var notificationsPage: NotificationsPage; 21 21 declare var privateSearchPage: PrivateSearchPage; 22 22 23 + declare var Paginator: PaginatorType; 24 + 23 25 type json = Record<string, any>; 24 26 25 27 function $tag(tag: string): HTMLElement;
+7 -2
utils.js
··· 17 17 } 18 18 } 19 19 20 - window.Paginator = { 21 - /** @param {Function} callback */ 20 + /** 21 + * @typedef {object} PaginatorType 22 + * @property {(callback: (boolean) => void) => void} loadInPages 23 + * @property {(() => void)=} scrollHandler 24 + * @property {ResizeObserver=} resizeObserver 25 + */ 22 26 27 + window.Paginator = { 23 28 loadInPages(callback) { 24 29 if (this.scrollHandler) { 25 30 document.removeEventListener('scroll', this.scrollHandler);