Thread viewer for Bluesky

Compare changes

Choose any two refs to compare.

Changed files
+7473 -7076
dist
icons
lib
src
test
+2
.gitattributes
··· 1 + dist/*.js binary 2 + dist/*.css binary
+2
.gitignore
··· 1 1 Capfile 2 2 config 3 3 debug* 4 + dist/*.map 4 5 Gemfile* 6 + node_modules
+1
.tm_properties
··· 1 + excludeInFolderSearch = "{$excludeInFolderSearch,dist,node_modules}"
+1 -2
LICENSE.txt
··· 1 1 The zlib License 2 2 3 - Copyright (c) 2024 Jakub Suder 3 + Copyright (c) 2025 Jakub Suder 4 4 5 5 This software is provided 'as-is', without any express or implied 6 6 warranty. In no event will the authors be held liable for any damages ··· 19 19 misrepresented as being the original software. 20 20 21 21 3. This notice may not be removed or altered from any source distribution. 22 -
+47 -4
README.md
··· 6 6 7 7 <img width="600" src="https://github.com/mackuba/skythread/assets/28465/d1314c89-61e9-4667-b906-32e0cb96f198"> 8 8 9 - To use Skythread, open the GitHub pages view of this repo: https://blue.mackuba.eu/skythread/ (or download a copy and use it locally). 9 + 10 + ## List of features 11 + 12 + Main parts of the app: 13 + 14 + * viewing threads (look up by [bsky.app](https://bsky.app) URL or an at:// URI) 15 + * listing quotes of a given post (including "detached" ones) 16 + * hashtag feed โ€“ latest posts with a given hashtag 17 + * personal statistics & search tools: 18 + - posting stats: statistics of who posts how much 19 + - like stats: who likes your posts and vice versa 20 + - timeline search: search in the recent posts in your Following feed 21 + - archive search: search in your likes, reposts, quotes and bookmarks (pins) 22 + 23 + Also: 24 + 25 + * liking comments in the thread 26 + * loading contents of a blocked post on demand 27 + * detecting & loading "hidden replies" hidden by Bluesky because of a "nuclear block" (look for an orange link with a "biohazard" icon) 28 + * alternatively, both "hidden replies" and blocked post links can be hidden for peace of mind by turning off "Show infohazards" in the top-right menu 29 + * "incognito mode" which lets you browse threads logged out but still be able to like comments from your account 30 + * displays outline tags (the `tags` field in the post record), link cards for normal links, starter packs, feeds and lists 31 + * special handling for Mastodon posts bridged through [Bridgy](https://fed.brid.gy) โ€“ full post content beyond 300 characters is loaded from the record data 32 + * Tenor GIFs are loaded and played inline once you click on the tenor.com link card 33 + * nested quotes (quote-chains) are automatically loaded beyond the first level 34 + * self-replies are collapsed into a flat vertical list if possible 35 + 36 + 37 + ## What is currently missing (but planned) 38 + 39 + * images and videos aren't shown inline yet, only as links like `[Image]` (I'll need to make sure first that labels and moderation preferences are always applied as needed) 40 + * UI is not currently designed with mobile phones in mind (though it *should* work) 41 + * OAuth support โ€“ only app passwords are supported 42 + * easy configuration of things like date format, language, preferred AppView and other services, enabled labellers, some UI preferences etc. 43 + 44 + 45 + ## Running 46 + 47 + You can access Skythread at: 10 48 49 + - [skythread.mackuba.eu](https://skythread.mackuba.eu) โ€“ new version rewritten in Svelte 50 + - [blue.mackuba.eu/skythread](https://blue.mackuba.eu/skythread/) โ€“ old stable version in vanilla JS 11 51 12 - ## TODO 52 + You can also download a zipped copy of this repo or clone it and use it locally โ€“ just open the `index.html` at the root of the project, no need to start any servers! 13 53 14 - * showing images and GIFs 54 + 55 + ## Development 56 + 57 + If you want to make any changes, you'll need to install [Bun](https://bun.com) and install the project dependences with `bun install`. Use `bun build.js` or `bun serve.js` to recompile the bundles in `dist`. 15 58 16 59 17 60 ## Credits 18 61 19 - Copyright ยฉ 2024 [Kuba Suder](https://mackuba.eu) (<a href="https://bsky.app/profile/mackuba.eu">@mackuba.eu</a> on Bluesky). Licensed under [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT). 62 + Copyright ยฉ 2025 [Kuba Suder](https://mackuba.eu) (<a href="https://bsky.app/profile/mackuba.eu">@mackuba.eu</a> on Bluesky). Licensed under [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT). 20 63 21 64 Pull requests, bug reports and suggestions are welcome :)
-469
api.js
··· 1 - /** 2 - * Thrown when the response is technically a "success" one, but the returned data is not what it should be. 3 - */ 4 - 5 - class ResponseDataError extends Error {} 6 - 7 - 8 - /** 9 - * Thrown when the passed URL is not a supported post URL on bsky.app. 10 - */ 11 - 12 - class URLError extends Error { 13 - 14 - /** @param {string} message */ 15 - constructor(message) { 16 - super(message); 17 - } 18 - } 19 - 20 - 21 - /** 22 - * Caches the mapping of handles to DIDs to avoid unnecessary API calls to resolveHandle or getProfile. 23 - */ 24 - 25 - class HandleCache { 26 - prepareCache() { 27 - if (!this.cache) { 28 - this.cache = JSON.parse(localStorage.getItem('handleCache') ?? '{}'); 29 - } 30 - } 31 - 32 - saveCache() { 33 - localStorage.setItem('handleCache', JSON.stringify(this.cache)); 34 - } 35 - 36 - /** @param {string} handle, @returns {string | undefined} */ 37 - 38 - getHandleDid(handle) { 39 - this.prepareCache(); 40 - return this.cache[handle]; 41 - } 42 - 43 - /** @param {string} handle, @param {string} did */ 44 - 45 - setHandleDid(handle, did) { 46 - this.prepareCache(); 47 - this.cache[handle] = did; 48 - this.saveCache(); 49 - } 50 - 51 - /** @param {string} did, @returns {string | undefined} */ 52 - 53 - findHandleByDid(did) { 54 - this.prepareCache(); 55 - let found = Object.entries(this.cache).find((e) => e[1] == did); 56 - return found ? found[0] : undefined; 57 - } 58 - } 59 - 60 - 61 - /** 62 - * Stores user's access tokens and data in local storage after they log in. 63 - */ 64 - 65 - class LocalStorageConfig { 66 - constructor() { 67 - let data = localStorage.getItem('userData'); 68 - this.user = data ? JSON.parse(data) : {}; 69 - } 70 - 71 - save() { 72 - if (this.user) { 73 - localStorage.setItem('userData', JSON.stringify(this.user)); 74 - } else { 75 - localStorage.removeItem('userData'); 76 - } 77 - } 78 - } 79 - 80 - 81 - /** 82 - * API client for connecting to the Bluesky XRPC API (authenticated or not). 83 - */ 84 - 85 - class BlueskyAPI extends Minisky { 86 - 87 - /** @param {string | undefined} host, @param {boolean} useAuthentication */ 88 - constructor(host, useAuthentication) { 89 - super(host, useAuthentication ? new LocalStorageConfig() : undefined); 90 - 91 - this.handleCache = new HandleCache(); 92 - this.profiles = {}; 93 - } 94 - 95 - /** @param {json} author */ 96 - 97 - cacheProfile(author) { 98 - this.profiles[author.did] = author; 99 - this.profiles[author.handle] = author; 100 - this.handleCache.setHandleDid(author.handle, author.did); 101 - } 102 - 103 - /** @param {string} did, @returns {string | undefined} */ 104 - 105 - findHandleByDid(did) { 106 - return this.handleCache.findHandleByDid(did); 107 - } 108 - 109 - /** @param {string} did, @returns {Promise<string>} */ 110 - 111 - async fetchHandleForDid(did) { 112 - let cachedHandle = this.handleCache.findHandleByDid(did); 113 - 114 - if (cachedHandle) { 115 - return cachedHandle; 116 - } else { 117 - let author = await this.loadUserProfile(did); 118 - return author.handle; 119 - } 120 - } 121 - 122 - /** @param {string} string, @returns {[string, string]} */ 123 - 124 - static parsePostURL(string) { 125 - let url; 126 - 127 - try { 128 - url = new URL(string); 129 - } catch (error) { 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('/'); 138 - 139 - if (parts.length < 5 || parts[1] != 'profile' || parts[3] != 'post') { 140 - throw new URLError('This is not a valid thread URL'); 141 - } 142 - 143 - let handle = parts[2]; 144 - let postId = parts[4]; 145 - 146 - return [handle, postId]; 147 - } 148 - 149 - /** @param {string} handle, @returns {Promise<string>} */ 150 - 151 - async resolveHandle(handle) { 152 - let cachedDid = this.handleCache.getHandleDid(handle); 153 - 154 - if (cachedDid) { 155 - return cachedDid; 156 - } else { 157 - let json = await this.getRequest('com.atproto.identity.resolveHandle', { handle }, { auth: false }); 158 - let did = json['did']; 159 - 160 - if (did) { 161 - this.handleCache.setHandleDid(handle, did); 162 - return did; 163 - } else { 164 - throw new ResponseDataError('Missing DID in response: ' + JSON.stringify(json)); 165 - } 166 - } 167 - } 168 - 169 - /** @param {string} url, @returns {Promise<json>} */ 170 - 171 - async loadThreadByURL(url) { 172 - let [handle, postId] = BlueskyAPI.parsePostURL(url); 173 - return await this.loadThreadById(handle, postId); 174 - } 175 - 176 - /** @param {string} author, @param {string} postId, @returns {Promise<json>} */ 177 - 178 - async loadThreadById(author, postId) { 179 - let did = author.startsWith('did:') ? author : await this.resolveHandle(author); 180 - let postURI = `at://${did}/app.bsky.feed.post/${postId}`; 181 - return await this.loadThreadByAtURI(postURI); 182 - } 183 - 184 - /** @param {string} uri, @returns {Promise<json>} */ 185 - 186 - async loadThreadByAtURI(uri) { 187 - return await this.getRequest('app.bsky.feed.getPostThread', { uri: uri, depth: 10 }); 188 - } 189 - 190 - /** @param {string} handle, @returns {Promise<json>} */ 191 - 192 - async loadUserProfile(handle) { 193 - if (this.profiles[handle]) { 194 - return this.profiles[handle]; 195 - } else { 196 - let profile = await this.getRequest('app.bsky.actor.getProfile', { actor: handle }); 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>} */ 210 - 211 - async getCurrentUserAvatar() { 212 - let json = await this.getRequest('com.atproto.repo.getRecord', { 213 - repo: this.user.did, 214 - collection: 'app.bsky.actor.profile', 215 - rkey: 'self' 216 - }); 217 - 218 - return json.value.avatar; 219 - } 220 - 221 - /** @returns {Promise<string?>} */ 222 - 223 - async loadCurrentUserAvatar() { 224 - if (!this.config || !this.config.user) { 225 - throw new AuthError("User isn't logged in"); 226 - } 227 - 228 - let avatar = await this.getCurrentUserAvatar(); 229 - 230 - if (avatar) { 231 - let url = `https://cdn.bsky.app/img/avatar/plain/${this.user.did}/${avatar.ref.$link}@jpeg`; 232 - this.config.user.avatar = url; 233 - this.config.save(); 234 - return url; 235 - } else { 236 - return null; 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 }); 244 - return json.replies; 245 - } 246 - 247 - /** @param {string} uri, @returns {Promise<number>} */ 248 - 249 - async getQuoteCount(uri) { 250 - let json = await this.getRequest('blue.feeds.post.getQuoteCount', { uri }); 251 - return json.quoteCount; 252 - } 253 - 254 - /** @param {string} url, @param {string | undefined} cursor, @returns {Promise<json>} */ 255 - 256 - async getQuotes(url, cursor = undefined) { 257 - let postURI; 258 - 259 - if (url.startsWith('at://')) { 260 - postURI = url; 261 - } else { 262 - let [handle, postId] = BlueskyAPI.parsePostURL(url); 263 - let did = handle.startsWith('did:') ? handle : await appView.resolveHandle(handle); 264 - postURI = `at://${did}/app.bsky.feed.post/${postId}`; 265 - } 266 - 267 - let params = { uri: postURI }; 268 - 269 - if (cursor) { 270 - params['cursor'] = cursor; 271 - } 272 - 273 - return await this.getRequest('blue.feeds.post.getQuotes', params); 274 - } 275 - 276 - /** @param {string} hashtag, @param {string | undefined} cursor, @returns {Promise<json>} */ 277 - 278 - async getHashtagFeed(hashtag, cursor = undefined) { 279 - let params = { q: '#' + hashtag, limit: 50, sort: 'latest' }; 280 - 281 - if (cursor) { 282 - params['cursor'] = cursor; 283 - } 284 - 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>} */ 408 - 409 - async loadPost(postURI) { 410 - let posts = await this.loadPosts([postURI]); 411 - 412 - if (posts.length == 1) { 413 - return posts[0]; 414 - } else { 415 - throw new ResponseDataError('Post not found'); 416 - } 417 - } 418 - 419 - /** @param {string} postURI, @returns {Promise<json | undefined>} */ 420 - 421 - async loadPostIfExists(postURI) { 422 - let posts = await this.loadPosts([postURI]); 423 - return posts[0]; 424 - } 425 - 426 - /** @param {string[]} uris, @returns {Promise<object[]>} */ 427 - 428 - async loadPosts(uris) { 429 - if (uris.length > 0) { 430 - let response = await this.getRequest('app.bsky.feed.getPosts', { uris }); 431 - return response.posts; 432 - } else { 433 - return []; 434 - } 435 - } 436 - 437 - /** @param {Post} post, @returns {Promise<json>} */ 438 - 439 - async likePost(post) { 440 - return await this.postRequest('com.atproto.repo.createRecord', { 441 - repo: this.user.did, 442 - collection: 'app.bsky.feed.like', 443 - record: { 444 - subject: { 445 - uri: post.uri, 446 - cid: post.cid 447 - }, 448 - createdAt: new Date().toISOString() 449 - } 450 - }); 451 - } 452 - 453 - /** @param {string} uri, @returns {Promise<void>} */ 454 - 455 - async removeLike(uri) { 456 - let { rkey } = atURI(uri); 457 - 458 - await this.postRequest('com.atproto.repo.deleteRecord', { 459 - repo: this.user.did, 460 - collection: 'app.bsky.feed.like', 461 - rkey: rkey 462 - }); 463 - } 464 - 465 - resetTokens() { 466 - delete this.user.avatar; 467 - super.resetTokens(); 468 - } 469 - }
+42
build.js
··· 1 + import { parseArgs } from 'util'; 2 + import { SveltePlugin } from 'bun-plugin-svelte'; 3 + 4 + function buildOptions(devMode) { 5 + return { 6 + conditions: devMode ? [] : ['production'], 7 + entrypoints: ['src/skythread.js'], 8 + outdir: 'dist', 9 + format: 'iife', 10 + minify: true, 11 + sourcemap: true, 12 + plugins: [ 13 + SveltePlugin({ 14 + // When `true`, this plugin will generate development-only checks and other niceties. 15 + // When `false`, this plugin will generate production-ready code 16 + development: devMode, 17 + }) 18 + ] 19 + }; 20 + } 21 + 22 + async function runBuild(devMode) { 23 + let options = buildOptions(devMode); 24 + return await Bun.build(options); 25 + } 26 + 27 + if (import.meta.main) { 28 + let { values: options } = parseArgs({ 29 + args: Bun.argv, 30 + options: { 31 + dev: { 32 + type: 'boolean' 33 + } 34 + }, 35 + strict: true, 36 + allowPositionals: true 37 + }); 38 + 39 + await runBuild(options.dev); 40 + } 41 + 42 + export { buildOptions, runBuild };
+136
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 0, 4 + "workspaces": { 5 + "": { 6 + "dependencies": { 7 + "dompurify": "^3.3.0", 8 + "svelte": "^5.45.2", 9 + }, 10 + "devDependencies": { 11 + "bun-plugin-svelte": "^0.0.6", 12 + "esbuild": "^0.27.0", 13 + "esbuild-svelte": "^0.9.3", 14 + "svelte-check": "^4.3.4", 15 + "typescript": "^5.9.3", 16 + }, 17 + }, 18 + }, 19 + "packages": { 20 + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], 21 + 22 + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], 23 + 24 + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="], 25 + 26 + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="], 27 + 28 + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="], 29 + 30 + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="], 31 + 32 + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="], 33 + 34 + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="], 35 + 36 + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="], 37 + 38 + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="], 39 + 40 + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="], 41 + 42 + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="], 43 + 44 + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="], 45 + 46 + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="], 47 + 48 + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="], 49 + 50 + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="], 51 + 52 + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="], 53 + 54 + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="], 55 + 56 + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="], 57 + 58 + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="], 59 + 60 + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="], 61 + 62 + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="], 63 + 64 + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="], 65 + 66 + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="], 67 + 68 + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], 69 + 70 + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], 71 + 72 + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], 73 + 74 + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], 75 + 76 + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 77 + 78 + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 79 + 80 + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], 81 + 82 + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.6", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ=="], 83 + 84 + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], 85 + 86 + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], 87 + 88 + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], 89 + 90 + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], 91 + 92 + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], 93 + 94 + "bun-plugin-svelte": ["bun-plugin-svelte@0.0.6", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-HuEDvOieVwXvhpcHLcASeQIOVgje2GRO3Tu0ypJh3MkjGLkEBOQ1+6FWsMgb54FxfbPw9JP3q0WlBd8SjgrnGQ=="], 95 + 96 + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], 97 + 98 + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], 99 + 100 + "devalue": ["devalue@5.5.0", "", {}, "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w=="], 101 + 102 + "dompurify": ["dompurify@3.3.0", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="], 103 + 104 + "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], 105 + 106 + "esbuild-svelte": ["esbuild-svelte@0.9.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.19" }, "peerDependencies": { "esbuild": ">=0.17.0", "svelte": ">=4.2.1 <6" } }, "sha512-CgEcGY1r/d16+aggec3czoFBEBaYIrFOnMxpsO6fWNaNEqHregPN5DLAPZDqrL7rXDNplW+WMu8s3GMq9FqgJA=="], 107 + 108 + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], 109 + 110 + "esrap": ["esrap@2.2.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-WBmtxe7R9C5mvL4n2le8nMUe4mD5V9oiK2vJpQ9I3y20ENPUomPcphBXE8D1x/Bm84oN1V+lOfgXxtqmxTp3Xg=="], 111 + 112 + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], 113 + 114 + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], 115 + 116 + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], 117 + 118 + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], 119 + 120 + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], 121 + 122 + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 123 + 124 + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], 125 + 126 + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], 127 + 128 + "svelte": ["svelte@5.45.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-yyXdW2u3H0H/zxxWoGwJoQlRgaSJLp+Vhktv12iRw2WRDlKqUPT54Fi0K/PkXqrdkcQ98aBazpy0AH4BCBVfoA=="], 129 + 130 + "svelte-check": ["svelte-check@4.3.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw=="], 131 + 132 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 133 + 134 + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], 135 + } 136 + }
+2
bunfig.toml
··· 1 + [serve.static] 2 + plugins = ["bun-plugin-svelte"]
+1
dist/skythread.css
··· 1 + .dialog.svelte-1fggtsn{position:fixed;display:flex;z-index:10;background-color:#f0f0f066;justify-content:center;align-items: center;padding-bottom:5%;inset:0}.dialog.svelte-1fggtsn.expanded{padding-bottom:0}.dialog.svelte-1fggtsn~main{filter:blur(8px)}.dialog.svelte-1fggtsn form{position:relative;background-color:#f5faff;border:2px solid #b3d9ff;border-radius:10px;padding:15px 25px}.dialog.svelte-1fggtsn .close{position:absolute;color:#80bfff;opacity:.6;top:5px;right:5px}.dialog.svelte-1fggtsn .close:hover{color:#4ca6ff;opacity:1}.dialog.svelte-1fggtsn p{text-align:center;line-height:125%}.dialog.svelte-1fggtsn h2{text-align:center;margin-bottom:25px;padding-right:10px;font-size:13pt;font-weight:600}.dialog.svelte-1fggtsn input[type=text]{border:1px solid #d6d6d6;border-radius:4px;width:200px;margin:0 15px;padding:5px 6px;font-size:11pt}.dialog.svelte-1fggtsn input[type=password]{border:1px solid #d6d6d6;border-radius:4px;width:200px;margin:0 15px;padding:5px 6px;font-size:11pt}.dialog.svelte-1fggtsn p.submit{margin-top:25px}.dialog.svelte-1fggtsn input[type=submit]{background-color:#d6ebff;border:1px solid #b6d9fb;border-radius:4px;width:150px;padding:5px 6px;font-size:11pt}.dialog.svelte-1fggtsn input[type=submit]:hover{background-color:#cce6ff;border:1px solid #a8d1fa}.dialog.svelte-1fggtsn input[type=submit]:active{background-color:#bddeff;border:1px solid #9eccfa}form.svelte-1b6ue70{width:400px}.dialog p.submit.svelte-1b6ue70{margin-top:40px;margin-bottom:20px}.dialog input[type=submit].svelte-1b6ue70{width:180px;margin-left:5px;margin-right:5px}p.info.svelte-1pnuyy2{font-size:9pt}p.info.svelte-1pnuyy2 a:where(.svelte-1pnuyy2){color:#666}.cloudy.svelte-1pnuyy2{color:#99bfe6;margin:14px 0}.info-box.svelte-1pnuyy2{background-color:#fffceb;border:1px solid #fc3;border-radius:6px;width:360px;font-size:11pt}.info-box.svelte-1pnuyy2 p:where(.svelte-1pnuyy2){text-align:left;margin:15px}@media (prefers-color-scheme:dark){#login{background-color:#f0f0f026}form.svelte-1pnuyy2{background-color:#384047;border-color:#52667a}.close.svelte-1pnuyy2{color:#668099;opacity:.6}.close.svelte-1pnuyy2:hover{color:#668099;opacity:1}p.info.svelte-1pnuyy2 a:where(.svelte-1pnuyy2){color:#888}input[type=text].svelte-1pnuyy2,input[type=password].svelte-1pnuyy2{border-color:#666}input[type=submit].svelte-1pnuyy2{background-color:#4f5964;border-color:#576675}input[type=submit].svelte-1pnuyy2:active{background-color:#434d56;border-color:#4c5967}.cloudy.svelte-1pnuyy2{color:#99bfe6}.info-box.svelte-1pnuyy2{background-color:#6b612e;border-color:#e6ac00}.info-box.svelte-1pnuyy2 a:where(.svelte-1pnuyy2){color:#ffbf00}}li.svelte-1obod96 .button:where(.svelte-1obod96){display:inline-block;color:#333;background-color:#000a141f;border:1px solid #bbb;border-radius:5px;margin-top:8px;padding:3px 5px;font-size:11pt}li.svelte-1obod96 .button:where(.svelte-1obod96):hover{text-decoration:none;background-color:#000a1433}@media (prefers-color-scheme:dark){li.svelte-1obod96 .button:where(.svelte-1obod96){color:#333;background-color:#000a141f;border-color:#bbb}li.svelte-1obod96 .button:where(.svelte-1obod96):hover{background-color:#000a1433}}#account.svelte-jzoz05{position:fixed;z-index:20;user-select:none;-webkit-user-select:none;line-height:24px;top:10px;left:10px}#account.svelte-jzoz05 i:where(.svelte-jzoz05){opacity:.4}#account.svelte-jzoz05 i:where(.svelte-jzoz05):hover{cursor:pointer;opacity:.6}#account.svelte-jzoz05 img.avatar{border-radius:13px;width:24px;height:24px;box-shadow:0 0 2px #000}#account_menu.svelte-jzoz05{position:fixed;visibility:hidden;z-index:15;user-select:none;-webkit-user-select:none;background:#ebf0f5;border:1px solid #ccc;border-radius:5px;padding-top:30px;top:5px;left:5px}#account_menu.svelte-jzoz05 ul:where(.svelte-jzoz05){list-style-type:none;margin:0 0 10px;padding:6px 11px}#account_menu.svelte-jzoz05 li:not(.link)+li.link{border-top:1px solid #ccc;margin-top:16px;padding-top:10px}li.link.svelte-jzoz05{margin-top:8px;margin-left:2px}li.link.svelte-jzoz05 a:where(.svelte-jzoz05){color:#333;font-size:11pt}@media (prefers-color-scheme:dark){#account.active.svelte-jzoz05{color:#333}#account_menu.svelte-jzoz05{background:#ebf0f5;border-color:#ccc}}#loader.svelte-1larzq0{position:fixed;width:36px;height:36px;margin:auto;inset:0}#loader.svelte-1larzq0 img:where(.svelte-1larzq0){animation:rotation 3s infinite linear;width:36px}@media (prefers-color-scheme:dark){#loader.svelte-1larzq0{filter:invert()}}.edge.svelte-qe4209{position:absolute;width:6px;top:30px;bottom:0;left:-2px}.line.svelte-qe4209{position:absolute;border-left:1px solid #aaa;top:0;bottom:0;left:2px}.edge.svelte-qe4209:hover .line:where(.svelte-qe4209){border-left:2px solid #888}.plus.svelte-qe4209{position:absolute;width:14px;top:8px;left:-6px}.post.collapsed .line.svelte-qe4209,.post.flat>.margin.svelte-qe4209{display:none}@media (prefers-color-scheme:dark){.line.svelte-qe4209{border-left-color:#666}.edge.svelte-qe4209:hover .line:where(.svelte-qe4209){border-left-color:#888}.plus.svelte-qe4209{filter:invert()}}.fedi-link.svelte-ul6xja{display:inline-block;margin-top:2px;margin-bottom:6px}.fedi-link.svelte-ul6xja:hover{text-decoration:none}div.svelte-ul6xja{color:#555;border:1px solid #d0d0d0;border-radius:8px;padding:5px 9px;font-size:10pt}i.svelte-ul6xja{margin-right:3px}.fedi-link.svelte-ul6xja:hover div:where(.svelte-ul6xja){background-color:#f6f7f8;border:1px solid #c8c8c8}@media (prefers-color-scheme:dark){div.svelte-ul6xja{color:#909090;border-color:#606060}.fedi-link.svelte-ul6xja:hover div:where(.svelte-ul6xja){background-color:#444;border-color:#909090}}.hidden-replies.svelte-1epmfrv{margin-top:20px;font-size:11pt}.hidden-replies.svelte-1epmfrv a:where(.svelte-1epmfrv){color:#8b4513;font-size:12pt}.bridged-body.svelte-rk6ws2 p+p{margin-top:18px}.svelte-rk6ws2::highlight(search-results){background-color:#ffff00bf}@media (prefers-color-scheme:dark){.svelte-rk6ws2::highlight(search-results){background-color:#ffff0059}}h2.svelte-b7kxl{margin-bottom:0;font-size:12pt}.avatar.svelte-b7kxl{vertical-align:middle;border-radius:16px;width:32px;height:32px;margin-bottom:3px;margin-right:4px}.no-avatar.svelte-b7kxl,.muted-avatar.svelte-b7kxl{color:#aaa;vertical-align:middle;background-color:#eee;border-radius:16px;margin-right:4px}.muted-avatar.svelte-b7kxl{color:#bbb}.handle.svelte-b7kxl{color:#888;vertical-align:text-top;font-size:11pt;font-weight:400}.mastodon.svelte-b7kxl{position:relative;width:15px;margin-left:3px;top:2px}.time.svelte-b7kxl{color:#666;vertical-align:text-top;font-size:10pt;font-weight:400}@media (prefers-color-scheme:dark){.handle.svelte-b7kxl,.separator.svelte-b7kxl{color:#888}.time.svelte-b7kxl{color:#aaa}h2.svelte-b7kxl .action{color:#888}}a.svelte-1d08m6n{color:#333;background-color:#f0f7fe;border:1px solid #b6d3fb;border-radius:6px;margin-right:5px;padding:3px 7px;font-size:10pt}a.svelte-1d08m6n:hover{text-decoration:none;background-color:#ddedfd}.stats.svelte-14wd2aa{color:#666;font-size:10pt}a.svelte-14wd2aa{color:#666;text-decoration:none}a.svelte-14wd2aa:hover{text-decoration:underline}i.svelte-14wd2aa{color:#888;font-size:9pt}i.fa-heart.svelte-14wd2aa{color:#aaa}i.fa-heart.liked.svelte-14wd2aa{color:#e03030}i.fa-heart.svelte-14wd2aa:hover{color:#888;cursor:pointer}i.fa-heart.liked.svelte-14wd2aa:hover{color:#c02020}span.svelte-14wd2aa{margin-right:7px}.blocked-info.svelte-14wd2aa{color:#a02020;margin-left:5px;font-weight:700}@media (prefers-color-scheme:dark){.stats.svelte-14wd2aa{color:#aaa}i.svelte-14wd2aa{color:#888}i.fa-heart.svelte-14wd2aa{color:#aaa}i.fa-heart.liked.svelte-14wd2aa{color:#f04040}i.fa-heart.svelte-14wd2aa:hover{color:#eee}i.fa-heart.liked.svelte-14wd2aa:hover{color:#ff7070}}.image-alt.svelte-1d4qxx0{color:#666;margin-bottom:20px;font-size:11pt}.image-alt.svelte-1d4qxx0 summary:where(.svelte-1d4qxx0){color:#666;user-select:none;-webkit-user-select:none;cursor:default;margin-bottom:5px;font-size:11pt}@media (prefers-color-scheme:dark){.image-alt.svelte-1d4qxx0{color:#999}.image-alt.svelte-1d4qxx0 summary:where(.svelte-1d4qxx0){color:#999}}.gif.svelte-1g38dct img:where(.svelte-1g38dct){user-select:none;-webkit-user-select:none}.gif.svelte-1g38dct img.static:where(.svelte-1g38dct){opacity:.75}.quote-embed.svelte-qy2yyv{background-color:#fbfcfd;border:1px solid #ddd;border-radius:8px;max-width:800px;margin-top:25px;margin-bottom:15px;margin-left:0}.quote-embed.svelte-qy2yyv .post{margin-top:16px;padding-bottom:5px;padding-left:16px;padding-right:16px}.placeholder.svelte-qy2yyv{color:#888;font-size:11pt;font-style:italic}@media (prefers-color-scheme:dark){.quote-embed.svelte-qy2yyv{background-color:#303030;border-color:#606060}}.embed.svelte-19fytgx a.link-card{display:block;position:relative;max-width:500px;margin-bottom:12px}.embed.svelte-19fytgx a.link-card:hover{text-decoration:none}.embed.svelte-19fytgx a.link-card>div{background-color:#fcfcfd;border:1px solid #d8d8d8;border-radius:8px;padding:11px 15px}.embed.svelte-19fytgx a.link-card:hover>div{background-color:#f6f7f8;border:1px solid #c8c8c8}.embed.svelte-19fytgx a.link-card>div:not(:has(p.description)){padding-bottom:14px}.embed.svelte-19fytgx a.link-card p.domain{color:#888;margin-top:1px;margin-bottom:5px;font-size:10pt}.embed.svelte-19fytgx a.link-card h2{color:#333;margin-top:8px;margin-bottom:0;font-size:12pt}.embed.svelte-19fytgx a.link-card p.description{color:#666;white-space:pre-line;margin-top:8px;margin-bottom:4px;font-size:11pt;line-height:135%}.embed.svelte-19fytgx a.link-card.record>div:has(.avatar){padding-left:65px}.embed.svelte-19fytgx a.link-card.record h2{margin-top:3px}.embed.svelte-19fytgx a.link-card.record .handle{color:#666;vertical-align:text-top;margin-left:1px;font-size:11pt;font-weight:400}.embed.svelte-19fytgx a.link-card.record .avatar{position:absolute;border:1px solid #ddd;border-radius:6px;width:36px;height:36px;top:15px;left:15px}.embed.svelte-19fytgx a.link-card.record .stats{color:#666;margin-top:9px;margin-bottom:1px;font-size:10pt}.embed.svelte-19fytgx a.link-card.record .stats i.fa-heart{color:#aaa;font-size:9pt}@media (prefers-color-scheme:dark){.embed.svelte-19fytgx a.link-card>div{background-color:#303030;border-color:#606060}.embed.svelte-19fytgx a.link-card:hover>div{background-color:#383838;border-color:#707070}.embed.svelte-19fytgx a.link-card p.domain{color:#666}.embed.svelte-19fytgx a.link-card h2{color:#ccc}.embed.svelte-19fytgx a.link-card p.description{color:#888}.embed.svelte-19fytgx a.link-card.record .handle{color:#666}.embed.svelte-19fytgx a.link-card.record .avatar{border-color:#888}}.post.blocked.svelte-qmmoky p{color:#666;font-size:11pt}.post.blocked.svelte-qmmoky a{color:#666;font-size:11pt}@media (prefers-color-scheme:dark){.post.blocked.svelte-qmmoky p{color:#aaa}.post.blocked.svelte-qmmoky a{color:#aaa}}.post p{margin-top:10px}.post .blocked-header i{margin-right:2px}.post h2 .separator,.post .blocked-header .separator,.blocked-header .separator{color:#888;vertical-align:text-top;font-size:11pt;font-weight:400}.post h2 .action,.post .blocked-header .action,.blocked-header .action{color:#888;vertical-align:text-top;font-size:10pt;font-weight:400}.post h2 .action:hover,.post .blocked-header .action:hover,.blocked-header .action:hover{color:#444}.post{position:relative;margin-top:30px;padding-left:21px}.post.collapsed.svelte-rwn0j1 .content:where(.svelte-rwn0j1){display:none}.post.flat.svelte-rwn0j1{margin-top:25px;padding-left:0}.post.muted.svelte-rwn0j1>h2{opacity:.3;font-weight:600}.post.muted.svelte-rwn0j1>.content>details>p,.post.muted.svelte-rwn0j1>.content>details summary{opacity:.3}details.svelte-rwn0j1{margin-top:12px;margin-bottom:10px}summary.svelte-rwn0j1{user-select:none;-webkit-user-select:none;cursor:default;font-size:10pt}.missing-replies-info.svelte-rwn0j1{color:#8b0000;margin-top:25px;font-size:11pt}.post.svelte-rwn0j1 img.loader{animation:rotation 3s infinite linear;width:24px;margin-top:5px}.hashtag.svelte-1l2woaq>.post{border-bottom:1px solid #ddd;padding-bottom:10px}#search.svelte-1drcssc{position:fixed;display:flex;justify-content:center;align-items: center;padding-bottom:5%;inset:0}form.svelte-1drcssc{border:2px solid #9cf;border-radius:10px;margin-left:50px;padding:15px 20px}input.svelte-1drcssc{border:0;width:600px;margin-left:8px;font-size:16pt}input.svelte-1drcssc:focus{outline:none}@media (prefers-color-scheme:dark){form.svelte-1drcssc{border-color:#7099c2}form.svelte-1drcssc input:where(.svelte-1drcssc){background-color:#0000}}.scan-result.svelte-8hgnpr{border-collapse:collapse;display:none;float:left;border:1px solid #333;margin-top:20px;margin-bottom:40px}td.svelte-8hgnpr,th.svelte-8hgnpr{border:1px solid #333;padding:5px 10px}th.svelte-8hgnpr{text-align:center;background-color:#b8dfff;padding:12px 10px}td.no.svelte-8hgnpr{text-align:right;font-weight:700}td.handle.svelte-8hgnpr{width:280px}td.count.svelte-8hgnpr{padding:5px 15px}.avatar.svelte-8hgnpr{vertical-align:middle;border-radius:14px;width:24px;height:24px;margin-right:2px;padding:2px}@media (prefers-color-scheme:dark){.scan-result.svelte-8hgnpr,td.svelte-8hgnpr,th.svelte-8hgnpr{border-color:#888}th.svelte-8hgnpr{background-color:#064579}}input[type=range].svelte-16cw7lp{vertical-align:middle;width:250px}input[type=submit].svelte-16cw7lp{margin:5px 0;padding:5px 10px;font-size:12pt}progress.svelte-16cw7lp{vertical-align:middle;display:none;width:300px;margin-left:10px}.scan-result.given-likes{margin-right:100px}.search-page.svelte-p7bb5y input[type=submit]{margin:5px 0;padding:5px 10px;font-size:12pt}.search-page.svelte-p7bb5y progress{vertical-align:middle;width:300px;margin-left:10px}.search-page.svelte-p7bb5y .search-query{border:1px solid #ccc;border-radius:6px;margin-left:8px;padding:5px 6px;font-size:12pt}.search-page.svelte-p7bb5y .results{margin-top:30px}.search-page.svelte-p7bb5y .results>.post{border-bottom:1px solid #ddd;margin-top:24px;margin-left:-15px;padding-bottom:10px;padding-left:15px}.search-page.svelte-p7bb5y .results-end{color:#333;font-size:12pt}.search-page.svelte-p7bb5y .post+.results-end{font-size:11pt}@media (prefers-color-scheme:dark){.search-page.svelte-p7bb5y .search-query{border:1px solid #666}.search-page.svelte-p7bb5y .results-end{color:#888}.search-page.svelte-p7bb5y .results>.post{border-bottom:1px solid #555}}.search-collections.svelte-1xf0p4l label:where(.svelte-1xf0p4l){vertical-align:middle;margin-right:5px}.lycan-import.svelte-1xf0p4l{border-top:1px solid #ccc;margin-top:30px;padding-top:5px}.lycan-import.svelte-1xf0p4l form:where(.svelte-1xf0p4l) p:where(.svelte-1xf0p4l){line-height:135%}.import-progress.svelte-1xf0p4l progress:where(.svelte-1xf0p4l){margin-left:0;margin-right:6px}.import-progress.svelte-1xf0p4l progress:where(.svelte-1xf0p4l)+output:where(.svelte-1xf0p4l){font-size:11pt}@media (prefers-color-scheme:dark){.lycan-import.svelte-1xf0p4l{border-top-color:#888}}.notifications.svelte-95g2ry .post{border-bottom:1px solid #ddd;margin-top:24px;padding-bottom:4px}.notifications.svelte-95g2ry .back{margin-top:15px;margin-bottom:-12px;margin-left:22px}.notifications.svelte-95g2ry .back{font-size:10pt}.notifications.svelte-95g2ry .back a{font-size:10pt}.notifications.svelte-95g2ry .back i{margin-right:2px;font-size:9pt}.user-choice.svelte-1cm32f6{position:relative}input.svelte-1cm32f6{width:260px;font-size:11pt}.autocomplete.svelte-1cm32f6{position:absolute;overflow-y:auto;z-index:10;background-color:#fff;border:1px solid #ccc;width:350px;max-height:250px;margin-top:4px;top:0;left:0}.selected-users.svelte-1cm32f6{overflow-y:auto;border:1px solid #aaa;width:275px;height:150px;margin-top:20px;padding:4px}.user-row.svelte-1cm32f6{position:relative;cursor:pointer;padding:2px 4px 2px 37px}.user-row.svelte-1cm32f6 .avatar:where(.svelte-1cm32f6){position:absolute;border-radius:12px;width:24px;top:8px;left:6px}.user-row.svelte-1cm32f6 span:where(.svelte-1cm32f6){display:block;overflow-x:hidden;text-overflow:ellipsis}.user-row.svelte-1cm32f6 .name:where(.svelte-1cm32f6){margin-top:1px;margin-bottom:1px;font-size:11pt}.user-row.svelte-1cm32f6 .handle:where(.svelte-1cm32f6){color:#666;margin-bottom:2px;font-size:10pt}.autocomplete.svelte-1cm32f6 .user-row:where(.svelte-1cm32f6){cursor:pointer}.autocomplete.svelte-1cm32f6 .user-row.highlighted:where(.svelte-1cm32f6){background-color:#b3ddff}.selected-users.svelte-1cm32f6 .user-row:where(.svelte-1cm32f6) span:where(.svelte-1cm32f6){padding-right:14px}.selected-users.svelte-1cm32f6 .user-row:where(.svelte-1cm32f6) .remove:where(.svelte-1cm32f6){position:absolute;color:#333;padding:0 4px;line-height:17px;top:11px;right:4px}.selected-users.svelte-1cm32f6 .user-row:where(.svelte-1cm32f6) .remove:where(.svelte-1cm32f6):hover{text-decoration:none;background-color:#ddd;border-radius:8px}@media (prefers-color-scheme:dark){.autocomplete.svelte-1cm32f6{background-color:#2c2e30;border-color:#4b4b4b}.selected-users.svelte-1cm32f6{border-color:#666}.user-row.svelte-1cm32f6 .handle:where(.svelte-1cm32f6){color:#888}.autocomplete.svelte-1cm32f6 .user-row.highlighted:where(.svelte-1cm32f6){background-color:#064579}.selected-users.svelte-1cm32f6 .user-row:where(.svelte-1cm32f6) .remove:where(.svelte-1cm32f6){color:#aaa}.selected-users.svelte-1cm32f6 .user-row:where(.svelte-1cm32f6) .remove:where(.svelte-1cm32f6):hover{color:#bbb;background-color:#555}}.scan-result.svelte-vhh361{border-collapse:collapse;border:1px solid #333}td.svelte-vhh361,th.svelte-vhh361{border:1px solid #333}td.svelte-vhh361{text-align:right;padding:5px 8px}th.svelte-vhh361{text-align:center;background-color:#b8dfff;padding:7px 10px}td.handle.svelte-vhh361{text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:450px}tr.total.svelte-vhh361 td:where(.svelte-vhh361){background-color:#b8dfff66;font-size:11pt;font-weight:700}tr.total.svelte-vhh361 td.handle:where(.svelte-vhh361){text-align:left;padding:10px 12px}.avatar.svelte-vhh361{vertical-align:middle;border-radius:14px;width:24px;height:24px;margin-right:2px;padding:2px}td.no.svelte-vhh361{font-weight:700}td.percent.svelte-vhh361{min-width:70px}@media (prefers-color-scheme:dark){.scan-result.svelte-vhh361,td.svelte-vhh361,th.svelte-vhh361{border-color:#888}th.svelte-vhh361{background-color:#064579}tr.total.svelte-vhh361 td:where(.svelte-vhh361){background-color:#06457966}}input[type=radio].svelte-1khgu5y{position:relative;margin-left:5px;top:-1px}input[type=radio].svelte-1khgu5y+label:where(.svelte-1khgu5y){user-select:none;-webkit-user-select:none;margin-right:4px}input[type=range].svelte-1khgu5y{vertical-align:middle;width:250px}input[type=submit].svelte-1khgu5y{margin:5px 0;padding:5px 10px;font-size:12pt}select.svelte-1khgu5y{margin-left:5px;font-size:12pt}progress.svelte-1khgu5y{vertical-align:middle;width:300px;margin-left:10px}.scan-info.svelte-1khgu5y{margin:20px 0;font-weight:600;line-height:125%}.quotes.svelte-13teqqd p.back{padding-left:10px}.quotes.svelte-13teqqd .post{padding-bottom:5px}.quotes.svelte-13teqqd .post-quote .quote-embed,.quotes.svelte-13teqqd .post-quote p.stats{display:none}#tangled.svelte-18p55jz{position:fixed;z-index:10;bottom:10px;right:10px}img.svelte-18p55jz{opacity:.4;width:20px}a.svelte-18p55jz:hover img:where(.svelte-18p55jz){opacity:.6}@media (prefers-color-scheme:dark){#tangled.svelte-18p55jz{filter:invert()}}input[type=range].svelte-ba7vy9{vertical-align:middle;width:250px}
+55
dist/skythread.js
··· 1 + (()=>{var m=!1;var w6=Array.isArray,r7=Array.prototype.indexOf,k5=Array.from,I9=Object.keys,q1=Object.defineProperty,D1=Object.getOwnPropertyDescriptor,Mz=Object.getOwnPropertyDescriptors,S9=Object.prototype,n7=Array.prototype,q8=Object.getPrototypeOf,D9=Object.isExtensible;function M8(z){return typeof z==="function"}var J6=()=>{};function R9(z){return typeof z?.then==="function"}function t7(z){return z()}function o8(z){for(var J=0;J<z.length;J++)z[J]()}function Cz(){var z,J,Q=new Promise((K,X)=>{z=K,J=X});return{promise:Q,resolve:z,reject:J}}var h0=2,a8=4,C8=8,r8=16777216,m1=16,k1=32,Q6=64,x8=128,b1=512,l0=1024,i0=2048,R1=4096,j1=8192,T1=16384,U6=32768,E1=65536,O8=131072,n8=262144,S6=524288,j9=1048576,K6=32768,xz=2097152,m6=4194304,Z6=8388608,Q1=Symbol("$state"),t8=Symbol("legacy props"),e7=Symbol(""),Oz=Symbol("proxy path"),D6=new class extends Error{name="StaleReactionError";message="The reaction that called `getAbortSignal()` was re-run or destroyed"};var e8=3,A1=8;function Lz(z){if(m){let J=Error(`lifecycle_outside_component 2 + \`${z}(...)\` can only be used during component initialisation 3 + https://svelte.dev/e/lifecycle_outside_component`);throw J.name="Svelte error",J}else throw Error("https://svelte.dev/e/lifecycle_outside_component")}function zQ(){if(m){let z=Error(`missing_context 4 + Context was not set in a parent component 5 + https://svelte.dev/e/missing_context`);throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/missing_context")}function JQ(){if(m){let z=Error("async_derived_orphan\nCannot create a `$derived(...)` with an `await` expression outside of an effect tree\nhttps://svelte.dev/e/async_derived_orphan");throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/async_derived_orphan")}function A9(){if(m){let z=Error("bind_invalid_checkbox_value\nUsing `bind:value` together with a checkbox input is not allowed. Use `bind:checked` instead\nhttps://svelte.dev/e/bind_invalid_checkbox_value");throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/bind_invalid_checkbox_value")}function QQ(){if(m){let z=Error(`derived_references_self 6 + A derived value cannot reference itself recursively 7 + https://svelte.dev/e/derived_references_self`);throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/derived_references_self")}function KQ(z){if(m){let J=Error(`effect_in_teardown 8 + \`${z}\` cannot be used inside an effect cleanup function 9 + https://svelte.dev/e/effect_in_teardown`);throw J.name="Svelte error",J}else throw Error("https://svelte.dev/e/effect_in_teardown")}function ZQ(){if(m){let z=Error("effect_in_unowned_derived\nEffect cannot be created inside a `$derived` value that was not itself created inside an effect\nhttps://svelte.dev/e/effect_in_unowned_derived");throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/effect_in_unowned_derived")}function XQ(z){if(m){let J=Error(`effect_orphan 10 + \`${z}\` can only be used inside an effect (e.g. during component initialisation) 11 + https://svelte.dev/e/effect_orphan`);throw J.name="Svelte error",J}else throw Error("https://svelte.dev/e/effect_orphan")}function WQ(){if(m){let z=Error(`effect_update_depth_exceeded 12 + Maximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state 13 + https://svelte.dev/e/effect_update_depth_exceeded`);throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/effect_update_depth_exceeded")}function YQ(){if(m){let z=Error(`hydration_failed 14 + Failed to hydrate the application 15 + https://svelte.dev/e/hydration_failed`);throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/hydration_failed")}function GQ(){if(m){let z=Error("invalid_snippet\nCould not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}`\nhttps://svelte.dev/e/invalid_snippet");throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/invalid_snippet")}function BQ(z){if(m){let J=Error(`props_invalid_value 16 + Cannot do \`bind:${z}={undefined}\` when \`${z}\` has a fallback value 17 + https://svelte.dev/e/props_invalid_value`);throw J.name="Svelte error",J}else throw Error("https://svelte.dev/e/props_invalid_value")}function HQ(z){if(m){let J=Error(`props_rest_readonly 18 + Rest element properties of \`$props()\` such as \`${z}\` are readonly 19 + https://svelte.dev/e/props_rest_readonly`);throw J.name="Svelte error",J}else throw Error("https://svelte.dev/e/props_rest_readonly")}function wQ(z){if(m){let J=Error(`rune_outside_svelte 20 + The \`${z}\` rune is only available inside \`.svelte\` and \`.svelte.js/ts\` files 21 + https://svelte.dev/e/rune_outside_svelte`);throw J.name="Svelte error",J}else throw Error("https://svelte.dev/e/rune_outside_svelte")}function UQ(){if(m){let z=Error("set_context_after_init\n`setContext` must be called when a component first initializes, not in a subsequent effect or after an `await` expression\nhttps://svelte.dev/e/set_context_after_init");throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/set_context_after_init")}function VQ(){if(m){let z=Error("state_descriptors_fixed\nProperty descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`.\nhttps://svelte.dev/e/state_descriptors_fixed");throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/state_descriptors_fixed")}function FQ(){if(m){let z=Error("state_prototype_fixed\nCannot set prototype of `$state` object\nhttps://svelte.dev/e/state_prototype_fixed");throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/state_prototype_fixed")}function qQ(){if(m){let z=Error("state_unsafe_mutation\nUpdating state inside `$derived(...)`, `$inspect(...)` or a template expression is forbidden. If the value should not be reactive, declare it without `$state`\nhttps://svelte.dev/e/state_unsafe_mutation");throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/state_unsafe_mutation")}function MQ(){if(m){let z=Error("svelte_boundary_reset_onerror\nA `<svelte:boundary>` `reset` function cannot be called while an error is still being handled\nhttps://svelte.dev/e/svelte_boundary_reset_onerror");throw z.name="Svelte error",z}else throw Error("https://svelte.dev/e/svelte_boundary_reset_onerror")}var N9=1,k9=2,b9=4,CQ=8,xQ=16,OQ=1,LQ=2,PQ=4,IQ=8,SQ=16;var DQ=1,RQ=2;var b5="[",V6="[!",L8="]",R6={};var E0=Symbol(),$1=Symbol("filename"),jQ=Symbol("hmr"),AQ="http://www.w3.org/1999/xhtml";var T9="@attach";var $6="font-weight: bold",u6="font-weight: normal";function NQ(z,J){if(m)console.warn(`%c[svelte] await_waterfall 22 + %cAn async derived, \`${z}\` (${J}) was not read immediately after it resolved. This often indicates an unnecessary waterfall, which can slow down your app 23 + https://svelte.dev/e/await_waterfall`,$6,u6);else console.warn("https://svelte.dev/e/await_waterfall")}function kQ(z,J,Q){if(m)console.warn(`%c[svelte] hydration_attribute_changed 24 + %cThe \`${z}\` attribute on \`${J}\` changed its value between server and client renders. The client value, \`${Q}\`, will be ignored in favour of the server value 25 + https://svelte.dev/e/hydration_attribute_changed`,$6,u6);else console.warn("https://svelte.dev/e/hydration_attribute_changed")}function bQ(z){if(m)console.warn(`%c[svelte] hydration_html_changed 26 + %c${z?`The value of an \`{@html ...}\` block ${z} changed between server and client renders. The client value will be ignored in favour of the server value`:"The value of an `{@html ...}` block changed between server and client renders. The client value will be ignored in favour of the server value"} 27 + https://svelte.dev/e/hydration_html_changed`,$6,u6);else console.warn("https://svelte.dev/e/hydration_html_changed")}function z8(z){if(m)console.warn(`%c[svelte] hydration_mismatch 28 + %c${z?`Hydration failed because the initial UI does not match what was rendered on the server. The error occurred near ${z}`:"Hydration failed because the initial UI does not match what was rendered on the server"} 29 + https://svelte.dev/e/hydration_mismatch`,$6,u6);else console.warn("https://svelte.dev/e/hydration_mismatch")}function TQ(){if(m)console.warn(`%c[svelte] lifecycle_double_unmount 30 + %cTried to unmount a component that was not mounted 31 + https://svelte.dev/e/lifecycle_double_unmount`,$6,u6);else console.warn("https://svelte.dev/e/lifecycle_double_unmount")}function EQ(){if(m)console.warn("%c[svelte] select_multiple_invalid_value\n%cThe `value` property of a `<select multiple>` element should be an array, but it received a non-array value. The selection will be kept as is.\nhttps://svelte.dev/e/select_multiple_invalid_value",$6,u6);else console.warn("https://svelte.dev/e/select_multiple_invalid_value")}function Pz(z){if(m)console.warn(`%c[svelte] state_proxy_equality_mismatch 32 + %cReactive \`$state(...)\` proxies and the values they proxy have different identities. Because of this, comparisons with \`${z}\` will produce unexpected results 33 + https://svelte.dev/e/state_proxy_equality_mismatch`,$6,u6);else console.warn("https://svelte.dev/e/state_proxy_equality_mismatch")}function yQ(){if(m)console.warn(`%c[svelte] state_proxy_unmount 34 + %cTried to unmount a state proxy, rather than a component 35 + https://svelte.dev/e/state_proxy_unmount`,$6,u6);else console.warn("https://svelte.dev/e/state_proxy_unmount")}function vQ(){if(m)console.warn("%c[svelte] svelte_boundary_reset_noop\n%cA `<svelte:boundary>` `reset` function only resets the boundary the first time it is called\nhttps://svelte.dev/e/svelte_boundary_reset_noop",$6,u6);else console.warn("https://svelte.dev/e/svelte_boundary_reset_noop")}var r=!1;function f0(z){r=z}var B0;function j0(z){if(z===null)throw z8(),R6;return B0=z}function _0(){return j0(e0(B0))}function C(z){if(!r)return;if(e0(B0)!==null)throw z8(),R6;B0=z}function c0(z=1){if(r){var J=z,Q=B0;while(J--)Q=e0(Q);B0=Q}}function F6(z=!0){var J=0,Q=B0;while(!0){if(Q.nodeType===A1){var K=Q.data;if(K===L8){if(J===0)return Q;J-=1}else if(K===b5||K===V6)J+=1}var X=e0(Q);if(z)Q.remove();Q=X}}function Iz(z){if(!z||z.nodeType!==A1)throw z8(),R6;return z.data}function Sz(z){return z===this.v}function E9(z,J){return z!=z?J==J:z!==J||z!==null&&typeof z==="object"||typeof z==="function"}function Dz(z){return!E9(z,this.v)}var N1=!1,l6=!1,q6=!1;function gQ(){l6=!0}var T5=null;function u1(z,J){return z.label=J,Rz(z.v,J),z}function Rz(z,J){return z?.[Oz]?.(J),z}function A6(z){let J=Error(),Q=jZ();if(Q.length===0)return null;return Q.unshift(` 36 + `),q1(J,"stack",{value:Q.join(` 37 + `)}),q1(J,"name",{value:z}),J}function jZ(){let z=Error.stackTraceLimit;Error.stackTraceLimit=1/0;let J=Error().stack;if(Error.stackTraceLimit=z,!J)return[];let Q=J.split(` 38 + `),K=[];for(let X=0;X<Q.length;X++){let Z=Q[X],W=Z.replaceAll("\\","/");if(Z.trim()==="Error")continue;if(Z.includes("validate_each_keys"))return[];if(W.includes("svelte/src/internal")||W.includes("node_modules/.vite"))continue;K.push(Z)}return K}var L0=null;function p6(z){L0=z}var M6=null;function z5(z){M6=z}var c1=null;function jz(z){c1=z}function v9(){let z={};return[()=>{if(!$Q(z))zQ();return fQ(z)},(J)=>mQ(z,J)]}function fQ(z){return g9("getContext").get(z)}function mQ(z,J){let Q=g9("setContext");if(N1){var K=J0.f,X=!W0&&(K&k1)!==0&&!L0.i;if(!X)UQ()}return Q.set(z,J),J}function $Q(z){return g9("hasContext").has(z)}function i(z,J=!1,Q){if(L0={p:L0,i:!1,c:null,e:null,s:z,x:null,l:l6&&!J?{s:null,u:null,$:[]}:null},m)L0.function=Q,c1=Q}function _(z){var J=L0,Q=J.e;if(Q!==null){J.e=null;for(var K of Q)h9(K)}if(z!==void 0)J.x=z;if(J.i=!0,L0=J.p,m)c1=L0?.function??null;return z??{}}function s1(){return!l6||L0!==null&&L0.l===null}function g9(z){if(L0===null)Lz(z);return L0.c??=new Map(AZ(L0)||void 0)}function AZ(z){let J=z.p;while(J!==null){let Q=J.c;if(Q!==null)return Q;J=J.p}return null}var P8=[];function uQ(){var z=P8;P8=[],o8(z)}function p0(z){if(P8.length===0&&!J8){var J=P8;queueMicrotask(()=>{if(J===P8)uQ()})}P8.push(z)}function lQ(){while(P8.length>0)uQ()}var f9=new WeakMap;function Az(z){var J=J0;if(J===null)return W0.f|=Z6,z;if(m&&z instanceof Error&&!f9.has(z))f9.set(z,NZ(z,J));if((J.f&U6)===0){if((J.f&x8)===0){if(m&&!J.parent&&z instanceof Error)pQ(z);throw z}J.b.error(z)}else N6(z,J)}function N6(z,J){while(J!==null){if((J.f&x8)!==0)try{J.b.error(z);return}catch(Q){z=Q}J=J.parent}if(m&&z instanceof Error)pQ(z);throw z}function NZ(z,J){let Q=D1(z,"message");if(Q&&!Q.configurable)return;var K=E5?" ":"\t",X=` 39 + ${K}in ${J.fn?.name||"<unknown>"}`,Z=J.ctx;while(Z!==null)X+=` 40 + ${K}in ${Z.function?.[$1].split("/").pop()}`,Z=Z.p;return{message:z.message+` 41 + ${X} 42 + `,stack:z.stack?.split(` 43 + `).filter((W)=>!W.includes("svelte/src/internal")).join(` 44 + `)}}function pQ(z){let J=f9.get(z);if(J)q1(z,"message",{value:J.message}),q1(z,"stack",{value:J.stack})}var J5=new Set,w0=null,I8=null,B1=null,X6=[],Nz=null,m9=!1,J8=!1;class o0{committed=!1;current=new Map;previous=new Map;#z=new Set;#J=new Set;#Q=0;#K=0;#G=null;#X=[];#Z=[];skipped_effects=new Set;is_fork=!1;is_deferred(){return this.is_fork||this.#K>0}process(z){X6=[],I8=null,this.apply();var J={parent:null,effect:null,effects:[],render_effects:[],block_effects:[]};for(let Q of z)this.#W(Q,J);if(!this.is_fork)this.#H();if(this.is_deferred())this.#Y(J.effects),this.#Y(J.render_effects),this.#Y(J.block_effects);else I8=this,w0=null,dQ(J.render_effects),dQ(J.effects),I8=null,this.#G?.resolve();B1=null}#W(z,J){z.f^=l0;var Q=z.first;while(Q!==null){var K=Q.f,X=(K&(k1|Q6))!==0,Z=X&&(K&l0)!==0,W=Z||(K&j1)!==0||this.skipped_effects.has(Q);if((Q.f&x8)!==0&&Q.b?.is_pending())J={parent:J,effect:Q,effects:[],render_effects:[],block_effects:[]};if(!W&&Q.fn!==null){if(X)Q.f^=l0;else if((K&a8)!==0)J.effects.push(Q);else if(N1&&(K&(C8|r8))!==0)J.render_effects.push(Q);else if(Z8(Q)){if((Q.f&m1)!==0)J.block_effects.push(Q);Q8(Q)}var G=Q.first;if(G!==null){Q=G;continue}}var w=Q.parent;Q=Q.next;while(Q===null&&w!==null){if(w===J.effect)this.#Y(J.effects),this.#Y(J.render_effects),this.#Y(J.block_effects),J=J.parent;Q=w.next,w=w.parent}}}#Y(z){for(let J of z)((J.f&i0)!==0?this.#X:this.#Z).push(J),this.#B(J.deps),s0(J,l0)}#B(z){if(z===null)return;for(let J of z){if((J.f&h0)===0||(J.f&K6)===0)continue;J.f^=K6,this.#B(J.deps)}}capture(z,J){if(!this.previous.has(z))this.previous.set(z,J);if((z.f&Z6)===0)this.current.set(z,z.v),B1?.set(z,z.v)}activate(){w0=this,this.apply()}deactivate(){if(w0!==this)return;w0=null,B1=null}flush(){if(this.activate(),X6.length>0){if($9(),w0!==null&&w0!==this)return}else if(this.#Q===0)this.process([]);this.deactivate()}discard(){for(let z of this.#J)z(this);this.#J.clear()}#H(){if(this.#K===0){for(let z of this.#z)z();this.#z.clear()}if(this.#Q===0)this.#w()}#w(){if(J5.size>1){this.previous.clear();var z=B1,J=!0,Q={parent:null,effect:null,effects:[],render_effects:[],block_effects:[]};for(let X of J5){if(X===this){J=!1;continue}let Z=[];for(let[G,w]of this.current){if(X.current.has(G))if(J&&w!==X.current.get(G))X.current.set(G,w);else continue;Z.push(G)}if(Z.length===0)continue;let W=[...X.current.keys()].filter((G)=>!this.current.has(G));if(W.length>0){var K=X6;X6=[];let G=new Set,w=new Map;for(let H of Z)iQ(H,W,G,w);if(X6.length>0){w0=X,X.apply();for(let H of X6)X.#W(H,Q);X.deactivate()}X6=K}}w0=null,B1=z}this.committed=!0,J5.delete(this)}increment(z){if(this.#Q+=1,z)this.#K+=1}decrement(z){if(this.#Q-=1,z)this.#K-=1;this.revive()}revive(){for(let z of this.#X)s0(z,i0),k6(z);for(let z of this.#Z)s0(z,R1),k6(z);this.#X=[],this.#Z=[],this.flush()}oncommit(z){this.#z.add(z)}ondiscard(z){this.#J.add(z)}settled(){return(this.#G??=Cz()).promise}static ensure(){if(w0===null){let z=w0=new o0;if(J5.add(w0),!J8)o0.enqueue(()=>{if(w0!==z)return;z.flush()})}return w0}static enqueue(z){p0(z)}apply(){if(!N1||!this.is_fork&&J5.size===1)return;B1=new Map(this.current);for(let z of J5){if(z===this)continue;for(let[J,Q]of z.previous)if(!B1.has(J))B1.set(J,Q)}}}function K8(z){var J=J8;J8=!0;try{var Q;if(z){if(w0!==null)$9();Q=z()}while(!0){if(lQ(),X6.length===0){if(w0?.flush(),X6.length===0)return Nz=null,Q}$9()}}finally{J8=J}}function $9(){var z=T6;m9=!0;var J=m?new Set:null;try{var Q=0;Q5(!0);while(X6.length>0){var K=o0.ensure();if(Q++>1000){if(m){var X=new Map;for(let W of K.current.keys())for(let[G,w]of W.updated??[]){var Z=X.get(G);if(!Z)Z={error:w.error,count:0},X.set(G,Z);Z.count+=w.count}for(let W of X.values())if(W.error)console.error(W.error)}bZ()}if(K.process(X6),b6.clear(),m)for(let W of K.current.keys())J.add(W)}}finally{if(m9=!1,Q5(z),Nz=null,m)for(let W of J)W.updated=null}}function bZ(){try{WQ()}catch(z){if(m)q1(z,"stack",{value:""});N6(z,Nz)}}var W6=null;function dQ(z){var J=z.length;if(J===0)return;var Q=0;while(Q<J){var K=z[Q++];if((K.f&(T1|j1))===0&&Z8(K)){if(W6=new Set,Q8(K),K.deps===null&&K.first===null&&K.nodes_start===null)if(K.teardown===null&&K.ac===null)u9(K);else K.fn=null;if(W6?.size>0){b6.clear();for(let X of W6){if((X.f&(T1|j1))!==0)continue;let Z=[X],W=X.parent;while(W!==null){if(W6.has(W))W6.delete(W),Z.push(W);W=W.parent}for(let G=Z.length-1;G>=0;G--){let w=Z[G];if((w.f&(T1|j1))!==0)continue;Q8(w)}}W6.clear()}}}W6=null}function iQ(z,J,Q,K){if(Q.has(z))return;if(Q.add(z),z.reactions!==null)for(let X of z.reactions){let Z=X.f;if((Z&h0)!==0)iQ(X,J,Q,K);else if((Z&(m6|m1))!==0&&(Z&i0)===0&&_Q(X,J,K))s0(X,i0),k6(X)}}function _Q(z,J,Q){let K=Q.get(z);if(K!==void 0)return K;if(z.deps!==null)for(let X of z.deps){if(J.includes(X))return!0;if((X.f&h0)!==0&&_Q(X,J,Q))return Q.set(X,!0),!0}return Q.set(z,!1),!1}function k6(z){var J=Nz=z;while(J.parent!==null){J=J.parent;var Q=J.f;if(m9&&J===J0&&(Q&m1)!==0&&(Q&n8)===0)return;if((Q&(Q6|k1))!==0){if((Q&l0)===0)return;J.f^=l0}}X6.push(J)}function sQ(z){let J=0,Q=H1(0),K;if(m)u1(Q,"createSubscriber version");return()=>{if(j6())Y(Q),w1(()=>{if(J===0)K=b0(()=>z(()=>S8(Q)));return J+=1,()=>{p0(()=>{if(J-=1,J===0)K?.(),K=void 0,S8(Q)})}})}}var EZ=E1|S6|x8;function l9(z,J,Q){new p9(z,J,Q)}class p9{parent;#z=!1;#J;#Q=r?B0:null;#K;#G;#X;#Z=null;#W=null;#Y=null;#B=null;#H=null;#w=0;#V=0;#F=!1;#U=null;#O=sQ(()=>{if(this.#U=H1(this.#w),m)u1(this.#U,"$effect.pending()");return()=>{this.#U=null}});constructor(z,J,Q){if(this.#J=z,this.#K=J,this.#G=Q,this.parent=J0.b,this.#z=!!this.#K.pending,this.#X=x1(()=>{if(J0.b=this,r){let X=this.#Q;if(_0(),X.nodeType===A1&&X.data===V6)this.#P();else this.#L()}else{var K=this.#C();try{this.#Z=z1(()=>Q(K))}catch(X){this.error(X)}if(this.#V>0)this.#M();else this.#z=!1}return()=>{this.#H?.remove()}},EZ),r)this.#J=B0}#L(){try{this.#Z=z1(()=>this.#G(this.#J))}catch(z){this.error(z)}this.#z=!1}#P(){let z=this.#K.pending;if(!z)return;this.#W=z1(()=>z(this.#J)),o0.enqueue(()=>{var J=this.#C();if(this.#Z=this.#q(()=>{return o0.ensure(),z1(()=>this.#G(J))}),this.#V>0)this.#M();else d6(this.#W,()=>{this.#W=null}),this.#z=!1})}#C(){var z=this.#J;if(this.#z)this.#H=d0(),this.#J.before(this.#H),z=this.#H;return z}is_pending(){return this.#z||!!this.parent&&this.parent.is_pending()}has_pending_snippet(){return!!this.#K.pending}#q(z){var J=J0,Q=W0,K=L0;U1(this.#X),a0(this.#X),p6(this.#X.ctx);try{return z()}catch(X){return Az(X),null}finally{U1(J),a0(Q),p6(K)}}#M(){let z=this.#K.pending;if(this.#Z!==null)this.#B=document.createDocumentFragment(),this.#B.append(this.#H),Tz(this.#Z,this.#B);if(this.#W===null)this.#W=z1(()=>z(this.#J))}#x(z){if(!this.has_pending_snippet()){if(this.parent)this.parent.#x(z);return}if(this.#V+=z,this.#V===0){if(this.#z=!1,this.#W)d6(this.#W,()=>{this.#W=null});if(this.#B)this.#J.before(this.#B),this.#B=null}}update_pending_count(z){if(this.#x(z),this.#w+=z,this.#U)l1(this.#U,this.#w)}get_effect_pending(){return this.#O(),Y(this.#U)}error(z){var J=this.#K.onerror;let Q=this.#K.failed;if(this.#F||!J&&!Q)throw z;if(this.#Z)y0(this.#Z),this.#Z=null;if(this.#W)y0(this.#W),this.#W=null;if(this.#Y)y0(this.#Y),this.#Y=null;if(r)j0(this.#Q),c0(),j0(F6());var K=!1,X=!1;let Z=()=>{if(K){vQ();return}if(K=!0,X)MQ();if(o0.ensure(),this.#w=0,this.#Y!==null)d6(this.#Y,()=>{this.#Y=null});if(this.#z=this.has_pending_snippet(),this.#Z=this.#q(()=>{return this.#F=!1,z1(()=>this.#G(this.#J))}),this.#V>0)this.#M();else this.#z=!1};var W=W0;try{a0(null),X=!0,J?.(z,Z),X=!1}catch(G){N6(G,this.#X&&this.#X.parent)}finally{a0(W)}if(Q)p0(()=>{this.#Y=this.#q(()=>{o0.ensure(),this.#F=!0;try{return z1(()=>{Q(this.#J,()=>z,()=>Z)})}catch(G){return N6(G,this.#X.parent),null}finally{this.#F=!1}})})}}var K5=null;function O1(z,J){return J}function yZ(z,J,Q){var K=[],X=J.length;for(var Z=0;Z<X;Z++)yz(J[Z].e,K,!0);i9(K,()=>{var W=K.length===0&&Q!==null;if(W){var G=Q,w=G.parentNode;v5(w),w.append(G),z.items.clear(),C6(z,J[0].prev,J[X-1].next)}for(var H=0;H<X;H++){var q=J[H];if(!W)z.items.delete(q.k),C6(z,q.prev,q.next);y0(q.e,!W)}if(z.first===J[0])z.first=J[0].prev})}function A0(z,J,Q,K,X,Z=null){var W=z,G=new Map,w=null,H=(J&b9)!==0,q=(J&N9)!==0,F=(J&k9)!==0;if(H){var B=z;W=r?j0(r0(B)):B.appendChild(d0())}if(r)_0();var U=null,M=D8(()=>{var N=Q();return w6(N)?N:N==null?[]:k5(N)}),P,O=!0;function j(){if(vZ(E,P,W,J,K),U!==null)if(P.length===0){if(U.fragment)W.before(U.fragment),U.fragment=null;else g5(U.effect);T.first=U.effect}else d6(U.effect,()=>{U=null})}var T=x1(()=>{P=Y(M);var N=P.length;let y=!1;if(r){var b=Iz(W)===V6;if(b!==(N===0))W=F6(),j0(W),f0(!1),y=!0}var A=new Set,k=w0,$=null,v=Ez();for(var l=0;l<N;l+=1){if(r&&B0.nodeType===A1&&B0.data===L8)W=B0,y=!0,f0(!1);var n=P[l],o=K(n,l),a=O?null:G.get(o);if(a){if(q)l1(a.v,n);if(F)l1(a.i,l);else a.i=l;if(v)k.skipped_effects.delete(a.e)}else{if(a=gZ(O?W:null,$,n,o,l,X,J,Q),O){if(a.o=!0,$===null)w=a;else $.next=a;$=a}G.set(o,a)}A.add(o)}if(N===0&&Z&&!U)if(O)U={fragment:null,effect:z1(()=>Z(W))};else{var X0=document.createDocumentFragment(),Q0=d0();X0.append(Q0),U={fragment:X0,effect:z1(()=>Z(Q0))}}if(r&&N>0)j0(F6());if(!O)if(v){for(let[U0,D0]of G)if(!A.has(U0))k.skipped_effects.add(D0.e);k.oncommit(j),k.ondiscard(()=>{})}else j();if(y)f0(!0);Y(M)}),E={effect:T,flags:J,items:G,first:w};if(O=!1,r)W=B0}function vZ(z,J,Q,K,X){var Z=(K&CQ)!==0,W=J.length,G=z.items,w=z.first,H,q=null,F,B=[],U=[],M,P,O,j;if(Z){for(j=0;j<W;j+=1)if(M=J[j],P=X(M,j),O=G.get(P),O.o)O.a?.measure(),(F??=new Set).add(O)}for(j=0;j<W;j+=1){if(M=J[j],P=X(M,j),O=G.get(P),z.first??=O,!O.o){O.o=!0;var T=q?q.next:w;C6(z,q,O),C6(z,O,T),d9(O,T,Q),q=O,B=[],U=[],w=q.next;continue}if((O.e.f&j1)!==0){if(g5(O.e),Z)O.a?.unfix(),(F??=new Set).delete(O)}if(O!==w){if(H!==void 0&&H.has(O)){if(B.length<U.length){var E=U[0],N;q=E.prev;var y=B[0],b=B[B.length-1];for(N=0;N<B.length;N+=1)d9(B[N],E,Q);for(N=0;N<U.length;N+=1)H.delete(U[N]);C6(z,y.prev,b.next),C6(z,q,y),C6(z,b,E),w=E,q=b,j-=1,B=[],U=[]}else H.delete(O),d9(O,w,Q),C6(z,O.prev,O.next),C6(z,O,q===null?z.first:q.next),C6(z,q,O),q=O;continue}B=[],U=[];while(w!==null&&w.k!==P){if((w.e.f&j1)===0)(H??=new Set).add(w);U.push(w),w=w.next}if(w===null)continue;O=w}B.push(O),q=O,w=O.next}let A=G.size>W;if(w!==null||H!==void 0){var k=H===void 0?[]:k5(H);while(w!==null){if((w.e.f&j1)===0)k.push(w);w=w.next}var $=k.length;if(A=G.size-$>W,$>0){var v=(K&b9)!==0&&W===0?Q:null;if(Z){for(j=0;j<$;j+=1)k[j].a?.measure();for(j=0;j<$;j+=1)k[j].a?.fix()}yZ(z,k,v)}}if(A){for(let l of G.values())if(!l.o)C6(z,q,l),q=l}if(z.effect.last=q&&q.e,Z)p0(()=>{if(F===void 0)return;for(O of F)O.a?.apply()})}function gZ(z,J,Q,K,X,Z,W,G){var w=K5,H=(W&N9)!==0,q=(W&xQ)===0,F=H?q?i6(Q,!1,!1):H1(Q):Q,B=(W&k9)===0?X:H1(X);if(m&&H)F.trace=()=>{var P=typeof B==="number"?X:B.v;G()[P]};var U={i:B,v:F,k:K,a:null,e:null,o:!1,prev:J,next:null};K5=U;try{if(z===null){var M=document.createDocumentFragment();M.append(z=d0())}if(U.e=z1(()=>Z(z,F,B,G)),J!==null)J.next=U;return U}finally{K5=w}}function d9(z,J,Q){var K=z.next?z.next.e.nodes_start:Q,X=J?J.e.nodes_start:Q,Z=z.e.nodes_start;while(Z!==null&&Z!==K){var W=e0(Z);X.before(Z),Z=W}}function C6(z,J,Q){if(J===null)z.first=Q,z.effect.first=Q&&Q.e;else{if(J.e.next)J.e.next.prev=null;J.next=Q,J.e.next=Q&&Q.e}if(Q!==null){if(Q.e.prev)Q.e.prev.next=null;Q.prev=J,Q.e.prev=J&&J.e}}function Z5(z,J,Q,K){let X=s1()?X8:D8;if(Q.length===0&&z.length===0){K(J.map(X));return}var Z=w0,W=J0,G=_9();function w(){Promise.all(Q.map((H)=>s9(H))).then((H)=>{G();try{K([...J.map(X),...H])}catch(q){if((W.f&T1)===0)N6(q,W)}Z?.deactivate(),R8()}).catch((H)=>{N6(H,W)})}if(z.length>0)Promise.all(z).then(()=>{G();try{return w()}finally{Z?.deactivate(),R8()}});else w()}function _9(){var z=J0,J=W0,Q=L0,K=w0;if(m)var X=M6;return function(W=!0){if(U1(z),a0(J),p6(Q),W)K?.activate();if(m)c9(null),z5(X)}}function R8(){if(U1(null),a0(null),p6(null),m)c9(null),z5(null)}var X5=null;function c9(z){X5=z}var h5=new Set;function X8(z){var J=h0|i0,Q=W0!==null&&(W0.f&h0)!==0?W0:null;if(J0!==null)J0.f|=S6;let K={ctx:L0,deps:null,effects:null,equals:Sz,f:J,fn:z,reactions:null,rv:0,v:E0,wv:0,parent:Q??J0,ac:null};if(m&&q6)K.created=A6("created at");return K}function s9(z,J){let Q=J0;if(Q===null)JQ();var K=Q.b,X=void 0,Z=H1(E0),W=!W0,G=new Map;if(oQ(()=>{if(m)X5=J0;var w=Cz();X=w.promise;try{Promise.resolve(z()).then(w.resolve,w.reject).then(()=>{if(H===w0&&H.committed)H.deactivate();R8()})}catch(B){w.reject(B),R8()}if(m)X5=null;var H=w0;if(W){var q=!K.is_pending();K.update_pending_count(1),H.increment(q),G.get(H)?.reject(D6),G.delete(H),G.set(H,w)}let F=(B,U=void 0)=>{if(X5=null,H.activate(),U){if(U!==D6)Z.f|=Z6,l1(Z,U)}else{if((Z.f&Z6)!==0)Z.f^=Z6;l1(Z,B);for(let[M,P]of G){if(G.delete(M),M===H)break;P.reject(D6)}if(m&&J!==void 0)h5.add(Z),setTimeout(()=>{if(h5.has(Z))NQ(Z.label,J),h5.delete(Z)})}if(W)K.update_pending_count(-1),H.decrement(q)};w.promise.then(F,(B)=>F(null,B||"unknown"))}),K1(()=>{for(let w of G.values())w.reject(D6)}),m)Z.f|=m6;return new Promise((w)=>{function H(q){function F(){if(q===X)w(Z);else H(X)}q.then(F,F)}H(X)})}function C0(z){let J=X8(z);if(!N1)hz(J);return J}function D8(z){let J=X8(z);return J.equals=Dz,J}function vz(z){var J=z.effects;if(J!==null){z.effects=null;for(var Q=0;Q<J.length;Q+=1)y0(J[Q])}}var o9=[];function $Z(z){var J=z.parent;while(J!==null){if((J.f&h0)===0)return(J.f&T1)===0?J:null;J=J.parent}return null}function f5(z){var J,Q=J0;if(U1($Z(z)),m){let K=j8;kz(new Set);try{if(o9.includes(z))QQ();o9.push(z),z.f&=~K6,vz(z),J=gz(z)}finally{U1(Q),kz(K),o9.pop()}}else try{z.f&=~K6,vz(z),J=gz(z)}finally{U1(Q)}return J}function a9(z){var J=f5(z);if(!z.equals(J)){if(!w0?.is_fork)z.v=J;z.wv=m5()}if(x6)return;if(B1!==null){if(j6()||w0?.is_fork)B1.set(z,J)}else{var Q=(z.f&b1)===0?R1:l0;s0(z,Q)}}var j8=new Set,b6=new Map;function kz(z){j8=z}var r9=!1;function aQ(){r9=!0}function H1(z,J){var Q={f:0,v:z,reactions:null,equals:Sz,rv:0,wv:0};if(m&&q6)Q.created=J??A6("created at"),Q.updated=null,Q.set_during_effect=!1,Q.trace=null;return Q}function f(z,J){let Q=H1(z,J);return hz(Q),Q}function i6(z,J=!1,Q=!0){let K=H1(z);if(!J)K.equals=Dz;if(l6&&Q&&L0!==null&&L0.l!==null)(L0.l.s??=[]).push(K);return K}function R(z,J,Q=!1){if(W0!==null&&(!a1||(W0.f&O8)!==0)&&s1()&&(W0.f&(h0|m1|m6|O8))!==0&&!E6?.includes(z))qQ();let K=Q?x0(J):J;if(m)Rz(K,z.label);return l1(z,K)}function l1(z,J){if(!z.equals(J)){var Q=z.v;if(x6)b6.set(z,J);else b6.set(z,Q);z.v=J;var K=o0.ensure();if(K.capture(z,Q),m){if(q6||J0!==null){z.updated??=new Map;let X=(z.updated.get("")?.count??0)+1;if(z.updated.set("",{error:null,count:X}),q6||X>5){let Z=A6("updated at");if(Z!==null){let W=z.updated.get(Z.stack);if(!W)W={error:Z,count:0},z.updated.set(Z.stack,W);W.count++}}}if(J0!==null)z.set_during_effect=!0}if((z.f&h0)!==0){if((z.f&i0)!==0)f5(z);s0(z,(z.f&b1)!==0?l0:R1)}if(z.wv=m5(),rQ(z,i0),s1()&&J0!==null&&(J0.f&l0)!==0&&(J0.f&(k1|Q6))===0)if(o1===null)nQ([z]);else o1.push(z);if(!K.is_fork&&j8.size>0&&!r9)bz()}return J}function bz(){r9=!1;var z=T6;Q5(!0);let J=Array.from(j8);try{for(let Q of J){if((Q.f&l0)!==0)s0(Q,R1);if(Z8(Q))Q8(Q)}}finally{Q5(z)}j8.clear()}function S8(z){R(z,z.v+1)}function rQ(z,J){var Q=z.reactions;if(Q===null)return;var K=s1(),X=Q.length;for(var Z=0;Z<X;Z++){var W=Q[Z],G=W.f;if(!K&&W===J0)continue;if(m&&(G&O8)!==0){j8.add(W);continue}var w=(G&i0)===0;if(w)s0(W,J);if((G&h0)!==0){var H=W;if(B1?.delete(H),(G&K6)===0){if(G&b1)W.f|=K6;rQ(H,R1)}}else if(w){if((G&m1)!==0&&W6!==null)W6.add(W);k6(W)}}}var uZ=/^[a-zA-Z_$][a-zA-Z_$0-9]*$/;function x0(z){if(typeof z!=="object"||z===null||Q1 in z)return z;let J=q8(z);if(J!==S9&&J!==n7)return z;var Q=new Map,K=w6(z),X=f(0),Z=m&&q6?A6("created at"):null,W=W8,G=(F)=>{if(W8===W)return F();var B=W0,U=W8;a0(null),n9(W);var M=F();return a0(B),n9(U),M};if(K){if(Q.set("length",f(z.length,Z)),m)z=pZ(z)}var w="";let H=!1;function q(F){if(H)return;H=!0,w=F,u1(X,`${w} version`);for(let[B,U]of Q)u1(U,A8(w,B));H=!1}return new Proxy(z,{defineProperty(F,B,U){if(!("value"in U)||U.configurable===!1||U.enumerable===!1||U.writable===!1)VQ();var M=Q.get(B);if(M===void 0)M=G(()=>{var P=f(U.value,Z);if(Q.set(B,P),m&&typeof B==="string")u1(P,A8(w,B));return P});else R(M,U.value,!0);return!0},deleteProperty(F,B){var U=Q.get(B);if(U===void 0){if(B in F){let M=G(()=>f(E0,Z));if(Q.set(B,M),S8(X),m)u1(M,A8(w,B))}}else R(U,E0),S8(X);return!0},get(F,B,U){if(B===Q1)return z;if(m&&B===Oz)return q;var M=Q.get(B),P=B in F;if(M===void 0&&(!P||D1(F,B)?.writable))M=G(()=>{var j=x0(P?F[B]:E0),T=f(j,Z);if(m)u1(T,A8(w,B));return T}),Q.set(B,M);if(M!==void 0){var O=Y(M);return O===E0?void 0:O}return Reflect.get(F,B,U)},getOwnPropertyDescriptor(F,B){var U=Reflect.getOwnPropertyDescriptor(F,B);if(U&&"value"in U){var M=Q.get(B);if(M)U.value=Y(M)}else if(U===void 0){var P=Q.get(B),O=P?.v;if(P!==void 0&&O!==E0)return{enumerable:!0,configurable:!0,value:O,writable:!0}}return U},has(F,B){if(B===Q1)return!0;var U=Q.get(B),M=U!==void 0&&U.v!==E0||Reflect.has(F,B);if(U!==void 0||J0!==null&&(!M||D1(F,B)?.writable)){if(U===void 0)U=G(()=>{var O=M?x0(F[B]):E0,j=f(O,Z);if(m)u1(j,A8(w,B));return j}),Q.set(B,U);var P=Y(U);if(P===E0)return!1}return M},set(F,B,U,M){var P=Q.get(B),O=B in F;if(K&&B==="length")for(var j=U;j<P.v;j+=1){var T=Q.get(j+"");if(T!==void 0)R(T,E0);else if(j in F){if(T=G(()=>f(E0,Z)),Q.set(j+"",T),m)u1(T,A8(w,j))}}if(P===void 0){if(!O||D1(F,B)?.writable){if(P=G(()=>f(void 0,Z)),m)u1(P,A8(w,B));R(P,x0(U)),Q.set(B,P)}}else{O=P.v!==E0;var E=G(()=>x0(U));R(P,E)}var N=Reflect.getOwnPropertyDescriptor(F,B);if(N?.set)N.set.call(M,U);if(!O){if(K&&typeof B==="string"){var y=Q.get("length"),b=Number(B);if(Number.isInteger(b)&&b>=y.v)R(y,b+1)}S8(X)}return!0},ownKeys(F){Y(X);var B=Reflect.ownKeys(F).filter((P)=>{var O=Q.get(P);return O===void 0||O.v!==E0});for(var[U,M]of Q)if(M.v!==E0&&!(U in F))B.push(U);return B},setPrototypeOf(){FQ()}})}function A8(z,J){if(typeof J==="symbol")return`${z}[Symbol(${J.description??""})]`;if(uZ.test(J))return`${z}.${J}`;return/^\d+$/.test(J)?`${z}[${J}]`:`${z}['${J}']`}function W5(z){try{if(z!==null&&typeof z==="object"&&Q1 in z)return z[Q1]}catch{}return z}function fz(z,J){return Object.is(W5(z),W5(J))}var lZ=new Set(["copyWithin","fill","pop","push","reverse","shift","sort","splice","unshift"]);function pZ(z){return new Proxy(z,{get(J,Q,K){var X=Reflect.get(J,Q,K);if(!lZ.has(Q))return X;return function(...Z){aQ();var W=X.apply(this,Z);return bz(),W}}})}function tQ(){let{prototype:z,__svelte_cleanup:J}=Array;if(J)J();let{indexOf:Q,lastIndexOf:K,includes:X}=z;z.indexOf=function(Z,W){let G=Q.call(this,Z,W);if(G===-1){for(let w=W??0;w<this.length;w+=1)if(W5(this[w])===Z){Pz("array.indexOf(...)");break}}return G},z.lastIndexOf=function(Z,W){let G=K.call(this,Z,W??this.length-1);if(G===-1){for(let w=0;w<=(W??this.length-1);w+=1)if(W5(this[w])===Z){Pz("array.lastIndexOf(...)");break}}return G},z.includes=function(Z,W){let G=X.call(this,Z,W);if(!G){for(let w=0;w<this.length;w+=1)if(W5(this[w])===Z){Pz("array.includes(...)");break}}return G},Array.__svelte_cleanup=()=>{z.indexOf=Q,z.lastIndexOf=K,z.includes=X}}var t9,Y8,E5,eQ,zK;function mz(){if(t9!==void 0)return;t9=window,Y8=document,E5=/Firefox/.test(navigator.userAgent);var z=Element.prototype,J=Node.prototype,Q=Text.prototype;if(eQ=D1(J,"firstChild").get,zK=D1(J,"nextSibling").get,D9(z))z.__click=void 0,z.__className=void 0,z.__attributes=null,z.__style=void 0,z.__e=void 0;if(D9(Q))Q.__t=void 0;if(m)z.__svelte_meta=null,tQ()}function d0(z=""){return document.createTextNode(z)}function r0(z){return eQ.call(z)}function e0(z){return zK.call(z)}function x(z,J){if(!r)return r0(z);var Q=r0(B0);if(Q===null)Q=B0.appendChild(d0());else if(J&&Q.nodeType!==e8){var K=d0();return Q?.before(K),j0(K),K}return j0(Q),Q}function h(z,J=!1){if(!r){var Q=r0(z);if(Q instanceof Comment&&Q.data==="")return e0(Q);return Q}if(J&&B0?.nodeType!==e8){var K=d0();return B0?.before(K),j0(K),K}return B0}function S(z,J=1,Q=!1){let K=r?B0:z;var X;while(J--)X=K,K=e0(K);if(!r)return K;if(Q&&K?.nodeType!==e8){var Z=d0();if(K===null)X?.after(Z);else K.before(Z);return j0(Z),Z}return j0(K),K}function v5(z){z.textContent=""}function Ez(){if(!N1)return!1;if(W6!==null)return!1;var z=J0.f;return(z&U6)!==0}function N8(z,J){if(J){let Q=document.body;z.autofocus=!0,p0(()=>{if(document.activeElement===Q)z.focus()})}}var JK=!1;function $z(){if(!JK)JK=!0,document.addEventListener("reset",(z)=>{Promise.resolve().then(()=>{if(!z.defaultPrevented)for(let J of z.target.elements)J.__on_r?.()})},{capture:!0})}function _6(z){var J=W0,Q=J0;a0(null),U1(null);try{return z()}finally{a0(J),U1(Q)}}function $5(z,J,Q,K=Q){z.addEventListener(J,()=>_6(Q));let X=z.__on_r;if(X)z.__on_r=()=>{X(),K(!0)};else z.__on_r=()=>K(!0);$z()}function zJ(z){if(J0===null){if(W0===null)XQ(z);ZQ()}if(x6)KQ(z)}function dZ(z,J){var Q=J.last;if(Q===null)J.last=J.first=z;else Q.next=z,z.prev=Q,J.last=z}function r1(z,J,Q){var K=J0;if(m)while(K!==null&&(K.f&O8)!==0)K=K.parent;if(K!==null&&(K.f&j1)!==0)z|=j1;var X={ctx:L0,deps:null,nodes_start:null,nodes_end:null,f:z|i0|b1,first:null,fn:J,last:null,next:null,parent:K,b:K&&K.b,prev:null,teardown:null,transitions:null,wv:0,ac:null};if(m)X.component_function=c1;if(Q)try{Q8(X),X.f|=U6}catch(G){throw y0(X),G}else if(J!==null)k6(X);var Z=X;if(Q&&Z.deps===null&&Z.teardown===null&&Z.nodes_start===null&&Z.first===Z.last&&(Z.f&S6)===0){if(Z=Z.first,(z&m1)!==0&&(z&E1)!==0&&Z!==null)Z.f|=E1}if(Z!==null){if(Z.parent=K,K!==null)dZ(Z,K);if(W0!==null&&(W0.f&h0)!==0&&(z&Q6)===0){var W=W0;(W.effects??=[]).push(Z)}}return X}function j6(){return W0!==null&&!a1}function K1(z){let J=r1(C8,null,!1);return s0(J,l0),J.teardown=z,J}function Z1(z){if(zJ("$effect"),m)q1(z,"name",{value:"$effect"});var J=J0.f,Q=!W0&&(J&k1)!==0&&(J&U6)===0;if(Q){var K=L0;(K.e??=[]).push(z)}else return h9(z)}function h9(z){return r1(a8|j9,z,!1)}function u5(z){if(zJ("$effect.pre"),m)q1(z,"name",{value:"$effect.pre"});return r1(C8|j9,z,!0)}function JJ(z){o0.ensure();let J=r1(Q6|S6,z,!0);return()=>{y0(J)}}function QK(z){o0.ensure();let J=r1(Q6|S6,z,!0);return(Q={})=>{return new Promise((K)=>{if(Q.outro)d6(J,()=>{y0(J),K(void 0)});else y0(J),K(void 0)})}}function V1(z){return r1(a8,z,!1)}function oQ(z){return r1(m6|S6,z,!0)}function w1(z,J=0){return r1(C8|J,z,!0)}function g(z,J=[],Q=[],K=[]){Z5(K,J,Q,(X)=>{r1(C8,()=>z(...X.map(Y)),!0)})}function l5(z,J=[],Q=[],K=[]){var X=w0,Z=Q.length>0||K.length>0;if(Z)X.increment(!0);Z5(K,J,Q,(W)=>{if(r1(a8,()=>z(...W.map(Y)),!1),Z)X.decrement(!0)})}function x1(z,J=0){var Q=r1(m1|J,z,!0);if(m)Q.dev_stack=M6;return Q}function uz(z,J=0){var Q=r1(r8|J,z,!0);if(m)Q.dev_stack=M6;return Q}function z1(z){return r1(k1|S6,z,!0)}function QJ(z){var J=z.teardown;if(J!==null){let Q=x6,K=W0;e9(!0),a0(null);try{J.call(null)}finally{e9(Q),a0(K)}}}function KJ(z,J=!1){var Q=z.first;z.first=z.last=null;while(Q!==null){let X=Q.ac;if(X!==null)_6(()=>{X.abort(D6)});var K=Q.next;if((Q.f&Q6)!==0)Q.parent=null;else y0(Q,J);Q=K}}function KK(z){var J=z.first;while(J!==null){var Q=J.next;if((J.f&k1)===0)y0(J);J=Q}}function y0(z,J=!0){var Q=!1;if((J||(z.f&n8)!==0)&&z.nodes_start!==null&&z.nodes_end!==null)ZJ(z.nodes_start,z.nodes_end),Q=!0;KJ(z,J&&!Q),p5(z,0),s0(z,T1);var K=z.transitions;if(K!==null)for(let Z of K)Z.stop();QJ(z);var X=z.parent;if(X!==null&&X.first!==null)u9(z);if(m)z.component_function=null;z.next=z.prev=z.teardown=z.ctx=z.deps=z.fn=z.nodes_start=z.nodes_end=z.ac=null}function ZJ(z,J){while(z!==null){var Q=z===J?null:e0(z);z.remove(),z=Q}}function u9(z){var{parent:J,prev:Q,next:K}=z;if(Q!==null)Q.next=K;if(K!==null)K.prev=Q;if(J!==null){if(J.first===z)J.first=K;if(J.last===z)J.last=Q}}function d6(z,J,Q=!0){var K=[];yz(z,K,!0),i9(K,()=>{if(Q)y0(z);if(J)J()})}function i9(z,J){var Q=z.length;if(Q>0){var K=()=>--Q||J();for(var X of z)X.out(K)}else J()}function yz(z,J,Q){if((z.f&j1)!==0)return;if(z.f^=j1,z.transitions!==null){for(let W of z.transitions)if(W.is_global||Q)J.push(W)}var K=z.first;while(K!==null){var X=K.next,Z=(K.f&E1)!==0||(K.f&k1)!==0&&(z.f&m1)!==0;yz(K,J,Z?Q:!1),K=X}}function g5(z){ZK(z,!0)}function ZK(z,J){if((z.f&j1)===0)return;if(z.f^=j1,(z.f&l0)===0)s0(z,i0),k6(z);var Q=z.first;while(Q!==null){var K=Q.next,X=(Q.f&E1)!==0||(Q.f&k1)!==0;ZK(Q,X?J:!1),Q=K}if(z.transitions!==null){for(let Z of z.transitions)if(Z.is_global||J)Z.in()}}function Tz(z,J){var{nodes_start:Q,nodes_end:K}=z;while(Q!==null){var X=Q===K?null:e0(Q);J.append(Q),Q=X}}var XK=null;var T6=!1;function Q5(z){T6=z}var x6=!1;function e9(z){x6=z}var W0=null,a1=!1;function a0(z){W0=z}var J0=null;function U1(z){J0=z}var E6=null;function hz(z){if(W0!==null&&(!N1||(W0.f&h0)!==0))if(E6===null)E6=[z];else E6.push(z)}var y1=null,n1=0,o1=null;function nQ(z){o1=z}var WK=1,d5=0,W8=d5;function n9(z){W8=z}function m5(){return++WK}function Z8(z){var J=z.f;if((J&i0)!==0)return!0;if(J&h0)z.f&=~K6;if((J&R1)!==0){var Q=z.deps;if(Q!==null){var K=Q.length;for(var X=0;X<K;X++){var Z=Q[X];if(Z8(Z))a9(Z);if(Z.wv>z.wv)return!0}}if((J&b1)!==0&&B1===null)s0(z,l0)}return!1}function YK(z,J,Q=!0){var K=z.reactions;if(K===null)return;if(!N1&&E6?.includes(z))return;for(var X=0;X<K.length;X++){var Z=K[X];if((Z.f&h0)!==0)YK(Z,J,!1);else if(J===Z){if(Q)s0(Z,i0);else if((Z.f&l0)!==0)s0(Z,R1);k6(Z)}}}function gz(z){var J=y1,Q=n1,K=o1,X=W0,Z=E6,W=L0,G=a1,w=W8,H=z.f;if(y1=null,n1=0,o1=null,W0=(H&(k1|Q6))===0?z:null,E6=null,p6(z.ctx),a1=!1,W8=++d5,z.ac!==null)_6(()=>{z.ac.abort(D6)}),z.ac=null;try{z.f|=xz;var q=z.fn,F=q(),B=z.deps;if(y1!==null){var U;if(p5(z,n1),B!==null&&n1>0){B.length=n1+y1.length;for(U=0;U<y1.length;U++)B[n1+U]=y1[U]}else z.deps=B=y1;if(T6&&j6()&&(z.f&b1)!==0)for(U=n1;U<B.length;U++)(B[U].reactions??=[]).push(z)}else if(B!==null&&n1<B.length)p5(z,n1),B.length=n1;if(s1()&&o1!==null&&!a1&&B!==null&&(z.f&(h0|R1|i0))===0)for(U=0;U<o1.length;U++)YK(o1[U],z);if(X!==null&&X!==z){if(d5++,o1!==null)if(K===null)K=o1;else K.push(...o1)}if((z.f&Z6)!==0)z.f^=Z6;return F}catch(M){return Az(M)}finally{z.f^=xz,y1=J,n1=Q,o1=K,W0=X,E6=Z,p6(W),a1=G,W8=w}}function iZ(z,J){let Q=J.reactions;if(Q!==null){var K=r7.call(Q,z);if(K!==-1){var X=Q.length-1;if(X===0)Q=J.reactions=null;else Q[K]=Q[X],Q.pop()}}if(Q===null&&(J.f&h0)!==0&&(y1===null||!y1.includes(J))){if(s0(J,R1),(J.f&b1)!==0)J.f^=b1,J.f&=~K6;vz(J),p5(J,0)}}function p5(z,J){var Q=z.deps;if(Q===null)return;for(var K=J;K<Q.length;K++)iZ(z,Q[K])}function Q8(z){var J=z.f;if((J&T1)!==0)return;s0(z,l0);var Q=J0,K=T6;if(J0=z,T6=!0,m){var X=c1;jz(z.component_function);var Z=M6;z5(z.dev_stack??M6)}try{if((J&(m1|r8))!==0)KK(z);else KJ(z);QJ(z);var W=gz(z);if(z.teardown=typeof W==="function"?W:null,z.wv=WK,m&&q6&&(z.f&i0)!==0&&z.deps!==null){for(var G of z.deps)if(G.set_during_effect)G.wv=m5(),G.set_during_effect=!1}}finally{if(T6=K,J0=Q,m)jz(X),z5(Z)}}async function y5(){if(N1)return new Promise((z)=>{requestAnimationFrame(()=>z()),setTimeout(()=>z())});await Promise.resolve(),K8()}function Y(z){var J=z.f,Q=(J&h0)!==0;if(XK?.add(z),W0!==null&&!a1){var K=J0!==null&&(J0.f&T1)!==0;if(!K&&!E6?.includes(z)){var X=W0.deps;if((W0.f&xz)!==0){if(z.rv<d5){if(z.rv=d5,y1===null&&X!==null&&X[n1]===z)n1++;else if(y1===null)y1=[z];else if(!y1.includes(z))y1.push(z)}}else{(W0.deps??=[]).push(z);var Z=z.reactions;if(Z===null)z.reactions=[W0];else if(!Z.includes(W0))Z.push(W0)}}}if(m){if(h5.delete(z),q6&&!a1&&T5!==null&&W0!==null&&T5.reaction===W0)if(z.trace)z.trace();else{var W=A6("traced at");if(W){var G=T5.entries.get(z);if(G===void 0)G={traces:[]},T5.entries.set(z,G);var w=G.traces[G.traces.length-1];if(W.stack!==w?.stack)G.traces.push(W)}}}if(x6){if(b6.has(z))return b6.get(z);if(Q){var H=z,q=H.v;if((H.f&l0)===0&&H.reactions!==null||BK(H))q=f5(H);return b6.set(H,q),q}}else if(Q&&(!B1?.has(z)||w0?.is_fork&&!j6())){if(H=z,Z8(H))a9(H);if(T6&&j6()&&(H.f&b1)===0)GK(H)}if(B1?.has(z))return B1.get(z);if((z.f&Z6)!==0)throw z.v;return z.v}function GK(z){if(z.deps===null)return;z.f^=b1;for(let J of z.deps)if((J.reactions??=[]).push(z),(J.f&h0)!==0&&(J.f&b1)===0)GK(J)}function BK(z){if(z.v===E0)return!0;if(z.deps===null)return!1;for(let J of z.deps){if(b6.has(J))return!0;if((J.f&h0)!==0&&BK(J))return!0}return!1}function b0(z){var J=a1;try{return a1=!0,z()}finally{a1=J}}var _Z=~(i0|R1|l0);function s0(z,J){z.f=z.f&_Z|J}function pz(z){if(typeof z!=="object"||!z||z instanceof EventTarget)return;if(Q1 in z)lz(z);else if(!Array.isArray(z))for(let J in z){let Q=z[J];if(typeof Q==="object"&&Q&&Q1 in Q)lz(Q)}}function lz(z,J=new Set){if(typeof z==="object"&&z!==null&&!(z instanceof EventTarget)&&!J.has(z)){if(J.add(z),z instanceof Date)z.getTime();for(let K in z)try{lz(z[K],J)}catch(X){}let Q=q8(z);if(Q!==Object.prototype&&Q!==Array.prototype&&Q!==Map.prototype&&Q!==Set.prototype&&Q!==Date.prototype){let K=Mz(Q);for(let X in K){let Z=K[X].get;if(Z)try{Z.call(z)}catch(W){}}}}}var cZ=/\r/g;function HK(z){z=z.replace(cZ,"");let J=5381,Q=z.length;while(Q--)J=(J<<5)-J^z.charCodeAt(Q);return(J>>>0).toString(36)}function wK(z){return z.endsWith("capture")&&z!=="gotpointercapture"&&z!=="lostpointercapture"}var sZ=["beforeinput","click","change","dblclick","contextmenu","focusin","focusout","input","keydown","keyup","mousedown","mousemove","mouseout","mouseover","mouseup","pointerdown","pointermove","pointerout","pointerover","pointerup","touchend","touchmove","touchstart"];function UK(z){return sZ.includes(z)}var oZ=["allowfullscreen","async","autofocus","autoplay","checked","controls","default","disabled","formnovalidate","indeterminate","inert","ismap","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","seamless","selected","webkitdirectory","defer","disablepictureinpicture","disableremoteplayback"];var aZ={formnovalidate:"formNoValidate",ismap:"isMap",nomodule:"noModule",playsinline:"playsInline",readonly:"readOnly",defaultvalue:"defaultValue",defaultchecked:"defaultChecked",srcobject:"srcObject",novalidate:"noValidate",allowfullscreen:"allowFullscreen",disablepictureinpicture:"disablePictureInPicture",disableremoteplayback:"disableRemotePlayback"};function VK(z){return z=z.toLowerCase(),aZ[z]??z}var RH=[...oZ,"formNoValidate","isMap","noModule","playsInline","readOnly","value","volume","defaultValue","defaultChecked","srcObject","noValidate","allowFullscreen","disablePictureInPicture","disableRemotePlayback"];var rZ=["touchstart","touchmove"];function FK(z){return rZ.includes(z)}var nZ=["$state","$state.raw","$derived","$derived.by"],jH=[...nZ,"$state.eager","$state.snapshot","$props","$props.id","$bindable","$effect","$effect.pre","$effect.tracking","$effect.root","$effect.pending","$inspect","$inspect().with","$inspect.trace","$host"];function dz(z){return z?.replace(/\//g,"/โ€‹")}var XJ=new Set,iz=new Set;function Y5(z){if(!r)return;z.removeAttribute("onload"),z.removeAttribute("onerror");let J=z.__e;if(J!==void 0)z.__e=void 0,queueMicrotask(()=>{if(z.isConnected)z.dispatchEvent(J)})}function WJ(z,J,Q,K={}){function X(Z){if(!K.capture)G5.call(J,Z);if(!Z.cancelBubble)return _6(()=>{return Q?.call(this,Z)})}if(z.startsWith("pointer")||z.startsWith("touch")||z==="wheel")p0(()=>{J.addEventListener(z,X,K)});else J.addEventListener(z,X,K);return X}function L1(z,J,Q,K,X){var Z={capture:K,passive:X},W=WJ(z,J,Q,Z);if(J===document.body||J===window||J===document||J instanceof HTMLMediaElement)K1(()=>{J.removeEventListener(z,W,Z)})}function I0(z){for(var J=0;J<z.length;J++)XJ.add(z[J]);for(var Q of iz)Q(z)}var MK=null;function G5(z){var J=this,Q=J.ownerDocument,K=z.type,X=z.composedPath?.()||[],Z=X[0]||z.target;MK=z;var W=0,G=MK===z&&z.__root;if(G){var w=X.indexOf(G);if(w!==-1&&(J===document||J===window)){z.__root=J;return}var H=X.indexOf(J);if(H===-1)return;if(w<=H)W=w}if(Z=X[W]||z.target,Z===J)return;q1(z,"currentTarget",{configurable:!0,get(){return Z||Q}});var q=W0,F=J0;a0(null),U1(null);try{var B,U=[];while(Z!==null){var M=Z.assignedSlot||Z.parentNode||Z.host||null;try{var P=Z["__"+K];if(P!=null&&(!Z.disabled||z.target===Z))P.call(Z,z)}catch(O){if(B)U.push(O);else B=O}if(z.cancelBubble||M===J||M===null)break;Z=M}if(B){for(let O of U)queueMicrotask(()=>{throw O});throw B}}finally{z.__root=J,delete z.currentTarget,a0(q),U1(F)}}function i5(z){var J=document.createElement("template");return J.innerHTML=z.replaceAll("<!>","<!---->"),J.content}function p1(z,J){var Q=J0;if(Q.nodes_start===null)Q.nodes_start=z,Q.nodes_end=J}function L(z,J){var Q=(J&DQ)!==0,K=(J&RQ)!==0,X,Z=!z.startsWith("<!>");return()=>{if(r)return p1(B0,null),B0;if(X===void 0){if(X=i5(Z?z:"<!>"+z),!Q)X=r0(X)}var W=K||E5?document.importNode(X,!0):X.cloneNode(!0);if(Q){var G=r0(W),w=W.lastChild;p1(G,w)}else p1(W,W);return W}}function d1(z=""){if(!r){var J=d0(z+"");return p1(J,J),J}var Q=B0;if(Q.nodeType!==e8)Q.before(Q=d0()),j0(Q);return p1(Q,Q),Q}function c(){if(r)return p1(B0,null),B0;var z=document.createDocumentFragment(),J=document.createComment(""),Q=d0();return z.append(J,Q),p1(J,Q),z}function V(z,J){if(r){var Q=J0;if((Q.f&U6)===0||Q.nodes_end===null)Q.nodes_end=B0;_0();return}if(z===null)return;z.before(J)}var YJ=!0;function u(z,J){var Q=J==null?"":typeof J==="object"?J+"":J;if(Q!==(z.__t??=z.nodeValue))z.__t=Q,z.nodeValue=Q+""}function H5(z,J){return xK(z,J)}function BJ(z,J){mz(),J.intro=J.intro??!1;let Q=J.target,K=r,X=B0;try{var Z=r0(Q);while(Z&&(Z.nodeType!==A1||Z.data!==b5))Z=e0(Z);if(!Z)throw R6;f0(!0),j0(Z);let W=xK(z,{...J,anchor:Z});return f0(!1),W}catch(W){if(W instanceof Error&&W.message.split(` 45 + `).some((G)=>G.startsWith("https://svelte.dev/e/")))throw W;if(W!==R6)console.warn("Failed to hydrate: ",W);if(J.recover===!1)YQ();return mz(),v5(Q),f0(!1),H5(z,J)}finally{f0(K),j0(X)}}var B5=new Map;function xK(z,{target:J,anchor:Q,props:K={},events:X,context:Z,intro:W=!0}){mz();var G=new Set,w=(F)=>{for(var B=0;B<F.length;B++){var U=F[B];if(G.has(U))continue;G.add(U);var M=FK(U);J.addEventListener(U,G5,{passive:M});var P=B5.get(U);if(P===void 0)document.addEventListener(U,G5,{passive:M}),B5.set(U,1);else B5.set(U,P+1)}};w(k5(XJ)),iz.add(w);var H=void 0,q=QK(()=>{var F=Q??J.appendChild(d0());return l9(F,{pending:()=>{}},(B)=>{if(Z){i({});var U=L0;U.c=Z}if(X)K.$$events=X;if(r)p1(B,null);if(YJ=W,H=z(B,K)||{},YJ=!0,r){if(J0.nodes_end=B0,B0===null||B0.nodeType!==A1||B0.data!==L8)throw z8(),R6}if(Z)_()}),()=>{for(var B of G){J.removeEventListener(B,G5);var U=B5.get(B);if(--U===0)document.removeEventListener(B,G5),B5.delete(B);else B5.set(B,U)}if(iz.delete(w),F!==Q)F.parentNode?.removeChild(F)}});return GJ.set(H,q),H}var GJ=new WeakMap;function HJ(z,J){let Q=GJ.get(z);if(Q)return GJ.delete(z),Q(J);if(m)if(Q1 in z)yQ();else TQ();return Promise.resolve()}class O6{anchor;#z=new Map;#J=new Map;#Q=new Map;#K=new Set;#G=!0;constructor(z,J=!0){this.anchor=z,this.#G=J}#X=()=>{var z=w0;if(!this.#z.has(z))return;var J=this.#z.get(z),Q=this.#J.get(J);if(Q)g5(Q),this.#K.delete(J);else{var K=this.#Q.get(J);if(K)this.#J.set(J,K.effect),this.#Q.delete(J),K.fragment.lastChild.remove(),this.anchor.before(K.fragment),Q=K.effect}for(let[X,Z]of this.#z){if(this.#z.delete(X),X===z)break;let W=this.#Q.get(Z);if(W)y0(W.effect),this.#Q.delete(Z)}for(let[X,Z]of this.#J){if(X===J||this.#K.has(X))continue;let W=()=>{if(Array.from(this.#z.values()).includes(X)){var w=document.createDocumentFragment();Tz(Z,w),w.append(d0()),this.#Q.set(X,{effect:Z,fragment:w})}else y0(Z);this.#K.delete(X),this.#J.delete(X)};if(this.#G||!Q)this.#K.add(X),d6(Z,W,!1);else W()}};#Z=(z)=>{this.#z.delete(z);let J=Array.from(this.#z.values());for(let[Q,K]of this.#Q)if(!J.includes(Q))y0(K.effect),this.#Q.delete(Q)};ensure(z,J){var Q=w0,K=Ez();if(J&&!this.#J.has(z)&&!this.#Q.has(z))if(K){var X=document.createDocumentFragment(),Z=d0();X.append(Z),this.#Q.set(z,{effect:z1(()=>J(Z)),fragment:X})}else this.#J.set(z,z1(()=>J(this.anchor)));if(this.#z.set(Q,z),K){for(let[W,G]of this.#J)if(W===z)Q.skipped_effects.delete(G);else Q.skipped_effects.add(G);for(let[W,G]of this.#Q)if(W===z)Q.skipped_effects.delete(G.effect);else Q.skipped_effects.add(G.effect);Q.oncommit(this.#X),Q.ondiscard(this.#Z)}else{if(r)this.anchor=B0;this.#X()}}}var OK=0,wJ=1;function k8(z,J,Q,K,X){if(r)_0();var Z=s1(),W=E0,G=Z?H1(W):i6(W,!1,!1),w=Z?H1(W):i6(W,!1,!1),H=new O6(z);x1(()=>{var q=J(),F=!1;let B=r&&R9(q)===(z.data===V6);if(B)j0(F6()),f0(!1);if(R9(q)){var U=_9(),M=!1;let P=(O)=>{if(F)return;if(M=!0,U(!1),o0.ensure(),r)f0(!1);try{O()}finally{if(R8(),!J8)K8()}};if(q.then((O)=>{P(()=>{l1(G,O),H.ensure(wJ,K&&((j)=>K(j,G)))})},(O)=>{P(()=>{if(l1(w,O),H.ensure(wJ,X&&((j)=>X(j,w))),!X)throw w.v})}),r)H.ensure(OK,Q);else p0(()=>{if(!M)P(()=>{H.ensure(OK,Q)})})}else l1(G,q),H.ensure(wJ,K&&((P)=>K(P,G)));if(B)f0(!0);return()=>{F=!0}})}function D(z,J,Q=!1){if(r)_0();var K=new O6(z),X=Q?E1:0;function Z(W,G){if(r){let H=Iz(z)===V6;if(W===H){var w=F6();j0(w),K.anchor=w,f0(!1),K.ensure(W,G),f0(!0);return}}K.ensure(W,G)}x1(()=>{var W=!1;if(J((G,w=!0)=>{W=!0,Z(w,G)}),!W)Z(!1,null)},X)}function UJ(z,J,Q){if(r)_0();var K=new O6(z),X=!s1();x1(()=>{var Z=J();if(X&&Z!==null&&typeof Z==="object")Z={};K.ensure(Z,Q)})}function z4(z,J,Q){if(!J||J===HK(String(Q??"")))return;let K,X=z.__svelte_meta?.loc;if(X)K=`near ${X.file}:${X.line}:${X.column}`;else if(c1?.[$1])K=`in ${c1[$1]}`;bQ(dz(K))}function VJ(z,J,Q=!1,K=!1,X=!1){var Z=z,W="";g(()=>{var G=J0;if(W===(W=J()??"")){if(r)_0();return}if(G.nodes_start!==null)ZJ(G.nodes_start,G.nodes_end),G.nodes_start=G.nodes_end=null;if(W==="")return;if(r){var w=B0.data,H=_0(),q=H;while(H!==null&&(H.nodeType!==A1||H.data!==""))q=H,H=e0(H);if(H===null)throw z8(),R6;if(m&&!X)z4(H.parentNode,w,W);p1(B0,q),Z=j0(H);return}var F=W+"";if(Q)F=`<svg>${F}</svg>`;else if(K)F=`<math>${F}</math>`;var B=i5(F);if(Q||K)B=r0(B);if(p1(r0(B),B.lastChild),Q||K)while(r0(B))Z.before(r0(B));else Z.before(B)})}function G8(z,J,...Q){var K=new O6(z);x1(()=>{let X=J()??null;if(m&&X==null)GQ();K.ensure(X,X&&((Z)=>X(Z,...Q)))},E1)}function b8(z,J){let Q=null,K=r;var X;if(r){Q=B0;var Z=r0(document.head);while(Z!==null&&(Z.nodeType!==A1||Z.data!==z))Z=e0(Z);if(Z===null)f0(!1);else{var W=e0(Z);Z.remove(),j0(W)}}if(!r)X=document.head.appendChild(d0());try{x1(()=>J(X),n8)}finally{if(K)f0(!0),j0(Q)}}function FJ(z,J){var Q=void 0,K;uz(()=>{if(Q!==(Q=J())){if(K)y0(K),K=null;if(Q)K=z1(()=>{V1(()=>Q(z))})}})}function LK(z){var J,Q,K="";if(typeof z=="string"||typeof z=="number")K+=z;else if(typeof z=="object")if(Array.isArray(z)){var X=z.length;for(J=0;J<X;J++)z[J]&&(Q=LK(z[J]))&&(K&&(K+=" "),K+=Q)}else for(Q in z)z[Q]&&(K&&(K+=" "),K+=Q);return K}function PK(){for(var z,J,Q=0,K="",X=arguments.length;Q<X;Q++)(z=arguments[Q])&&(J=LK(z))&&(K&&(K+=" "),K+=J);return K}function T8(z){if(typeof z==="object")return PK(z);else return z??""}var IK=[...` 46 + \r\fย \v\uFEFF`];function DK(z,J,Q){var K=z==null?"":""+z;if(J)K=K?K+" "+J:J;if(Q){for(var X in Q)if(Q[X])K=K?K+" "+X:X;else if(K.length){var Z=X.length,W=0;while((W=K.indexOf(X,W))>=0){var G=W+Z;if((W===0||IK.includes(K[W-1]))&&(G===K.length||IK.includes(K[G])))K=(W===0?"":K.substring(0,W))+K.substring(G+1);else W=G}}}return K===""?null:K}function SK(z,J=!1){var Q=J?" !important;":";",K="";for(var X in z){var Z=z[X];if(Z!=null&&Z!=="")K+=" "+X+": "+Z+Q}return K}function qJ(z){if(z[0]!=="-"||z[1]!=="-")return z.toLowerCase();return z}function RK(z,J){if(J){var Q="",K,X;if(Array.isArray(J))K=J[0],X=J[1];else K=J;if(z){z=String(z).replaceAll(/\s*\/\*.*?\*\/\s*/g,"").trim();var Z=!1,W=0,G=!1,w=[];if(K)w.push(...Object.keys(K).map(qJ));if(X)w.push(...Object.keys(X).map(qJ));var H=0,q=-1;let P=z.length;for(var F=0;F<P;F++){var B=z[F];if(G){if(B==="/"&&z[F-1]==="*")G=!1}else if(Z){if(Z===B)Z=!1}else if(B==="/"&&z[F+1]==="*")G=!0;else if(B==='"'||B==="'")Z=B;else if(B==="(")W++;else if(B===")")W--;if(!G&&Z===!1&&W===0){if(B===":"&&q===-1)q=F;else if(B===";"||F===P-1){if(q!==-1){var U=qJ(z.substring(H,q).trim());if(!w.includes(U)){if(B!==";")F++;var M=z.substring(H,F).trim();Q+=" "+M+";"}}H=F+1,q=-1}}}}if(K)Q+=SK(K);if(X)Q+=SK(X,!0);return Q=Q.trim(),Q===""?null:Q}return z==null?null:String(z)}function X1(z,J,Q,K,X,Z){var W=z.__className;if(r||W!==Q||W===void 0){var G=DK(Q,K,Z);if(!r||G!==z.getAttribute("class"))if(G==null)z.removeAttribute("class");else if(J)z.className=G;else z.setAttribute("class",G);z.__className=Q}else if(Z&&X!==Z)for(var w in Z){var H=!!Z[w];if(X==null||H!==!!X[w])z.classList.toggle(w,H)}return Z}function MJ(z,J={},Q,K){for(var X in Q){var Z=Q[X];if(J[X]!==Z)if(Q[X]==null)z.style.removeProperty(X);else z.style.setProperty(X,Z,K)}}function c6(z,J,Q,K){var X=z.__style;if(r||X!==J){var Z=RK(J,K);if(!r||Z!==z.getAttribute("style"))if(Z==null)z.removeAttribute("style");else z.style.cssText=Z;z.__style=J}else if(K)if(Array.isArray(K))MJ(z,Q?.[0],K[0]),MJ(z,Q?.[1],K[1],"important");else MJ(z,Q,K);return K}function w5(z,J,Q=!1){if(z.multiple){if(J==null)return;if(!w6(J))return EQ();for(var K of z.options)K.selected=J.includes(_5(K));return}for(K of z.options){var X=_5(K);if(fz(X,J)){K.selected=!0;return}}if(!Q||J!==void 0)z.selectedIndex=-1}function _z(z){var J=new MutationObserver(()=>{w5(z,z.__value)});J.observe(z,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["value"]}),K1(()=>{J.disconnect()})}function CJ(z,J,Q=J){var K=new WeakSet,X=!0;$5(z,"change",(Z)=>{var W=Z?"[selected]":":checked",G;if(z.multiple)G=[].map.call(z.querySelectorAll(W),_5);else{var w=z.querySelector(W)??z.querySelector("option:not([disabled])");G=w&&_5(w)}if(Q(G),w0!==null)K.add(w0)}),V1(()=>{var Z=J();if(z===document.activeElement){var W=I8??w0;if(K.has(W))return}if(w5(z,Z,X),X&&Z===void 0){var G=z.querySelector(":checked");if(G!==null)Z=_5(G),Q(Z)}z.__value=Z,X=!1}),_z(z)}function _5(z){if("__value"in z)return z.__value;else return z.value}var U5=Symbol("class"),V5=Symbol("style"),NK=Symbol("is custom element"),kK=Symbol("is html");function $0(z){if(!r)return;var J=!1,Q=()=>{if(J)return;if(J=!0,z.hasAttribute("value")){var K=z.value;d(z,"value",null),z.value=K}if(z.hasAttribute("checked")){var X=z.checked;d(z,"checked",null),z.checked=X}};z.__on_r=Q,p0(Q),$z()}function Y6(z,J){var Q=OJ(z);if(Q.value===(Q.value=J??void 0)||z.value===J&&(J!==0||z.nodeName!=="PROGRESS"))return;z.value=J??""}function bK(z,J){if(J){if(!z.hasAttribute("selected"))z.setAttribute("selected","")}else z.removeAttribute("selected")}function d(z,J,Q,K){var X=OJ(z);if(r){if(X[J]=z.getAttribute(J),J==="src"||J==="srcset"||J==="href"&&z.nodeName==="LINK"){if(!K)Z4(z,J,Q??"");return}}if(X[J]===(X[J]=Q))return;if(J==="loading")z[e7]=Q;if(Q==null)z.removeAttribute(J);else if(typeof Q!=="string"&&TK(z).includes(J))z[J]=Q;else z.setAttribute(J,Q)}function K4(z,J,Q,K,X=!1,Z=!1){if(r&&X&&z.tagName==="INPUT"){var W=z,G=W.type==="checkbox"?"defaultChecked":"defaultValue";if(!(G in Q))$0(W)}var w=OJ(z),H=w[NK],q=!w[kK];let F=r&&H;if(F)f0(!1);var B=J||{},U=z.tagName==="OPTION";for(var M in J)if(!(M in Q))Q[M]=null;if(Q.class)Q.class=T8(Q.class);else if(K||Q[U5])Q.class=null;if(Q[V5])Q.style??=null;var P=TK(z);for(let b in Q){let A=Q[b];if(U&&b==="value"&&A==null){z.value=z.__value="",B[b]=A;continue}if(b==="class"){var O=z.namespaceURI==="http://www.w3.org/1999/xhtml";X1(z,O,A,K,J?.[U5],Q[U5]),B[b]=A,B[U5]=Q[U5];continue}if(b==="style"){c6(z,A,J?.[V5],Q[V5]),B[b]=A,B[V5]=Q[V5];continue}var j=B[b];if(A===j&&!(A===void 0&&z.hasAttribute(b)))continue;B[b]=A;var T=b[0]+b[1];if(T==="$$")continue;if(T==="on"){let k={},$="$$"+b,v=b.slice(2);var E=UK(v);if(wK(v))v=v.slice(0,-7),k.capture=!0;if(!E&&j){if(A!=null)continue;z.removeEventListener(v,B[$],k),B[$]=null}if(A!=null)if(!E){let l=function(n){B[b].call(this,n)};B[$]=WJ(v,z,l,k)}else z[`__${v}`]=A,I0([v]);else if(E)z[`__${v}`]=void 0}else if(b==="style")d(z,b,A);else if(b==="autofocus")N8(z,Boolean(A));else if(!H&&(b==="__value"||b==="value"&&A!=null))z.value=z.__value=A;else if(b==="selected"&&U)bK(z,A);else{var N=b;if(!q)N=VK(N);var y=N==="defaultValue"||N==="defaultChecked";if(A==null&&!H&&!y)if(w[b]=null,N==="value"||N==="checked"){let k=z,$=J===void 0;if(N==="value"){let v=k.defaultValue;k.removeAttribute(N),k.defaultValue=v,k.value=k.__value=$?v:null}else{let v=k.defaultChecked;k.removeAttribute(N),k.defaultChecked=v,k.checked=$?v:!1}}else z.removeAttribute(b);else if(y||P.includes(N)&&(H||typeof A!=="string")){if(z[N]=A,N in w)w[N]=E0}else if(typeof A!=="function")d(z,N,A,Z)}}if(F)f0(!0);return B}function cz(z,J,Q=[],K=[],X=[],Z,W=!1,G=!1){Z5(X,Q,K,(w)=>{var H=void 0,q={},F=z.nodeName==="SELECT",B=!1;if(uz(()=>{var M=J(...w.map(Y)),P=K4(z,H,M,Z,W,G);if(B&&F&&"value"in M)w5(z,M.value);for(let j of Object.getOwnPropertySymbols(q))if(!M[j])y0(q[j]);for(let j of Object.getOwnPropertySymbols(M)){var O=M[j];if(j.description===T9&&(!H||O!==H[j])){if(q[j])y0(q[j]);q[j]=z1(()=>FJ(z,()=>O))}P[j]=O}H=P}),F){var U=z;V1(()=>{w5(U,H.value,!0),_z(U)})}B=!0})}function OJ(z){return z.__attributes??={[NK]:z.nodeName.includes("-"),[kK]:z.namespaceURI===AQ}}var jK=new Map;function TK(z){var J=z.getAttribute("is")||z.nodeName,Q=jK.get(J);if(Q)return Q;jK.set(J,Q=[]);var K,X=z,Z=Element.prototype;while(Z!==X){K=Mz(X);for(var W in K)if(K[W].set)Q.push(W);X=q8(X)}return Q}function Z4(z,J,Q){if(!m)return;if(J==="srcset"&&X4(z,Q))return;if(xJ(z.getAttribute(J)??"",Q))return;kQ(J,z.outerHTML.replace(z.innerHTML,z.innerHTML&&"..."),String(Q))}function xJ(z,J){if(z===J)return!0;return new URL(z,document.baseURI).href===new URL(J,document.baseURI).href}function AK(z){return z.split(",").map((J)=>J.trim().split(" ").filter(Boolean))}function X4(z,J){var Q=AK(z.srcset),K=AK(J);return K.length===Q.length&&K.every(([X,Z],W)=>Z===Q[W][1]&&(xJ(Q[W][0],X)||xJ(X,Q[W][0])))}function P1(z,J,Q=J){var K=new WeakSet;if($5(z,"input",async(X)=>{if(m&&z.type==="checkbox")A9();var Z=X?z.defaultValue:z.value;if(Z=PJ(z)?IJ(Z):Z,Q(Z),w0!==null)K.add(w0);if(await y5(),Z!==(Z=J())){var{selectionStart:W,selectionEnd:G}=z,w=z.value.length;if(z.value=Z??"",G!==null){var H=z.value.length;if(W===G&&G===w&&H>w)z.selectionStart=H,z.selectionEnd=H;else z.selectionStart=W,z.selectionEnd=Math.min(G,H)}}}),r&&z.defaultValue!==z.value||b0(J)==null&&z.value){if(Q(PJ(z)?IJ(z.value):z.value),w0!==null)K.add(w0)}w1(()=>{if(m&&z.type==="checkbox")A9();var X=J();if(z===document.activeElement){var Z=I8??w0;if(K.has(Z))return}if(PJ(z)&&X===IJ(z.value))return;if(z.type==="date"&&!X&&!z.value)return;if(X!==z.value)z.value=X??""})}var LJ=new Set;function c5(z,J,Q,K,X=K){var Z=Q.getAttribute("type")==="checkbox",W=z;let G=!1;if(J!==null)for(var w of J)W=W[w]??=[];if(W.push(Q),$5(Q,"change",()=>{var H=Q.__value;if(Z)H=EK(W,H,Q.checked);X(H)},()=>X(Z?[]:null)),w1(()=>{var H=K();if(r&&Q.defaultChecked!==Q.checked){G=!0;return}if(Z)H=H||[],Q.checked=H.includes(Q.__value);else Q.checked=fz(Q.__value,H)}),K1(()=>{var H=W.indexOf(Q);if(H!==-1)W.splice(H,1)}),!LJ.has(W))LJ.add(W),p0(()=>{W.sort((H,q)=>H.compareDocumentPosition(q)===4?-1:1),LJ.delete(W)});p0(()=>{if(G){var H;if(Z)H=EK(W,H,Q.checked);else{var q=W.find((F)=>F.checked);H=q?.__value}X(H)}})}function EK(z,J,Q){var K=new Set;for(var X=0;X<z.length;X+=1)if(z[X].checked)K.add(z[X].__value);if(!Q)K.delete(J);return Array.from(K)}function PJ(z){var J=z.type;return J==="number"||J==="range"}function IJ(z){return z===""?null:+z}class SJ{#z=new WeakMap;#J;#Q;static entries=new WeakMap;constructor(z){this.#Q=z}observe(z,J){var Q=this.#z.get(z)||new Set;return Q.add(J),this.#z.set(z,Q),this.#K().observe(z,this.#Q),()=>{var K=this.#z.get(z);if(K.delete(J),K.size===0)this.#z.delete(z),this.#J.unobserve(z)}}#K(){return this.#J??(this.#J=new ResizeObserver((z)=>{for(var J of z){SJ.entries.set(J.target,J);for(var Q of this.#z.get(J.target)||[])Q(J)}}))}}var Y4=new SJ({box:"border-box"});function DJ(z,J,Q){var K=Y4.observe(z,()=>Q(z[J]));V1(()=>{return b0(()=>Q(z[J])),K})}function yK(z,J){return z===J||z?.[Q1]===J}function t1(z={},J,Q,K){return V1(()=>{var X,Z;return w1(()=>{X=Z,Z=K?.()||[],b0(()=>{if(z!==Q(...Z)){if(J(z,...Z),X&&yK(Q(...X),z))J(null,...X)}})}),()=>{p0(()=>{if(Z&&yK(Q(...Z),z))J(null,...Z)})}}),z}function RJ(z=!1){let J=L0,Q=J.l.u;if(!Q)return;let K=()=>pz(J.s);if(z){let X=0,Z={},W=X8(()=>{let G=!1,w=J.s;for(let H in w)if(w[H]!==Z[H])Z[H]=w[H],G=!0;if(G)X++;return X});K=()=>Y(W)}if(Q.b.length)u5(()=>{vK(J,K),o8(Q.b)});if(Z1(()=>{let X=b0(()=>Q.m.map(t7));return()=>{for(let Z of X)if(typeof Z==="function")Z()}}),Q.a.length)Z1(()=>{vK(J,K),o8(Q.a)})}function vK(z,J){if(z.l.s)for(let Q of z.l.s)Y(Q);J()}var oz=!1,Gq=Symbol();function jJ(z){var J=oz;try{return oz=!1,[z(),oz]}finally{oz=J}}var B4={get(z,J){if(z.exclude.includes(J))return;return z.props[J]},set(z,J){if(m)HQ(`${z.name}.${String(J)}`);return!1},getOwnPropertyDescriptor(z,J){if(z.exclude.includes(J))return;if(J in z.props)return{enumerable:!0,configurable:!0,value:z.props[J]}},has(z,J){if(z.exclude.includes(J))return!1;return J in z.props},ownKeys(z){return Reflect.ownKeys(z.props).filter((J)=>!z.exclude.includes(J))}};function s6(z,J,Q){return new Proxy(m?{props:z,exclude:J,name:Q,other:{},to_proxy:[]}:{props:z,exclude:J},B4)}var H4={get(z,J){let Q=z.props.length;while(Q--){let K=z.props[Q];if(M8(K))K=K();if(typeof K==="object"&&K!==null&&J in K)return K[J]}},set(z,J,Q){let K=z.props.length;while(K--){let X=z.props[K];if(M8(X))X=X();let Z=D1(X,J);if(Z&&Z.set)return Z.set(Q),!0}return!1},getOwnPropertyDescriptor(z,J){let Q=z.props.length;while(Q--){let K=z.props[Q];if(M8(K))K=K();if(typeof K==="object"&&K!==null&&J in K){let X=D1(K,J);if(X&&!X.configurable)X.configurable=!0;return X}}},has(z,J){if(J===Q1||J===t8)return!1;for(let Q of z.props){if(M8(Q))Q=Q();if(Q!=null&&J in Q)return!0}return!1},ownKeys(z){let J=[];for(let Q of z.props){if(M8(Q))Q=Q();if(!Q)continue;for(let K in Q)if(!J.includes(K))J.push(K);for(let K of Object.getOwnPropertySymbols(Q))if(!J.includes(K))J.push(K)}return J}};function AJ(...z){return new Proxy({props:z},H4)}function R0(z,J,Q,K){var X=!l6||(Q&LQ)!==0,Z=(Q&IQ)!==0,W=(Q&SQ)!==0,G=K,w=!0,H=()=>{if(w)w=!1,G=W?b0(K):K;return G},q;if(Z){var F=Q1 in z||t8 in z;q=D1(z,J)?.set??(F&&J in z?(E)=>z[J]=E:void 0)}var B,U=!1;if(Z)[B,U]=jJ(()=>z[J]);else B=z[J];if(B===void 0&&K!==void 0){if(B=H(),q){if(X)BQ(J);q(B)}}var M;if(X)M=()=>{var E=z[J];if(E===void 0)return H();return w=!0,E};else M=()=>{var E=z[J];if(E!==void 0)G=void 0;return E===void 0?G:E};if(X&&(Q&PQ)===0)return M;if(q){var P=z.$$legacy;return function(E,N){if(arguments.length>0){if(!X||!N||P||U)q(N?M():E);return E}return M()}}var O=!1,j=((Q&OQ)!==0?X8:D8)(()=>{return O=!1,M()});if(m)j.label=J;if(Z)Y(j);var T=J0;return function(E,N){if(arguments.length>0){let y=N?Y(j):X&&Z?x0(E):E;if(R(j,y),O=!0,G!==void 0)G=y;return E}if(x6&&O||(T.f&T1)!==0)return j.v;return Y(j)}}function gK(z){return new hK(z)}class hK{#z;#J;constructor(z){var J=new Map,Q=(X,Z)=>{var W=i6(Z,!1,!1);return J.set(X,W),W};let K=new Proxy({...z.props||{},$$events:{}},{get(X,Z){return Y(J.get(Z)??Q(Z,Reflect.get(X,Z)))},has(X,Z){if(Z===t8)return!0;return Y(J.get(Z)??Q(Z,Reflect.get(X,Z))),Reflect.has(X,Z)},set(X,Z,W){return R(J.get(Z)??Q(Z,W),W),Reflect.set(X,Z,W)}});if(this.#J=(z.hydrate?BJ:H5)(z.component,{target:z.target,anchor:z.anchor,props:K,context:z.context,intro:z.intro??!1,recover:z.recover}),!N1&&(!z?.props?.$$host||z.sync===!1))K8();this.#z=K.$$events;for(let X of Object.keys(this.#J)){if(X==="$set"||X==="$destroy"||X==="$on")continue;q1(this,X,{get(){return this.#J[X]},set(Z){this.#J[X]=Z},enumerable:!0})}this.#J.$set=(X)=>{Object.assign(K,X)},this.#J.$destroy=()=>{HJ(this.#J)}}$set(z){this.#J.$set(z)}$on(z,J){this.#z[z]=this.#z[z]||[];let Q=(...K)=>J.call(this,...K);return this.#z[z].push(Q),()=>{this.#z[z]=this.#z[z].filter((K)=>K!==Q)}}$destroy(){this.#J.$destroy()}}var x4;if(typeof HTMLElement==="function")x4=class extends HTMLElement{$$ctor;$$s;$$c;$$cn=!1;$$d={};$$r=!1;$$p_d={};$$l={};$$l_u=new Map;$$me;constructor(z,J,Q){super();if(this.$$ctor=z,this.$$s=J,Q)this.attachShadow({mode:"open"})}addEventListener(z,J,Q){if(this.$$l[z]=this.$$l[z]||[],this.$$l[z].push(J),this.$$c){let K=this.$$c.$on(z,J);this.$$l_u.set(J,K)}super.addEventListener(z,J,Q)}removeEventListener(z,J,Q){if(super.removeEventListener(z,J,Q),this.$$c){let K=this.$$l_u.get(J);if(K)K(),this.$$l_u.delete(J)}}async connectedCallback(){if(this.$$cn=!0,!this.$$c){let z=function(K){return(X)=>{let Z=document.createElement("slot");if(K!=="default")Z.name=K;V(X,Z)}};if(await Promise.resolve(),!this.$$cn||this.$$c)return;let J={},Q=O4(this);for(let K of this.$$s)if(K in Q)if(K==="default"&&!this.$$d.children)this.$$d.children=z(K),J.default=!0;else J[K]=z(K);for(let K of this.attributes){let X=this.$$g_p(K.name);if(!(X in this.$$d))this.$$d[X]=NJ(X,K.value,this.$$p_d,"toProp")}for(let K in this.$$p_d)if(!(K in this.$$d)&&this[K]!==void 0)this.$$d[K]=this[K],delete this[K];this.$$c=gK({component:this.$$ctor,target:this.shadowRoot||this,props:{...this.$$d,$$slots:J,$$host:this}}),this.$$me=JJ(()=>{w1(()=>{this.$$r=!0;for(let K of I9(this.$$c)){if(!this.$$p_d[K]?.reflect)continue;this.$$d[K]=this.$$c[K];let X=NJ(K,this.$$d[K],this.$$p_d,"toAttribute");if(X==null)this.removeAttribute(this.$$p_d[K].attribute||K);else this.setAttribute(this.$$p_d[K].attribute||K,X)}this.$$r=!1})});for(let K in this.$$l)for(let X of this.$$l[K]){let Z=this.$$c.$on(K,X);this.$$l_u.set(X,Z)}this.$$l={}}}attributeChangedCallback(z,J,Q){if(this.$$r)return;z=this.$$g_p(z),this.$$d[z]=NJ(z,Q,this.$$p_d,"toProp"),this.$$c?.$set({[z]:this.$$d[z]})}disconnectedCallback(){this.$$cn=!1,Promise.resolve().then(()=>{if(!this.$$cn&&this.$$c)this.$$c.$destroy(),this.$$me(),this.$$c=void 0})}$$g_p(z){return I9(this.$$p_d).find((J)=>this.$$p_d[J].attribute===z||!this.$$p_d[J].attribute&&J.toLowerCase()===z)||z}};function NJ(z,J,Q,K){let X=Q[z]?.type;if(J=X==="Boolean"&&typeof J!=="boolean"?J!=null:J,!K||!Q[z])return J;else if(K==="toAttribute")switch(X){case"Object":case"Array":return J==null?null:JSON.stringify(J);case"Boolean":return J?"":null;case"Number":return J==null?null:J;default:return J}else switch(X){case"Object":case"Array":return J&&JSON.parse(J);case"Boolean":return J;case"Number":return J!=null?+J:J;default:return J}}function O4(z){let J={};return z.childNodes.forEach((Q)=>{J[Q.slot||"default"]=!0}),J}if(m){let z=function(J){if(!(J in globalThis)){let Q;Object.defineProperty(globalThis,J,{configurable:!0,get:()=>{if(Q!==void 0)return Q;wQ(J)},set:(K)=>{Q=K}})}};z("$state"),z("$effect"),z("$derived"),z("$inspect"),z("$props"),z("$bindable")}class kJ{cache;prepareCache(){if(!this.cache){let z=localStorage.getItem("handleCache");this.cache=z?JSON.parse(z):{}}}saveCache(){localStorage.setItem("handleCache",JSON.stringify(this.cache))}getHandleDid(z){return this.prepareCache(),this.cache[z]}setHandleDid(z,J){this.prepareCache(),this.cache[z]=J,this.saveCache()}findHandleByDid(z){this.prepareCache();let J=Object.entries(this.cache).find((Q)=>Q[1]==z);return J?J[0]:void 0}}class o6 extends Error{code;json;constructor(z,J){super("APIError status "+z+` 47 + 48 + `+JSON.stringify(J));this.code=z,this.json=J}}class bJ extends Error{}class B8 extends Error{}class s5{host;config;user;sendAuthHeaders;autoManageTokens;constructor(z,J,Q){if(this.host=z,this.config=J||null,this.user=J?.user||null,this.sendAuthHeaders=!!this.user,this.autoManageTokens=!!this.user,Q)Object.assign(this,Q)}get baseURL(){if(this.host)return(this.host.includes("://")?this.host:`https://${this.host}`)+"/xrpc";else throw new bJ("Hostname not set")}get isLoggedIn(){return!!(this.user&&this.user.accessToken&&this.user.refreshToken&&this.user.did&&this.user.pdsEndpoint)}async getRequest(z,J,Q={}){let K=new URL(`${this.baseURL}/${z}`),X=Q&&"auth"in Q?Q.auth:this.sendAuthHeaders;if(this.autoManageTokens&&X===!0)await this.checkAccess();if(J)for(let G in J)if(J[G]instanceof Array)J[G].forEach((w)=>K.searchParams.append(G,w));else K.searchParams.append(G,J[G]);let Z=this.authHeaders(X);if(Q.headers)Object.assign(Z,Q.headers);let W=await fetch(K,{headers:Z,signal:Q.abortSignal??null});return await this.parseResponse(W)}async postRequest(z,J,Q={}){let K=`${this.baseURL}/${z}`,X=Q&&"auth"in Q?Q.auth:this.sendAuthHeaders;if(this.autoManageTokens&&X===!0)await this.checkAccess();let Z=this.authHeaders(X),W={method:"POST"};if(J)W.body=JSON.stringify(J),Z["Content-Type"]="application/json";if(Q.headers)Object.assign(Z,Q.headers);if(Q.abortSignal)W.signal=Q.abortSignal;W.headers=Z;let G=await fetch(K,W);return await this.parseResponse(G)}async fetchAll(z,J){if(!J||!J.field)throw new bJ("'field' option is required");let Q=[],K=J.params??{},X=this.sliceOptions(J,["auth","headers","abortSignal"]);for(;;){let Z=await this.getRequest(z,K,X),W=Z[J.field],G=Z.cursor;if(J.breakWhen){let w=J.breakWhen;if(W.some((H)=>w(H))){if(!J.keepLastPage)W=W.filter((H)=>!w(H));G=null}}if(Q=Q.concat(W),K.cursor=G,J.onPageLoad?.(W),!G)break}return Q}authHeaders(z){if(typeof z=="string")return{Authorization:`Bearer ${z}`};else if(z)if(this.user?.accessToken)return{Authorization:`Bearer ${this.user.accessToken}`};else throw new B8("Can't send auth headers, access token is missing");else return{}}sliceOptions(z,J){let Q={};for(let K of J)if(K in z)Q[K]=z[K];return Q}tokenExpirationTimestamp(z){let J=z.split(".");if(J.length!=3)throw new B8("Invalid access token format");let K=JSON.parse(atob(J[1])).exp;if(!(K&&typeof K=="number"&&K>0))throw new B8("Invalid token expiry data");return K*1000}isInvalidToken(z,J){return z.status==400&&!!J&&["InvalidToken","ExpiredToken"].includes(J.error)}async parseResponse(z){let J=await z.text(),Q=J.trim().length>0?JSON.parse(J):void 0;if(z.status>=200&&z.status<300)return Q;else throw new o6(z.status,Q)}requireUserConfig(){if(!this.config||!this.config.user)throw new B8("Missing user configuration object")}requireLoggedInUser(){if(this.requireUserConfig(),!this.isLoggedIn)throw new B8("Not logged in")}async checkAccess(){if(this.requireLoggedInUser(),this.tokenExpirationTimestamp(this.user.accessToken)<new Date().getTime()+60000)await this.performTokenRefresh()}async logIn(z,J){this.requireUserConfig();let Q={identifier:z,password:J},K=await this.postRequest("com.atproto.server.createSession",Q,{auth:!1});return this.saveTokens(K),K}async performTokenRefresh(){this.requireLoggedInUser(),console.log("Refreshing access tokenโ€ฆ");let z=await this.postRequest("com.atproto.server.refreshSession",null,{auth:this.user.refreshToken});return this.saveTokens(z),z}saveTokens(z){if(this.requireUserConfig(),this.user.accessToken=z.accessJwt,this.user.refreshToken=z.refreshJwt,this.user.did=z.did,z.didDoc?.service){let J=z.didDoc.service.find((Q)=>Q.id=="#atproto_pds");this.host=J.serviceEndpoint.replace("https://","")}this.user.pdsEndpoint=this.host,this.config.save()}resetTokens(){this.requireUserConfig(),delete this.user.accessToken,delete this.user.refreshToken,delete this.user.did,delete this.user.pdsEndpoint,this.config.save()}}class fK{repo;collection;rkey;constructor(z){if(!z.startsWith("at://"))throw new a6(`Not an at:// URI: ${z}`);let J=z.split("/");if(J.length!=5)throw new a6(`Invalid at:// URI: ${z}`);this.repo=J[2],this.collection=J[3],this.rkey=J[4]}}function m0(z){return new fK(z)}function L4(){return new IntersectionObserver((z,J)=>{for(let Q of z)if(Q.isIntersecting){let K=Q.target;K.removeAttribute("lazy"),J.unobserve(K)}},{rootMargin:"1000px 0px"})}var TJ=L4();/*! @license DOMPurify 3.3.0 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.3.0/LICENSE */var{entries:cK,setPrototypeOf:mK,isFrozen:P4,getPrototypeOf:I4,getOwnPropertyDescriptor:S4}=Object,{freeze:g1,seal:G6,create:mJ}=Object,{apply:$J,construct:uJ}=typeof Reflect<"u"&&Reflect;if(!g1)g1=function(J){return J};if(!G6)G6=function(J){return J};if(!$J)$J=function(J,Q){for(var K=arguments.length,X=Array(K>2?K-2:0),Z=2;Z<K;Z++)X[Z-2]=arguments[Z];return J.apply(Q,X)};if(!uJ)uJ=function(J){for(var Q=arguments.length,K=Array(Q>1?Q-1:0),X=1;X<Q;X++)K[X-1]=arguments[X];return new J(...K)};var az=h1(Array.prototype.forEach),D4=h1(Array.prototype.lastIndexOf),$K=h1(Array.prototype.pop),o5=h1(Array.prototype.push),R4=h1(Array.prototype.splice),nz=h1(String.prototype.toLowerCase),EJ=h1(String.prototype.toString),yJ=h1(String.prototype.match),a5=h1(String.prototype.replace),j4=h1(String.prototype.indexOf),A4=h1(String.prototype.trim),L6=h1(Object.prototype.hasOwnProperty),v1=h1(RegExp.prototype.test),r5=N4(TypeError);function h1(z){return function(J){if(J instanceof RegExp)J.lastIndex=0;for(var Q=arguments.length,K=Array(Q>1?Q-1:0),X=1;X<Q;X++)K[X-1]=arguments[X];return $J(z,J,K)}}function N4(z){return function(){for(var J=arguments.length,Q=Array(J),K=0;K<J;K++)Q[K]=arguments[K];return uJ(z,Q)}}function P0(z,J){let Q=arguments.length>2&&arguments[2]!==void 0?arguments[2]:nz;if(mK)mK(z,null);let K=J.length;while(K--){let X=J[K];if(typeof X==="string"){let Z=Q(X);if(Z!==X){if(!P4(J))J[K]=Z;X=Z}}z[X]=!0}return z}function k4(z){for(let J=0;J<z.length;J++)if(!L6(z,J))z[J]=null;return z}function r6(z){let J=mJ(null);for(let[Q,K]of cK(z))if(L6(z,Q))if(Array.isArray(K))J[Q]=k4(K);else if(K&&typeof K==="object"&&K.constructor===Object)J[Q]=r6(K);else J[Q]=K;return J}function n5(z,J){while(z!==null){let K=S4(z,J);if(K){if(K.get)return h1(K.get);if(typeof K.value==="function")return h1(K.value)}z=I4(z)}function Q(){return null}return Q}var uK=g1(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","search","section","select","shadow","slot","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),vJ=g1(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","enterkeyhint","exportparts","filter","font","g","glyph","glyphref","hkern","image","inputmode","line","lineargradient","marker","mask","metadata","mpath","part","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),gJ=g1(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),b4=g1(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),hJ=g1(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),T4=g1(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),lK=g1(["#text"]),pK=g1(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","exportparts","face","for","headers","height","hidden","high","href","hreflang","id","inert","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","part","pattern","placeholder","playsinline","popover","popovertarget","popovertargetaction","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","slot","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","wrap","xmlns","slot"]),fJ=g1(["accent-height","accumulate","additive","alignment-baseline","amplitude","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dur","edgemode","elevation","end","exponent","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","intercept","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","mask-type","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","slope","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","tablevalues","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-rendering","textlength","type","u1","u2","unicode","values","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),dK=g1(["accent","accentunder","align","bevelled","close","columnsalign","columnlines","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lspace","lquote","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),rz=g1(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),E4=G6(/\{\{[\w\W]*|[\w\W]*\}\}/gm),y4=G6(/<%[\w\W]*|[\w\W]*%>/gm),v4=G6(/\$\{[\w\W]*/gm),g4=G6(/^data-[\-\w.\u00B7-\uFFFF]+$/),h4=G6(/^aria-[\-\w]+$/),sK=G6(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),f4=G6(/^(?:\w+script|data):/i),m4=G6(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),oK=G6(/^html$/i),$4=G6(/^[a-z][.\w]*(-[.\w]+)+$/i),iK=Object.freeze({__proto__:null,ARIA_ATTR:h4,ATTR_WHITESPACE:m4,CUSTOM_ELEMENT:$4,DATA_ATTR:g4,DOCTYPE_NAME:oK,ERB_EXPR:y4,IS_ALLOWED_URI:sK,IS_SCRIPT_OR_DATA:f4,MUSTACHE_EXPR:E4,TMPLIT_EXPR:v4}),t5={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},u4=function(){return typeof window>"u"?null:window},l4=function(J,Q){if(typeof J!=="object"||typeof J.createPolicy!=="function")return null;let K=null,X="data-tt-policy-suffix";if(Q&&Q.hasAttribute(X))K=Q.getAttribute(X);let Z="dompurify"+(K?"#"+K:"");try{return J.createPolicy(Z,{createHTML(W){return W},createScriptURL(W){return W}})}catch(W){return console.warn("TrustedTypes policy "+Z+" could not be created."),null}},_K=function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}};function aK(){let z=arguments.length>0&&arguments[0]!==void 0?arguments[0]:u4(),J=(K0)=>aK(K0);if(J.version="3.3.0",J.removed=[],!z||!z.document||z.document.nodeType!==t5.document||!z.Element)return J.isSupported=!1,J;let{document:Q}=z,K=Q,X=K.currentScript,{DocumentFragment:Z,HTMLTemplateElement:W,Node:G,Element:w,NodeFilter:H,NamedNodeMap:q=z.NamedNodeMap||z.MozNamedAttrMap,HTMLFormElement:F,DOMParser:B,trustedTypes:U}=z,M=w.prototype,P=n5(M,"cloneNode"),O=n5(M,"remove"),j=n5(M,"nextSibling"),T=n5(M,"childNodes"),E=n5(M,"parentNode");if(typeof W==="function"){let K0=Q.createElement("template");if(K0.content&&K0.content.ownerDocument)Q=K0.content.ownerDocument}let N,y="",{implementation:b,createNodeIterator:A,createDocumentFragment:k,getElementsByTagName:$}=Q,{importNode:v}=K,l=_K();J.isSupported=typeof cK==="function"&&typeof E==="function"&&b&&b.createHTMLDocument!==void 0;let{MUSTACHE_EXPR:n,ERB_EXPR:o,TMPLIT_EXPR:a,DATA_ATTR:X0,ARIA_ATTR:Q0,IS_SCRIPT_OR_DATA:U0,ATTR_WHITESPACE:D0,CUSTOM_ELEMENT:M0}=iK,{IS_ALLOWED_URI:v0}=iK,s=null,V0=P0({},[...uK,...vJ,...gJ,...hJ,...lK]),Y0=null,e=P0({},[...pK,...fJ,...dK,...rz]),t=Object.seal(mJ(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),G0=null,O0=null,H0=Object.seal(mJ(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}})),F0=!0,u0=!0,n0=!1,k0=!0,z6=!1,t6=!0,H6=!1,u8=!1,l8=!1,e6=!1,p8=!1,wz=!1,E7=!0,y7=!1,MZ="user-content-",q9=!0,A5=!1,d8={},i8=null,v7=P0({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]),g7=null,h7=P0({},["audio","video","img","source","image","track"]),M9=null,f7=P0({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),Uz="http://www.w3.org/1998/Math/MathML",Vz="http://www.w3.org/2000/svg",g6="http://www.w3.org/1999/xhtml",_8=g6,C9=!1,x9=null,CZ=P0({},[Uz,Vz,g6],EJ),Fz=P0({},["mi","mo","mn","ms","mtext"]),qz=P0({},["annotation-xml"]),xZ=P0({},["title","style","font","a","script"]),N5=null,OZ=["application/xhtml+xml","text/html"],LZ="text/html",G1=null,c8=null,PZ=Q.createElement("form"),m7=function(I){return I instanceof RegExp||I instanceof Function},O9=function(){let I=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(c8&&c8===I)return;if(!I||typeof I!=="object")I={};if(I=r6(I),N5=OZ.indexOf(I.PARSER_MEDIA_TYPE)===-1?LZ:I.PARSER_MEDIA_TYPE,G1=N5==="application/xhtml+xml"?EJ:nz,s=L6(I,"ALLOWED_TAGS")?P0({},I.ALLOWED_TAGS,G1):V0,Y0=L6(I,"ALLOWED_ATTR")?P0({},I.ALLOWED_ATTR,G1):e,x9=L6(I,"ALLOWED_NAMESPACES")?P0({},I.ALLOWED_NAMESPACES,EJ):CZ,M9=L6(I,"ADD_URI_SAFE_ATTR")?P0(r6(f7),I.ADD_URI_SAFE_ATTR,G1):f7,g7=L6(I,"ADD_DATA_URI_TAGS")?P0(r6(h7),I.ADD_DATA_URI_TAGS,G1):h7,i8=L6(I,"FORBID_CONTENTS")?P0({},I.FORBID_CONTENTS,G1):v7,G0=L6(I,"FORBID_TAGS")?P0({},I.FORBID_TAGS,G1):r6({}),O0=L6(I,"FORBID_ATTR")?P0({},I.FORBID_ATTR,G1):r6({}),d8=L6(I,"USE_PROFILES")?I.USE_PROFILES:!1,F0=I.ALLOW_ARIA_ATTR!==!1,u0=I.ALLOW_DATA_ATTR!==!1,n0=I.ALLOW_UNKNOWN_PROTOCOLS||!1,k0=I.ALLOW_SELF_CLOSE_IN_ATTR!==!1,z6=I.SAFE_FOR_TEMPLATES||!1,t6=I.SAFE_FOR_XML!==!1,H6=I.WHOLE_DOCUMENT||!1,e6=I.RETURN_DOM||!1,p8=I.RETURN_DOM_FRAGMENT||!1,wz=I.RETURN_TRUSTED_TYPE||!1,l8=I.FORCE_BODY||!1,E7=I.SANITIZE_DOM!==!1,y7=I.SANITIZE_NAMED_PROPS||!1,q9=I.KEEP_CONTENT!==!1,A5=I.IN_PLACE||!1,v0=I.ALLOWED_URI_REGEXP||sK,_8=I.NAMESPACE||g6,Fz=I.MATHML_TEXT_INTEGRATION_POINTS||Fz,qz=I.HTML_INTEGRATION_POINTS||qz,t=I.CUSTOM_ELEMENT_HANDLING||{},I.CUSTOM_ELEMENT_HANDLING&&m7(I.CUSTOM_ELEMENT_HANDLING.tagNameCheck))t.tagNameCheck=I.CUSTOM_ELEMENT_HANDLING.tagNameCheck;if(I.CUSTOM_ELEMENT_HANDLING&&m7(I.CUSTOM_ELEMENT_HANDLING.attributeNameCheck))t.attributeNameCheck=I.CUSTOM_ELEMENT_HANDLING.attributeNameCheck;if(I.CUSTOM_ELEMENT_HANDLING&&typeof I.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements==="boolean")t.allowCustomizedBuiltInElements=I.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements;if(z6)u0=!1;if(p8)e6=!0;if(d8){if(s=P0({},lK),Y0=[],d8.html===!0)P0(s,uK),P0(Y0,pK);if(d8.svg===!0)P0(s,vJ),P0(Y0,fJ),P0(Y0,rz);if(d8.svgFilters===!0)P0(s,gJ),P0(Y0,fJ),P0(Y0,rz);if(d8.mathMl===!0)P0(s,hJ),P0(Y0,dK),P0(Y0,rz)}if(I.ADD_TAGS)if(typeof I.ADD_TAGS==="function")H0.tagCheck=I.ADD_TAGS;else{if(s===V0)s=r6(s);P0(s,I.ADD_TAGS,G1)}if(I.ADD_ATTR)if(typeof I.ADD_ATTR==="function")H0.attributeCheck=I.ADD_ATTR;else{if(Y0===e)Y0=r6(Y0);P0(Y0,I.ADD_ATTR,G1)}if(I.ADD_URI_SAFE_ATTR)P0(M9,I.ADD_URI_SAFE_ATTR,G1);if(I.FORBID_CONTENTS){if(i8===v7)i8=r6(i8);P0(i8,I.FORBID_CONTENTS,G1)}if(q9)s["#text"]=!0;if(H6)P0(s,["html","head","body"]);if(s.table)P0(s,["tbody"]),delete G0.tbody;if(I.TRUSTED_TYPES_POLICY){if(typeof I.TRUSTED_TYPES_POLICY.createHTML!=="function")throw r5('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if(typeof I.TRUSTED_TYPES_POLICY.createScriptURL!=="function")throw r5('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');N=I.TRUSTED_TYPES_POLICY,y=N.createHTML("")}else{if(N===void 0)N=l4(U,X);if(N!==null&&typeof y==="string")y=N.createHTML("")}if(g1)g1(I);c8=I},$7=P0({},[...vJ,...gJ,...b4]),u7=P0({},[...hJ,...T4]),IZ=function(I){let p=E(I);if(!p||!p.tagName)p={namespaceURI:_8,tagName:"template"};let z0=nz(I.tagName),g0=nz(p.tagName);if(!x9[I.namespaceURI])return!1;if(I.namespaceURI===Vz){if(p.namespaceURI===g6)return z0==="svg";if(p.namespaceURI===Uz)return z0==="svg"&&(g0==="annotation-xml"||Fz[g0]);return Boolean($7[z0])}if(I.namespaceURI===Uz){if(p.namespaceURI===g6)return z0==="math";if(p.namespaceURI===Vz)return z0==="math"&&qz[g0];return Boolean(u7[z0])}if(I.namespaceURI===g6){if(p.namespaceURI===Vz&&!qz[g0])return!1;if(p.namespaceURI===Uz&&!Fz[g0])return!1;return!u7[z0]&&(xZ[z0]||!$7[z0])}if(N5==="application/xhtml+xml"&&x9[I.namespaceURI])return!0;return!1},I6=function(I){o5(J.removed,{element:I});try{E(I).removeChild(I)}catch(p){O(I)}},F8=function(I,p){try{o5(J.removed,{attribute:p.getAttributeNode(I),from:p})}catch(z0){o5(J.removed,{attribute:null,from:p})}if(p.removeAttribute(I),I==="is")if(e6||p8)try{I6(p)}catch(z0){}else try{p.setAttribute(I,"")}catch(z0){}},l7=function(I){let p=null,z0=null;if(l8)I="<remove></remove>"+I;else{let t0=yJ(I,/^[\r\n\t ]+/);z0=t0&&t0[0]}if(N5==="application/xhtml+xml"&&_8===g6)I='<html xmlns="http://www.w3.org/1999/xhtml"><head></head><body>'+I+"</body></html>";let g0=N?N.createHTML(I):I;if(_8===g6)try{p=new B().parseFromString(g0,N5)}catch(t0){}if(!p||!p.documentElement){p=b.createDocument(_8,"template",null);try{p.documentElement.innerHTML=C9?y:g0}catch(t0){}}let S1=p.body||p.documentElement;if(I&&z0)S1.insertBefore(Q.createTextNode(z0),S1.childNodes[0]||null);if(_8===g6)return $.call(p,H6?"html":"body")[0];return H6?p.documentElement:S1},p7=function(I){return A.call(I.ownerDocument||I,I,H.SHOW_ELEMENT|H.SHOW_COMMENT|H.SHOW_TEXT|H.SHOW_PROCESSING_INSTRUCTION|H.SHOW_CDATA_SECTION,null)},L9=function(I){return I instanceof F&&(typeof I.nodeName!=="string"||typeof I.textContent!=="string"||typeof I.removeChild!=="function"||!(I.attributes instanceof q)||typeof I.removeAttribute!=="function"||typeof I.setAttribute!=="function"||typeof I.namespaceURI!=="string"||typeof I.insertBefore!=="function"||typeof I.hasChildNodes!=="function")},d7=function(I){return typeof G==="function"&&I instanceof G};function h6(K0,I,p){az(K0,(z0)=>{z0.call(J,I,p,c8)})}let i7=function(I){let p=null;if(h6(l.beforeSanitizeElements,I,null),L9(I))return I6(I),!0;let z0=G1(I.nodeName);if(h6(l.uponSanitizeElement,I,{tagName:z0,allowedTags:s}),t6&&I.hasChildNodes()&&!d7(I.firstElementChild)&&v1(/<[/\w!]/g,I.innerHTML)&&v1(/<[/\w!]/g,I.textContent))return I6(I),!0;if(I.nodeType===t5.progressingInstruction)return I6(I),!0;if(t6&&I.nodeType===t5.comment&&v1(/<[/\w]/g,I.data))return I6(I),!0;if(!(H0.tagCheck instanceof Function&&H0.tagCheck(z0))&&(!s[z0]||G0[z0])){if(!G0[z0]&&c7(z0)){if(t.tagNameCheck instanceof RegExp&&v1(t.tagNameCheck,z0))return!1;if(t.tagNameCheck instanceof Function&&t.tagNameCheck(z0))return!1}if(q9&&!i8[z0]){let g0=E(I)||I.parentNode,S1=T(I)||I.childNodes;if(S1&&g0){let t0=S1.length;for(let f1=t0-1;f1>=0;--f1){let f6=P(S1[f1],!0);f6.__removalCount=(I.__removalCount||0)+1,g0.insertBefore(f6,j(I))}}}return I6(I),!0}if(I instanceof w&&!IZ(I))return I6(I),!0;if((z0==="noscript"||z0==="noembed"||z0==="noframes")&&v1(/<\/no(script|embed|frames)/i,I.innerHTML))return I6(I),!0;if(z6&&I.nodeType===t5.text){if(p=I.textContent,az([n,o,a],(g0)=>{p=a5(p,g0," ")}),I.textContent!==p)o5(J.removed,{element:I.cloneNode()}),I.textContent=p}return h6(l.afterSanitizeElements,I,null),!1},_7=function(I,p,z0){if(E7&&(p==="id"||p==="name")&&((z0 in Q)||(z0 in PZ)))return!1;if(u0&&!O0[p]&&v1(X0,p));else if(F0&&v1(Q0,p));else if(H0.attributeCheck instanceof Function&&H0.attributeCheck(p,I));else if(!Y0[p]||O0[p])if(c7(I)&&(t.tagNameCheck instanceof RegExp&&v1(t.tagNameCheck,I)||t.tagNameCheck instanceof Function&&t.tagNameCheck(I))&&(t.attributeNameCheck instanceof RegExp&&v1(t.attributeNameCheck,p)||t.attributeNameCheck instanceof Function&&t.attributeNameCheck(p,I))||p==="is"&&t.allowCustomizedBuiltInElements&&(t.tagNameCheck instanceof RegExp&&v1(t.tagNameCheck,z0)||t.tagNameCheck instanceof Function&&t.tagNameCheck(z0)));else return!1;else if(M9[p]);else if(v1(v0,a5(z0,D0,"")));else if((p==="src"||p==="xlink:href"||p==="href")&&I!=="script"&&j4(z0,"data:")===0&&g7[I]);else if(n0&&!v1(U0,a5(z0,D0,"")));else if(z0)return!1;return!0},c7=function(I){return I!=="annotation-xml"&&yJ(I,M0)},s7=function(I){h6(l.beforeSanitizeAttributes,I,null);let{attributes:p}=I;if(!p||L9(I))return;let z0={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Y0,forceKeepAttr:void 0},g0=p.length;while(g0--){let S1=p[g0],{name:t0,namespaceURI:f1,value:f6}=S1,s8=G1(t0),P9=f6,F1=t0==="value"?P9:A4(P9);if(z0.attrName=s8,z0.attrValue=F1,z0.keepAttr=!0,z0.forceKeepAttr=void 0,h6(l.uponSanitizeAttribute,I,z0),F1=z0.attrValue,y7&&(s8==="id"||s8==="name"))F8(t0,I),F1=MZ+F1;if(t6&&v1(/((--!?|])>)|<\/(style|title|textarea)/i,F1)){F8(t0,I);continue}if(s8==="attributename"&&yJ(F1,"href")){F8(t0,I);continue}if(z0.forceKeepAttr)continue;if(!z0.keepAttr){F8(t0,I);continue}if(!k0&&v1(/\/>/i,F1)){F8(t0,I);continue}if(z6)az([n,o,a],(a7)=>{F1=a5(F1,a7," ")});let o7=G1(I.nodeName);if(!_7(o7,s8,F1)){F8(t0,I);continue}if(N&&typeof U==="object"&&typeof U.getAttributeType==="function")if(f1);else switch(U.getAttributeType(o7,s8)){case"TrustedHTML":{F1=N.createHTML(F1);break}case"TrustedScriptURL":{F1=N.createScriptURL(F1);break}}if(F1!==P9)try{if(f1)I.setAttributeNS(f1,t0,F1);else I.setAttribute(t0,F1);if(L9(I))I6(I);else $K(J.removed)}catch(a7){F8(t0,I)}}h6(l.afterSanitizeAttributes,I,null)},SZ=function K0(I){let p=null,z0=p7(I);h6(l.beforeSanitizeShadowDOM,I,null);while(p=z0.nextNode())if(h6(l.uponSanitizeShadowNode,p,null),i7(p),s7(p),p.content instanceof Z)K0(p.content);h6(l.afterSanitizeShadowDOM,I,null)};return J.sanitize=function(K0){let I=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},p=null,z0=null,g0=null,S1=null;if(C9=!K0,C9)K0="<!-->";if(typeof K0!=="string"&&!d7(K0))if(typeof K0.toString==="function"){if(K0=K0.toString(),typeof K0!=="string")throw r5("dirty is not a string, aborting")}else throw r5("toString is not a function");if(!J.isSupported)return K0;if(!u8)O9(I);if(J.removed=[],typeof K0==="string")A5=!1;if(A5){if(K0.nodeName){let f6=G1(K0.nodeName);if(!s[f6]||G0[f6])throw r5("root node is forbidden and cannot be sanitized in-place")}}else if(K0 instanceof G)if(p=l7("<!---->"),z0=p.ownerDocument.importNode(K0,!0),z0.nodeType===t5.element&&z0.nodeName==="BODY")p=z0;else if(z0.nodeName==="HTML")p=z0;else p.appendChild(z0);else{if(!e6&&!z6&&!H6&&K0.indexOf("<")===-1)return N&&wz?N.createHTML(K0):K0;if(p=l7(K0),!p)return e6?null:wz?y:""}if(p&&l8)I6(p.firstChild);let t0=p7(A5?K0:p);while(g0=t0.nextNode())if(i7(g0),s7(g0),g0.content instanceof Z)SZ(g0.content);if(A5)return K0;if(e6){if(p8){S1=k.call(p.ownerDocument);while(p.firstChild)S1.appendChild(p.firstChild)}else S1=p;if(Y0.shadowroot||Y0.shadowrootmode)S1=v.call(K,S1,!0);return S1}let f1=H6?p.outerHTML:p.innerHTML;if(H6&&s["!doctype"]&&p.ownerDocument&&p.ownerDocument.doctype&&p.ownerDocument.doctype.name&&v1(oK,p.ownerDocument.doctype.name))f1="<!DOCTYPE "+p.ownerDocument.doctype.name+`> 49 + `+f1;if(z6)az([n,o,a],(f6)=>{f1=a5(f1,f6," ")});return N&&wz?N.createHTML(f1):f1},J.setConfig=function(){let K0=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};O9(K0),u8=!0},J.clearConfig=function(){c8=null,u8=!1},J.isValidAttribute=function(K0,I,p){if(!c8)O9({});let z0=G1(K0),g0=G1(I);return _7(z0,g0,p)},J.addHook=function(K0,I){if(typeof I!=="function")return;o5(l[K0],I)},J.removeHook=function(K0,I){if(I!==void 0){let p=D4(l[K0],I);return p===-1?void 0:R4(l[K0],p,1)[0]}return $K(l[K0])},J.removeHooks=function(K0){l[K0]=[]},J.removeAllHooks=function(){l=_K()},J}var rK=aK();function E8(z){return tz(z,"day")}function tz(z,J,Q){if(z==1)return`1 ${J}`;else return Q=Q??`${J}s`,`${z} ${Q}`}function nK(z){return rK.sanitize(z,{ALLOWED_TAGS:["a","b","blockquote","br","code","dd","del","div","dl","dt","em","font","h1","h2","h3","h4","h5","h6","hr","i","li","ol","p","q","pre","s","span","strong","sub","sup","u","wbr","#text"],ALLOWED_ATTR:["align","alt","class","clear","color","dir","href","lang","rel","title","translate"]})}function tK(z,J){if(z.length<=J)return z;else return z.slice(0,J-1)+"โ€ฆ"}function y8(z){if(z===void 0||z===null||typeof z=="number")return z;else return parseInt(z,10)}function i1(z){let J=z.reason?z.reason.indexedAt:z.post.record.createdAt;return Date.parse(J)}function ez(z){try{return new URL(z),!0}catch(J){return console.error("Invalid URL: "+J),!1}}function eK(z,J){return z.getDate()==J.getDate()&&z.getMonth()==J.getMonth()&&z.getFullYear()==J.getFullYear()}function H8(z){console.log(z),alert(z)}class _1{constructor(z,J){this.data=z,Object.assign(this,J??{})}get uri(){return this.data.uri}get cid(){return this.data.cid}get rkey(){return m0(this.uri).rkey}get type(){return this.data.$type}}class e5 extends _1{constructor(z){super(z);this.author=z.creator}get title(){return this.data.displayName}get description(){return this.data.description}get likeCount(){return y8(this.data.likeCount)}get avatar(){return this.data.avatar}}class zz extends _1{constructor(z){super(z);this.author=z.creator}get title(){return this.data.name}get purpose(){return this.data.purpose}get description(){return this.data.description}get avatar(){return this.data.avatar}}class Jz extends _1{constructor(z){super(z);this.author=z.creator}get title(){return this.data.record.name}get description(){return this.data.record.description}}class W1{json;static parseInlineEmbed(z){switch(z.$type){case"app.bsky.embed.record#view":return new q5(z);case"app.bsky.embed.recordWithMedia#view":return new M5(z);case"app.bsky.embed.images#view":return new X9(z);case"app.bsky.embed.external#view":return new C5(z);case"app.bsky.embed.video#view":return new x5(z);default:if(location.protocol=="file:")throw new F5(`Unexpected embed type: ${z.$type}`);else return console.warn("Unexpected embed type:",z.$type),new W1(z)}}static parseRawEmbed(z){switch(z.$type){case"app.bsky.embed.record":return new K9(z);case"app.bsky.embed.recordWithMedia":return new Z9(z);case"app.bsky.embed.images":return new z9(z);case"app.bsky.embed.external":return new J9(z);case"app.bsky.embed.video":return new Q9(z);default:if(location.protocol=="file:")throw new F5(`Unexpected embed type: ${z.$type}`);else return console.warn("Unexpected embed type:",z.$type),new W1(z)}}constructor(z){this.json=z}get type(){return this.json.$type}}class z9 extends W1{images;constructor(z){super(z);this.images=z.images}}class J9 extends W1{url;title;thumb;constructor(z){super(z);this.url=z.external.uri,this.title=z.external.title,this.thumb=z.external.thumb}}class Q9 extends W1{video;constructor(z){super(z);this.video=z.video}}class K9 extends W1{record;constructor(z){super(z);this.record=new _1(z.record)}}class Z9 extends W1{record;media;constructor(z){super(z);this.record=new _1(z.record.record),this.media=W1.parseRawEmbed(z.media)}}class q5 extends W1{record;constructor(z){super(z);this.record=lJ(z.record)}}class M5 extends W1{record;media;constructor(z){super(z);this.record=lJ(z.record.record),this.media=W1.parseInlineEmbed(z.media)}}class C5 extends W1{url;title;description;thumb;constructor(z){super(z);this.url=z.external.uri,this.title=z.external.title,this.description=z.external.description,this.thumb=z.external.thumb}}class X9 extends W1{images;constructor(z){super(z);this.images=z.images}}class x5 extends W1{playlistURL;alt;constructor(z){super(z);this.playlistURL=z.playlist,this.alt=z.alt}}class F5 extends Error{constructor(z){super(z)}}class v8 extends _1{get didLinkToAuthor(){let{repo:z}=m0(this.uri);return`https://bsky.app/profile/${z}`}}function w8(z,J=null,Q=0,K=0){switch(z.$type){case"app.bsky.feed.defs#threadViewPost":let X=new S0(z.post,{level:Q,absoluteLevel:K});if(X.pageRoot=J??X,z.replies){let Z=z.replies.map((W)=>w8(W,X.pageRoot,Q+1,K+1));X.setReplies(Z)}if(K<=0&&z.parent)X.parent=w8(z.parent,X.pageRoot,Q-1,K-1);return X;case"app.bsky.feed.defs#notFoundPost":return new P6(z);case"app.bsky.feed.defs#blockedPost":return new y6(z);default:throw new F5(`Unexpected record type: ${z.$type}`)}}function lJ(z){switch(z.$type){case"app.bsky.embed.record#viewRecord":return new S0(z,{isEmbed:!0});case"app.bsky.embed.record#viewNotFound":return new P6(z);case"app.bsky.embed.record#viewBlocked":return new y6(z);case"app.bsky.embed.record#viewDetached":return new O5(z);case"app.bsky.feed.defs#generatorView":return new e5(z);case"app.bsky.graph.defs#listView":return new zz(z);case"app.bsky.graph.defs#starterPackViewBasic":return new Jz(z);default:return console.warn("Unknown record type:",z.$type),new _1(z)}}function JZ(z){let J=new S0(z.post);if(z.reply){if(J.parent=zZ(z.reply.parent),J.threadRoot=zZ(z.reply.root),z.reply.grandparentAuthor)J.grandparentAuthor=z.reply.grandparentAuthor}if(z.reason)J.reason=z.reason;return J}function zZ(z){switch(z.$type){case"app.bsky.feed.defs#postView":return new S0(z);case"app.bsky.feed.defs#notFoundPost":return new P6(z);case"app.bsky.feed.defs#blockedPost":return new y6(z);default:throw new F5(`Unexpected record type: ${z.$type}`)}}class S0 extends v8{parent;threadRoot;pageRoot;replies;grandparentAuthor;level;absoluteLevel;reason;isEmbed;constructor(z,J){super(z);if(Object.assign(this,J??{}),this.absoluteLevel===0)this.pageRoot=this;if(this.record=this.isPostView?z.record:z.value,this.isPostView&&z.embed)this.embed=W1.parseInlineEmbed(z.embed);else if(this.isEmbed&&z.embeds&&z.embeds[0])this.embed=W1.parseInlineEmbed(z.embeds[0]);else if(this.record.embed)this.embed=W1.parseRawEmbed(this.record.embed);if(this.author=this.author??z.author,this.replies=[],this.viewerData=z.viewer,this.viewerLike=z.viewer?.like,this.author)N0.cacheProfile(this.author)}updateDataFromPost(z){this.record=z.record,this.embed=z.embed,this.author=z.author,this.viewerData=z.viewerData,this.viewerLike=z.viewerLike,this.level=z.level,this.absoluteLevel=z.absoluteLevel,this.setReplies(z.replies)}setReplies(z){this.replies=z,this.replies.sort(this.sortReplies.bind(this))}sortReplies(z,J){if(z instanceof S0&&J instanceof S0)if(z.author.did==this.author.did&&J.author.did!=this.author.did)return-1;else if(z.author.did!=this.author.did&&J.author.did==this.author.did)return 1;else if(z.text!="\uD83D\uDCCC"&&J.text=="\uD83D\uDCCC")return-1;else if(z.text=="\uD83D\uDCCC"&&J.text!="\uD83D\uDCCC")return 1;else if(z.createdAt.getTime()<J.createdAt.getTime())return-1;else if(z.createdAt.getTime()>J.createdAt.getTime())return 1;else return 0;else if(z instanceof S0)return-1;else if(J instanceof S0)return 1;else return 0}get isPostView(){return!this.isEmbed}get isFediPost(){return this.author?.handle.endsWith(".ap.brid.gy")}get originalFediContent(){return this.record.bridgyOriginalText}get originalFediURL(){return this.record.bridgyOriginalUrl}get isPageRoot(){return this.pageRoot===this}get authorFediHandle(){if(this.isFediPost)return this.author.handle.replace(/\.ap\.brid\.gy$/,"").replace(".","@");else throw"Not a Fedi post"}get hasValidHandle(){return this.author.handle!="handle.invalid"}get authorDisplayName(){if(this.author.displayName)return this.author.displayName.trim();else if(this.author.handle.endsWith(".bsky.social"))return this.author.handle.replace(/\.bsky\.social$/,"");else return this.author.handle}get linkToAuthor(){return"https://bsky.app/profile/"+(this.hasValidHandle?this.author.handle:this.author.did)}get linkToPost(){return this.linkToAuthor+"/post/"+this.rkey}get text(){return this.record.text}get lowercaseText(){if(!this._lowercaseText)this._lowercaseText=this.record.text.toLowerCase();return this._lowercaseText}get facets(){return this.record.facets}get tags(){return this.record.tags}get createdAt(){return new Date(this.record.createdAt)}get likeCount(){return y8(this.data.likeCount)}get replyCount(){return y8(this.data.replyCount)}get quoteCount(){return y8(this.data.quoteCount)}get hasMoreReplies(){return this.replyCount!==void 0&&this.replyCount>this.replies.length&&this.replies.length===0&&(this.level!==void 0&&this.level>4)}get hasHiddenReplies(){return this.replyCount!==void 0&&this.replyCount>this.replies.length&&(this.replies.length>0||this.level!==void 0&&this.level<=4)}get isRestrictingReplies(){return!!(this.data.threadgate&&this.data.threadgate.record.allow)}get repostCount(){return y8(this.data.repostCount)}get liked(){return this.viewerLike!==void 0}get muted(){return this.author.viewer?.muted}get muteList(){return this.author.viewer?.mutedByList?.name}get hasViewerInfo(){return this.viewerData!==void 0}get parentReference(){return this.record.reply?.parent&&new _1(this.record.reply?.parent)}get rootReference(){return this.record.reply?.root&&new _1(this.record.reply?.root)}}class y6 extends v8{constructor(z){super(z);this.author=z.author}get blocksUser(){return!!this.author.viewer?.blocking}get blockedByUser(){return this.author.viewer?.blockedBy}}class P6 extends v8{}class O5 extends v8{}class pJ extends Error{}class a6 extends Error{constructor(z){super(z)}}class Kz extends Error{originalError;constructor(z){super(z.message);this.originalError=z}}class e1 extends s5{handleCache;profiles;constructor(z,J,Q){super(z,J,Q);this.handleCache=new kJ,this.profiles={}}cacheProfile(z){this.profiles[z.did]=z,this.profiles[z.handle]=z,this.handleCache.setHandleDid(z.handle,z.did)}async fetchHandleForDid(z){let J=this.handleCache.findHandleByDid(z);if(J)return J;else return(await this.loadUserProfile(z)).handle}async resolveHandle(z){let J=this.handleCache.getHandleDid(z);if(J)return J;else{let Q=await this.getRequest("com.atproto.identity.resolveHandle",{handle:z},{auth:!1}),K=Q.did;if(K)return this.handleCache.setHandleDid(z,K),K;else throw new pJ("Missing DID in response: "+JSON.stringify(Q))}}async loadThreadByURL(z){let{user:J,post:Q}=Qz(z);return await this.loadThreadById(J,Q)}async loadThreadById(z,J){let K=`at://${z.startsWith("did:")?z:await this.resolveHandle(z)}/app.bsky.feed.post/${J}`;return await this.loadThreadByAtURI(K)}async loadThreadByAtURI(z){return await this.getRequest("app.bsky.feed.getPostThread",{uri:z,depth:10})}async loadUserProfile(z){if(this.profiles[z])return this.profiles[z];else{let J=await this.getRequest("app.bsky.actor.getProfile",{actor:z});return this.cacheProfile(J),J}}async autocompleteUsers(z){return(await this.getRequest("app.bsky.actor.searchActorsTypeahead",{q:z})).actors}async getReplies(z){return(await this.getRequest("blue.feeds.post.getReplies",{uri:z})).replies}async getQuoteCount(z){return(await this.getRequest("blue.feeds.post.getQuoteCount",{uri:z})).quoteCount}async getQuotes(z,J){let Q;if(z.startsWith("at://"))Q=z;else{let{user:X,post:Z}=Qz(z);Q=`at://${X.startsWith("did:")?X:await U8.resolveHandle(X)}/app.bsky.feed.post/${Z}`}let K={uri:Q};if(J)K.cursor=J;return await this.getRequest("blue.feeds.post.getQuotes",K)}async getHashtagFeed(z,J){let Q={q:"#"+z,limit:50,sort:"latest"};if(J)Q.cursor=J;return await this.getRequest("app.bsky.feed.searchPosts",Q)}async loadHiddenReplies(z){let J;try{J=await g8.getReplies(z.uri)}catch(Z){if(Z instanceof o6&&Z.code==404)throw new Kz(Z);else throw Z}let K=J.filter((Z)=>!z.replies.some((W)=>W.uri===Z)).map((Z)=>this.loadThreadByAtURI(Z));return(await Promise.allSettled(K)).map((Z)=>Z.status=="fulfilled"?Z.value:null)}async loadUserTimeline(z,J,Q){let X=new Date().getTime()-J*86400*1000,{filter:Z,...W}=Q;return await this.fetchAll("app.bsky.feed.getAuthorFeed",{params:{actor:z,filter:Z,limit:100},field:"feed",breakWhen:(G)=>i1(G)<X,...W})}async loadListTimeline(z,J,Q={}){let X=new Date().getTime()-J*86400*1000;return await this.fetchAll("app.bsky.feed.getListFeed",{params:{list:z,limit:100},field:"feed",breakWhen:(Z)=>i1(Z)<X,...Q})}async loadPost(z){let J=await this.loadPosts([z]);if(J.length==1)return J[0];else throw new pJ("Post not found")}async loadPostIfExists(z){return(await this.loadPosts([z]))[0]}async loadPosts(z){if(z.length>0)return(await this.getRequest("app.bsky.feed.getPosts",{uris:z})).posts;else return[]}async loadPostViewerInfo(z){let J=await this.loadPostIfExists(z.uri);if(J)z.author=J.author,z.viewerData=J.viewer,z.viewerLike=J.viewer?.like;return J}async reloadBlockedPost(z){let{repo:J}=m0(z),Q=U8.loadPostIfExists(z),K=this.getRequest("app.bsky.actor.getProfile",{actor:J}),X=await Q;if(!X)return null;let Z=await K;return new S0(X,{author:Z})}}class QZ{user;constructor(){let z=localStorage.getItem("userData");this.user=z?JSON.parse(z):{}}save(){if(this.user)localStorage.setItem("userData",JSON.stringify(this.user));else localStorage.removeItem("userData")}}class W9 extends e1{user;constructor(){let z=new QZ,J=z.user.pdsEndpoint||null;super(J,z);this.user=z.user}async getCurrentUserAvatar(){return(await this.getRequest("com.atproto.repo.getRecord",{repo:this.user.did,collection:"app.bsky.actor.profile",rkey:"self"})).value.avatar}async loadCurrentUserAvatar(){if(!this.config||!this.config.user)throw new B8("User isn't logged in");let z=await this.getCurrentUserAvatar();if(z){let J=`https://cdn.bsky.app/img/avatar/plain/${this.user.did}/${z.ref.$link}@jpeg`;return this.config.user.avatar=J,this.config.save(),J}else return null}async loadNotifications(z){return await this.getRequest("app.bsky.notification.listNotifications",z||{})}async loadMentions(z){let J=await this.loadNotifications({cursor:z??"",limit:100,reasons:["reply","mention"]}),Q=J.notifications.map((Z)=>Z.uri),K=[];for(let Z=0;Z<Q.length;Z+=25){let W=this.loadPosts(Q.slice(Z,Z+25));K.push(W)}let X=await Promise.all(K);return{cursor:J.cursor,posts:X.flat()}}async loadHomeTimeline(z,J={}){let K=new Date().getTime()-z*86400*1000;return await this.fetchAll("app.bsky.feed.getTimeline",{params:{limit:100},field:"feed",breakWhen:(X)=>i1(X)<K,...J})}async loadUserLists(){return(await this.fetchAll("app.bsky.graph.getLists",{params:{actor:this.user.did,limit:100},field:"lists"})).filter((J)=>J.purpose=="app.bsky.graph.defs#curatelist")}async likePost(z){return await this.postRequest("com.atproto.repo.createRecord",{repo:this.user.did,collection:"app.bsky.feed.like",record:{subject:{uri:z.uri,cid:z.cid},createdAt:new Date().toISOString()}})}async removeLike(z){let{rkey:J}=m0(z);await this.postRequest("com.atproto.repo.deleteRecord",{repo:this.user.did,collection:"app.bsky.feed.like",rkey:J})}resetTokens(){delete this.user.avatar,super.resetTokens()}}class KZ{#z;get data(){return Y(this.#z)}set data(z){R(this.#z,z,!0)}constructor(){let z=localStorage.getItem("settings");this.#z=f(x0(z?JSON.parse(z):{}))}save(){localStorage.setItem("settings",JSON.stringify(this.data))}logOut(){delete this.data.incognito,this.save()}get dateLocale(){return this.data.dateLocale}set dateLocale(z){this.data.dateLocale=z,this.save()}get incognitoMode(){return this.data.incognito}set incognitoMode(z){this.data.incognito=z,this.save()}get biohazardsEnabled(){return this.data.biohazard}set biohazardsEnabled(z){this.data.biohazard=z,this.save()}}var T0=new KZ;window.settings=T0;var U8=new e1("api.bsky.app"),g8=new e1("blue.mackuba.eu"),q0=new W9,N0;function dJ(){N0=q0.isLoggedIn&&!T0.incognitoMode?q0:U8,window.api=N0}dJ();window.AuthenticatedAPI=W9;window.BlueskyAPI=e1;window.Minisky=s5;window.appView=U8;window.blueAPI=g8;window.accountAPI=q0;function h8(){return location.origin+location.pathname}function L5(z){let J=new URL(h8());return J.searchParams.set("hash",z),J.toString()}function iJ(z){let J=new URL(h8());return J.searchParams.set("quotes",z),J.toString()}function v6(z){return B6(z.author.handle,z.rkey)}function B6(z,J){let Q=new URL(h8());return Q.searchParams.set("author",z),Q.searchParams.set("post",J),Q.toString()}function Qz(z){let J;try{J=new URL(z)}catch(Z){throw new a6(`${Z}`)}if(J.protocol!="https:"&&J.protocol!="http:")throw new a6("URL must start with http(s)://");let Q=J.pathname.split("/");if(Q.length<5||Q[1]!="profile"||Q[3]!="post")throw new a6("This is not a valid thread URL");let K=Q[2],X=Q[4];return{user:K,post:X}}function ZZ(z){return Object.fromEntries(new URLSearchParams(z))}var XZ="5";if(typeof window<"u")((window.__svelte??={}).v??=new Set).add(XZ);class _J extends Error{}class YZ extends Error{}async function WZ(z){let J;if(z.startsWith("did:plc:"))J=new URL(`https://plc.directory/${z}`);else if(z.startsWith("did:web:")){let Z=z.replace(/^did:web:/,"");J=new URL(`https://${Z}/.well-known/did.json`)}else throw new _J(`Unknown DID type: ${z}`);let Q=await fetch(J),K=await Q.text(),X=K.trim().length>0?JSON.parse(K):void 0;if(Q.status==200){let Z=(X.service||[]).find((W)=>W.id=="#atproto_pds");if(Z)return Z.serviceEndpoint.replace("https://","");else throw new _J("Missing #atproto_pds service definition")}else throw new o6(Q.status,X)}async function GZ(z){if(z.match(/^did:/))return await WZ(z);else if(z.match(/^[^@]+@[^@]+$/))return"bsky.social";else if(z.match(/^@?[\w\-]+(\.[\w\-]+)+$/)){z=z.replace(/^@/,"");let J=await U8.resolveHandle(z);return await WZ(J)}else throw new YZ("Please enter your handle or DID.")}class BZ{#z;#J;#Q;constructor(){this.#z=f(x0(q0.isLoggedIn)),this.#J=f(x0(q0.isLoggedIn?q0.user.avatar:void 0)),this.#Q=f(!1)}get isIncognito(){return!!T0.incognitoMode}toggleIncognitoMode(){T0.incognitoMode=!this.isIncognito,location.reload()}get loggedIn(){return Y(this.#z)}get avatarURL(){return Y(this.#J)}get avatarIsLoading(){return Y(this.#Q)}async logIn(z,J){let Q=await GZ(z);q0.host=Q,await q0.logIn(z,J),R(this.#z,!0),R(this.#Q,!0),dJ(),q0.loadCurrentUserAvatar().then((K)=>{R(this.#J,K||void 0,!0)}).catch((K)=>{console.log(K)}).finally(()=>{R(this.#Q,!1)})}logOut(){q0.resetTokens(),T0.logOut(),location.reload()}}var Y1=new BZ;var p4=L("<div><!></div>");function Zz(z,J){i(J,!0);let Q=R0(J,"onClose",3,void 0),K=R0(J,"id",3,void 0),X=s6(J,["$$slots","$$events","$$legacy","children","onClose","id"]);function Z(w){if(w.target===w.currentTarget)Q()?.()}var W=p4();W.__click=Z;var G=x(W);G8(G,()=>J.children),C(W),g(()=>{d(W,"id",K()),X1(W,1,`dialog ${J.class??""}`,"svelte-1fggtsn")}),V(z,W),_()}I0(["click"]);var d4=L(`<form method="get" class="svelte-1b6ue70"><i class="close fa-circle-xmark fa-regular"></i> <h2>โ˜ฃ๏ธ Infohazard Warning</h2> <p>&ldquo;<em>This thread is not a place of honor... no highly esteemed post is commemorated here... nothing valued is here.</em>&rdquo;</p> <p>This feature allows access to comments in a thread which were hidden because one of the commenters has blocked another. Bluesky currently hides such comments to avoid escalating conflicts.</p> <p>Are you sure you want to enter?<br/>(You can toggle this in the menu in top-left corner.)</p> <p class="submit svelte-1b6ue70"><input type="submit" value="Show me the drama \uD83D\uDE08" class="svelte-1b6ue70"/> <input type="submit" value="Nope, I'd rather not \uD83D\uDE48" class="svelte-1b6ue70"/></p></form>`);function cJ(z,J){i(J,!0);let Q=R0(J,"onConfirm",3,void 0),K=R0(J,"onReject",3,void 0),X=R0(J,"onClose",3,void 0);function Z(G){G.preventDefault(),T0.biohazardsEnabled=!0,Q()?.(),X()?.()}function W(G){G.preventDefault(),T0.biohazardsEnabled=!1,K()?.(),X()?.()}Zz(z,{onClose:()=>X()?.(),children:(G,w)=>{var H=d4(),q=x(H);q.__click=function(...M){X()?.apply(this,M)};var F=S(q,10),B=x(F);B.__click=Z;var U=S(B,2);U.__click=W,C(F),C(H),V(G,H)},$$slots:{default:!0}}),_()}I0(["click"]);var i4=L('<i class="close fa-circle-xmark fa-regular svelte-1pnuyy2"></i>'),_4=L(`<div class="info-box svelte-1pnuyy2"><p class="svelte-1pnuyy2">Skythread doesn't support OAuth yet. For now, you need to use an "app password" here, which you can generate in the Bluesky app settings.</p> <p class="svelte-1pnuyy2">The password you enter here is only passed to the Bluesky API (PDS) and isn't saved anywhere. The returned access token is only stored in your browser's local storage. You can see the complete source code of this app <a href="http://tangled.org/@mackuba.eu/skythread" target="_blank" class="svelte-1pnuyy2">on Tangled</a>.</p></div>`),c4=L('<input type="submit" value="Log in" class="svelte-1pnuyy2"/>'),s4=L('<i class="cloudy fa-solid fa-cloud fa-beat fa-xl svelte-1pnuyy2"></i>'),o4=L('<form method="get" class="svelte-1pnuyy2"><!> <h2>\uD83C\uDF24 Skythread</h2> <p><input type="text" id="login_handle" required placeholder="name.bsky.social" class="svelte-1pnuyy2"/></p> <p><input type="password" id="login_password" required placeholder="โœฑโœฑโœฑโœฑโœฑโœฑโœฑโœฑ" class="svelte-1pnuyy2"/></p> <p class="info svelte-1pnuyy2"><a href="#" class="svelte-1pnuyy2"><i class="fa-regular fa-circle-question"></i> Use an "app password" here</a></p> <!> <p class="submit"><!></p></form>');function sJ(z,J){i(J,!0);let Q=R0(J,"onClose",3,void 0),K=R0(J,"onLogin",3,void 0),X=f(""),Z=f(""),W=f(!1),G=f(!1),w,H;function q(){if(J.showClose&&Q())Q()()}function F(M){M.preventDefault(),R(W,!Y(W))}async function B(M){M.preventDefault(),R(G,!0),w.blur(),H.blur();try{await Y1.logIn(Y(X).trim(),Y(Z).trim()),K()?.(),Q()?.()}catch(P){R(G,!1),U(P)}}function U(M){if(console.log(M),M instanceof o6&&M.code==401&&M.json.error=="AuthFactorTokenRequired")alert('Please log in using an "app password" if you have 2FA enabled.');else window.setTimeout(()=>alert(M),10)}{let M=C0(()=>Y(W)?"expanded":"");Zz(z,{id:"login",get class(){return Y(M)},onClose:q,children:(P,O)=>{var j=o4(),T=x(j);{var E=(Q0)=>{var U0=i4();U0.__click=function(...D0){Q()?.apply(this,D0)},V(Q0,U0)};D(T,(Q0)=>{if(J.showClose)Q0(E)})}var N=S(T,4),y=x(N);$0(y),N8(y,!0),t1(y,(Q0)=>w=Q0,()=>w),C(N);var b=S(N,2),A=x(b);$0(A),t1(A,(Q0)=>H=Q0,()=>H),C(b);var k=S(b,2),$=x(k);$.__click=F,C(k);var v=S(k,2);{var l=(Q0)=>{var U0=_4();V(Q0,U0)};D(v,(Q0)=>{if(Y(W))Q0(l)})}var n=S(v,2),o=x(n);{var a=(Q0)=>{var U0=c4();V(Q0,U0)},X0=(Q0)=>{var U0=s4();V(Q0,U0)};D(o,(Q0)=>{if(!Y(G))Q0(a);else Q0(X0,!1)})}C(n),C(j),L1("submit",j,B),P1(y,()=>Y(X),(Q0)=>R(X,Q0)),P1(A,()=>Y(Z),(Q0)=>R(Z,Q0)),V(P,j)},$$slots:{default:!0}})}_()}I0(["click"]);var Y9=f(!1),HZ=f(!1),G9=f(!1),wZ=f(void 0);function P5(z){if(!Y(Y9))R(Y9,!0),R(HZ,z.showClose,!0)}function UZ(z){if(!Y(G9))R(G9,!0),R(wZ,z,!0)}function oJ(z){var J=c(),Q=h(J);{var K=(Z)=>{sJ(Z,{onClose:()=>R(Y9,!1),get showClose(){return Y(HZ)}})},X=(Z)=>{var W=c(),G=h(W);{var w=(H)=>{cJ(H,{onClose:()=>R(G9,!1),onConfirm:()=>Y(wZ)?.()})};D(G,(H)=>{if(Y(G9))H(w)},!0)}V(Z,W)};D(Q,(Z)=>{if(Y(Y9))Z(K);else Z(X,!1)})}V(z,J)}var a4=L('<span class="check">โœ“</span>'),r4=L('<li class="svelte-1obod96"><a class="button svelte-1obod96" href="#"><!> </a></li>');function I5(z,J){let Q=R0(J,"title",3,void 0),K=R0(J,"showCheckmark",3,!1);var X=r4(),Z=x(X);Z.__click=function(...H){J.onclick?.apply(this,H)};var W=x(Z);{var G=(H)=>{var q=a4();V(H,q)};D(W,(H)=>{if(K())H(G)})}var w=S(W);C(Z),C(X),g(()=>{d(Z,"title",Q()),u(w,` ${J.label??""}`)}),V(z,X)}I0(["click"]);var n4=L("<!> <img/>",1),t4=L("<img/>");function aJ(z,J){let Q=s6(J,["$$slots","$$events","$$legacy","loading","error"]),K=f(void 0);function X(){R(K,"loaded")}function Z(){R(K,"error")}var W=c(),G=h(W);{var w=(q)=>{var F=n4(),B=h(F);G8(B,()=>J.loading);var U=S(B,2);cz(U,()=>({...Q,style:"display: none",onload:X,onerror:Z})),Y5(U),V(q,F)},H=(q)=>{var F=c(),B=h(F);{var U=(P)=>{var O=t4();cz(O,()=>({...Q})),Y5(O),V(P,O)},M=(P)=>{var O=c(),j=h(O);G8(j,()=>J.error),V(P,O)};D(B,(P)=>{if(Y(K)=="loaded")P(U);else P(M,!1)},!0)}V(q,F)};D(G,(q)=>{if(!Y(K))q(w);else q(H,!1)})}V(z,W)}var e4=L('<i class="fa-solid fa-user-secret fa-lg svelte-jzoz05"></i>'),zX=L('<i class="fa-regular fa-user-circle fa-xl svelte-jzoz05"></i>'),JX=L('<i class="fa-regular fa-user-circle fa-xl svelte-jzoz05"></i>'),QX=L('<i class="fa-solid fa-user-circle fa-xl svelte-jzoz05"></i>'),KX=L('<i class="fa-solid fa-user-circle fa-xl svelte-jzoz05"></i>'),ZX=L('<div id="account"><!></div> <div id="account_menu" class="svelte-jzoz05"><ul class="svelte-jzoz05"><!> <!> <!> <li class="link svelte-jzoz05"><a class="svelte-jzoz05">Home</a></li> <li class="link svelte-jzoz05"><a href="?page=posting_stats" class="svelte-jzoz05">Posting stats</a></li> <li class="link svelte-jzoz05"><a href="?page=like_stats" class="svelte-jzoz05">Like stats</a></li> <li class="link svelte-jzoz05"><a href="?page=search" class="svelte-jzoz05">Timeline search</a></li> <li class="link svelte-jzoz05"><a href="?page=search&amp;mode=likes" class="svelte-jzoz05">Archive search</a></li></ul></div>',1);function rJ(z,J){i(J,!0);let Q=f(!1);Z1(()=>{let k=document.body.parentNode;return k.addEventListener("click",K),()=>{k.removeEventListener("click",K)}});function K(){R(Q,!1)}function X(k){k.stopPropagation(),R(Q,!Y(Q))}function Z(k){if(k.preventDefault(),T0.biohazardsEnabled===!1)T0.biohazardsEnabled=!0;else T0.biohazardsEnabled=!1}function W(k){k.preventDefault(),Y1.toggleIncognitoMode()}function G(k){k.preventDefault(),P5({showClose:!0}),R(Q,!1)}function w(k){k.preventDefault(),Y1.logOut()}var H=ZX(),q=h(H);q.__click=X;var F=x(q);{var B=(k)=>{var $=e4();V(k,$)},U=(k)=>{var $=c(),v=h($);{var l=(o)=>{var a=zX();V(o,a)},n=(o)=>{var a=c(),X0=h(a);{var Q0=(D0)=>{aJ(D0,{class:"avatar",get src(){return Y1.avatarURL},loading:(s)=>{var V0=JX();V(s,V0)},error:(s)=>{var V0=QX();V(s,V0)},$$slots:{loading:!0,error:!0}})},U0=(D0)=>{var M0=KX();V(D0,M0)};D(X0,(D0)=>{if(Y1.loggedIn&&Y1.avatarURL)D0(Q0);else D0(U0,!1)},!0)}V(o,a)};D(v,(o)=>{if(!Y1.loggedIn||Y1.avatarIsLoading)o(l);else o(n,!1)},!0)}V(k,$)};D(F,(k)=>{if(Y1.isIncognito)k(B);else k(U,!1)})}C(q);var M=S(q,2);M.__click=(k)=>k.stopPropagation();var P=x(M),O=x(P);{var j=(k)=>{I5(k,{onclick:W,label:"Incognito mode",title:"Temporarily load threads as a logged-out user",get showCheckmark(){return Y1.isIncognito}})};D(O,(k)=>{if(Y1.loggedIn)k(j)})}var T=S(O,2);{let k=C0(()=>T0.biohazardsEnabled!==!1);I5(T,{onclick:Z,label:"Show infohazards",title:"Show links to blocked and hidden comments",get showCheckmark(){return Y(k)}})}var E=S(T,2);{var N=(k)=>{I5(k,{onclick:G,label:"Log in"})},y=(k)=>{I5(k,{onclick:w,label:"Log out"})};D(E,(k)=>{if(!Y1.loggedIn)k(N);else k(y,!1)})}var b=S(E,2),A=x(b);C(b),c0(8),C(P),C(M),g((k)=>{X1(q,1,T8({active:Y(Q)}),"svelte-jzoz05"),c6(M,`visibility: ${Y(Q)?"visible":"hidden"}`),d(A,"href",k)},[h8]),V(z,H),_()}I0(["click"]);var f8,nJ;function V8(z){if(f8)document.removeEventListener("scroll",f8);nJ?.disconnect(),f8=()=>{if(window.pageYOffset+window.innerHeight>document.body.offsetHeight-500)z(f8)},z(f8),document.addEventListener("scroll",f8),nJ=new ResizeObserver(f8),nJ.observe(document.body)}gQ();var XX=L('<div id="loader" class="svelte-1larzq0"><img src="icons/sunny.png" alt="Loading..." class="svelte-1larzq0"/></div>');function n6(z){var J=XX();V(z,J)}var WX=L('<div class="margin svelte-qe4209"><div class="edge svelte-qe4209"><div class="line svelte-qe4209"></div></div> <img class="plus svelte-qe4209"/></div>');function tJ(z,J){i(J,!0);let Q=R0(J,"collapsed",15,!1);function K(){Q(!Q())}var X=WX(),Z=x(X);Z.__click=K;var W=S(Z,2);W.__click=K,C(X),g(()=>{d(W,"alt",Q()?"+":"-"),d(W,"src",`icons/${Q()?"add-square.png":"subtract-square.png"}`)}),V(z,X),_()}I0(["click"]);var YX=L('<a class="fedi-link svelte-ul6xja" target="_blank"><div class="svelte-ul6xja"><i class="fa-solid fa-arrow-up-right-from-square fa-sm svelte-ul6xja"></i> </div></a>');function eJ(z,J){i(J,!0);let Q=C0(()=>new URL(J.url).hostname);var K=YX(),X=x(K),Z=S(x(X));C(X),C(K),g(()=>{d(K,"href",J.url),u(Z,` View on ${Y(Q)??""}`)}),V(z,K),_()}var GX=L('โ˜ฃ๏ธ <a class="svelte-1epmfrv">Load hidden repliesโ€ฆ</a>',1),BX=L('<img class="loader" src="icons/sunny.png" alt="Loading..."/>'),HX=L('<p class="hidden-replies svelte-1epmfrv"><!></p>');function z7(z,J){i(J,!0);let{post:Q}=J1(),K=f(!1);function X(q){if(q.preventDefault(),T0.biohazardsEnabled===!0)Z();else UZ(()=>{Z()})}async function Z(){R(K,!0);try{let F=(await N0.loadHiddenReplies(Q)).map((B)=>B&&w8(B.thread,Q.pageRoot,1,Q.absoluteLevel+1));R(K,!1),J.onLoad(F)}catch(q){R(K,!1),J.onError(q)}}var W=HX(),G=x(W);{var w=(q)=>{var F=GX(),B=S(h(F));B.__click=X,g((U)=>d(B,"href",U),[()=>v6(Q)]),V(q,F)},H=(q)=>{var F=BX();V(q,F)};D(G,(q)=>{if(!Y(K))q(w);else q(H,!1)})}C(W),V(z,W),_()}I0(["click"]);var wX=L("<a>Load more repliesโ€ฆ</a>"),UX=L('<img class="loader" src="icons/sunny.png" alt="Loading..."/>'),VX=L("<p><!></p>");function J7(z,J){i(J,!0);let{post:Q}=J1(),K=f(!1);async function X(H){H.preventDefault(),R(K,!0);try{let q=await N0.loadThreadByAtURI(Q.uri),F=w8(q.thread,Q.pageRoot,0,Q.absoluteLevel);if(R(K,!1),F instanceof S0)window.subtreeRoot=F,J.onLoad(F);else J.onError(Error("Post is not available"))}catch(q){R(K,!1),J.onError(q)}}var Z=VX(),W=x(Z);{var G=(H)=>{var q=wX();q.__click=X,g((F)=>d(q,"href",F),[()=>v6(Q)]),V(H,q)},w=(H)=>{var q=UX();V(H,q)};D(W,(H)=>{if(!Y(K))H(G);else H(w,!1)})}C(Z),V(z,Z),_()}I0(["click"]);class S5{text;facet;constructor(z,J){this.text=z;this.facet=J}get link(){return this.facet?.features.find((z)=>z.$type==="app.bsky.richtext.facet#link")}isLink(){return!!this.link}get mention(){return this.facet?.features.find((z)=>z.$type==="app.bsky.richtext.facet#mention")}isMention(){return!!this.mention}get tag(){return this.facet?.features.find((z)=>z.$type==="app.bsky.richtext.facet#tag")}isTag(){return!!this.tag}}class Q7{unicodeText;facets;constructor(z){if(this.unicodeText=new VZ(z.text),this.facets=z.facets,this.facets)this.facets=this.facets.filter(qX).sort(FX)}get text(){return this.unicodeText.toString()}get length(){return this.unicodeText.length}get graphemeLength(){return this.unicodeText.graphemeLength}*segments(){let z=this.facets||[];if(!z.length){yield new S5(this.unicodeText.utf16);return}let J=0,Q=0;do{let K=z[Q];if(J<K.index.byteStart)yield new S5(this.unicodeText.slice(J,K.index.byteStart));else if(J>K.index.byteStart){Q++;continue}if(K.index.byteStart<K.index.byteEnd){let X=this.unicodeText.slice(K.index.byteStart,K.index.byteEnd);if(!X.trim())yield new S5(X);else yield new S5(X,K)}J=K.index.byteEnd,Q++}while(Q<z.length);if(J<this.unicodeText.length)yield new S5(this.unicodeText.slice(J,this.unicodeText.length))}}var FX=(z,J)=>z.index.byteStart-J.index.byteStart,qX=(z)=>z.index.byteStart<=z.index.byteEnd,MX=new TextEncoder,CX=new TextDecoder,xX=new Intl.Segmenter,OX=(z)=>{return Array.from(xX.segment(z)).length};class VZ{utf16;utf8;_graphemeLen;constructor(z){this.utf16=z,this.utf8=MX.encode(z)}get length(){return this.utf8.byteLength}get graphemeLength(){if(!this._graphemeLen)this._graphemeLen=OX(this.utf16);return this._graphemeLen}slice(z,J){return CX.decode(this.utf8.slice(z,J))}toString(){return this.utf16}}var LX=L("<a> </a>"),PX=L("<a> </a>"),IX=L("<a> </a>"),SX=L("<br/>"),DX=L("<!> ",1);function K7(z,J){i(J,!0);let Q=C0(()=>new Q7({text:J.text,facets:J.facets})),K=C0(()=>Y(Q).segments());var X=c(),Z=h(X);A0(Z,17,()=>Y(K),O1,(W,G)=>{var w=c(),H=h(w);{var q=(B)=>{var U=LX(),M=x(U,!0);C(U),g(()=>{d(U,"href",`https://bsky.app/profile/${Y(G).mention.did??""}`),u(M,Y(G).text)}),V(B,U)},F=(B)=>{var U=c(),M=h(U);{var P=(j)=>{var T=PX(),E=x(T,!0);C(T),g(()=>{d(T,"href",Y(G).link.uri),u(E,Y(G).text)}),V(j,T)},O=(j)=>{var T=c(),E=h(T);{var N=(b)=>{var A=IX(),k=x(A,!0);C(A),g(($)=>{d(A,"href",$),u(k,Y(G).text)},[()=>L5(Y(G).tag.tag)]),V(b,A)},y=(b)=>{let A=C0(()=>Y(G).text.split(` 50 + `));var k=c(),$=h(k);A0($,17,()=>Y(A),O1,(v,l,n)=>{var o=DX(),a=h(o);{var X0=(U0)=>{var D0=SX();V(U0,D0)};D(a,(U0)=>{if(n>0)U0(X0)})}var Q0=S(a,1,!0);g(()=>u(Q0,Y(l))),V(v,o)}),V(b,k)};D(E,(b)=>{if(Y(G).tag)b(N);else b(y,!1)},!0)}V(j,T)};D(M,(j)=>{if(Y(G).link)j(P);else j(O,!1)},!0)}V(B,U)};D(H,(B)=>{if(Y(G).mention)B(q);else B(F,!1)})}V(W,w)}),V(z,X),_()}var RX=L('<div class="bridged-body svelte-rk6ws2"><!></div>'),jX=L('<p class="body svelte-rk6ws2"><!></p>');function Xz(z,J){i(J,!0);let Q="search-results",{post:K}=J1(),X=R0(J,"highlightedMatches",3,void 0),Z=f(void 0);function W(F){let B=new RegExp(`\\b(${F.join("|")})\\b`,"gi"),U=document.createTreeWalker(Y(Z),NodeFilter.SHOW_TEXT),M=[];while(U.nextNode()){let O=U.currentNode;if(!O.textContent)continue;B.lastIndex=0;for(;;){let j=B.exec(O.textContent);if(j===null)break;let T=new Range;T.setStart(O,j.index),T.setEnd(O,j.index+j[0].length),M.push(T)}}let P=CSS.highlights.get(Q)||new Highlight;M.forEach((O)=>P.add(O)),CSS.highlights.set(Q,P)}Z1(()=>{if(X()&&X().length>0)return W(X()),()=>{CSS.highlights.delete(Q)};else return});var G=c(),w=h(G);{var H=(F)=>{var B=RX(),U=x(B);VJ(U,()=>nK(K.originalFediContent)),C(B),t1(B,(M)=>R(Z,M),()=>Y(Z)),V(F,B)},q=(F)=>{var B=jX(),U=x(B);K7(U,{get text(){return K.text},get facets(){return K.facets}}),C(B),t1(B,(M)=>R(Z,M),()=>Y(Z)),V(F,B)};D(w,(F)=>{if(K.originalFediContent)F(H);else F(q,!1)})}V(z,G),_()}class Z7{post;placement;constructor(z,J){this.post=z,this.placement=J}get timeFormatForTimestamp(){if(this.placement=="quotes"||this.placement=="feed")return{weekday:"short",day:"numeric",month:"short",year:"numeric",hour:"numeric",minute:"numeric"};else if(this.post.isPageRoot||this.placement!="thread")return{day:"numeric",month:"short",year:"numeric",hour:"numeric",minute:"numeric"};else if(this.post.pageRoot&&!eK(this.post.createdAt,this.post.pageRoot.createdAt))return{day:"numeric",month:"short",hour:"numeric",minute:"numeric"};else return{hour:"numeric",minute:"numeric"}}get formattedTimestamp(){let z=this.timeFormatForTimestamp;return this.post.createdAt.toLocaleString(T0.dateLocale,z)}}var AX=L('<a class="action"><i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i></a>');function D5(z,J){i(J,!0);let Q=R0(J,"title",3,"");var K=AX();g((X)=>{d(K,"href",X),d(K,"title",Q())},[()=>v6(J.post)]),V(z,K),_()}var NX=L('<i class="muted-avatar fa-regular fa-circle-user fa-2x svelte-b7kxl"></i>'),kX=L('<img class="avatar svelte-b7kxl" alt="Avatar" loading="lazy"/>'),bX=L('<i class="no-avatar fa-regular fa-face-smile fa-2x svelte-b7kxl"></i>'),TX=L('<a class="handle svelte-b7kxl" target="_blank"> </a> <img src="icons/mastodon.svg" class="mastodon svelte-b7kxl" alt="Mastodon logo"/>',1),EX=L('<a class="handle svelte-b7kxl" target="_blank"> </a>'),yX=L('<span class="separator svelte-b7kxl">&bull;</span> <!>',1),vX=L('<h2 class="svelte-b7kxl"><!> <!> <span class="separator svelte-b7kxl">&bull;</span> <a class="time svelte-b7kxl" target="_blank"> </a> <!></h2>');function X7(z,J){i(J,!0);let{post:Q,placement:K}=J1(),X=new Z7(Q,K),Z=f(void 0);Z1(()=>{if(Y(Z))TJ.observe(Y(Z));return()=>{Y(Z)&&TJ.unobserve(Y(Z))}});var W=vX(),G=x(W);{var w=(T)=>{var E=NX();V(T,E)},H=(T)=>{var E=c(),N=h(E);{var y=(A)=>{var k=kX();t1(k,($)=>R(Z,$),()=>Y(Z)),g(()=>d(k,"src",Q.author.avatar)),V(A,k)},b=(A)=>{var k=bX();V(A,k)};D(N,(A)=>{if(Q.author.avatar)A(y);else A(b,!1)},!0)}V(T,E)};D(G,(T)=>{if(Q.muted)T(w);else T(H,!1)})}var q=S(G),F=S(q);{var B=(T)=>{var E=TX(),N=h(E),y=x(N);C(N),c0(2),g(()=>{d(N,"href",Q.linkToAuthor),u(y,`@${Q.authorFediHandle??""}`)}),V(T,E)},U=(T)=>{var E=EX(),N=x(E,!0);C(E),g(()=>{d(E,"href",Q.linkToAuthor),u(N,Q.hasValidHandle?`@${Q.author.handle}`:"[invalid handle]")}),V(T,E)};D(F,(T)=>{if(Q.isFediPost)T(B);else T(U,!1)})}var M=S(F,4),P=x(M,!0);C(M);var O=S(M,2);{var j=(T)=>{var E=yX(),N=S(h(E),2);{var y=(A)=>{D5(A,{get post(){return Q},title:"Load thread"})},b=(A)=>{D5(A,{get post(){return Q},title:"Load this subtree"})};D(N,(A)=>{if(["quote","quotes","feed"].includes(K))A(y);else A(b,!1)})}V(T,E)};D(O,(T)=>{if(Q.replyCount>0&&!Q.isPageRoot||["quote","quotes","feed"].includes(K))T(j)})}C(W),g((T)=>{u(q,` ${Q.authorDisplayName??""} `),d(M,"href",Q.linkToPost),d(M,"title",T),u(P,X.formattedTimestamp)},[()=>Q.createdAt.toISOString()]),V(z,W),_()}var gX=L('<a class="svelte-1d08m6n"> </a>'),hX=L('<p class="tags"></p>');function W7(z,J){i(J,!1);let{post:Q}=J1();RJ();var K=hX();A0(K,5,()=>Q.tags,O1,(X,Z)=>{var W=gX(),G=x(W);C(W),g((w)=>{d(W,"href",w),u(G,`# ${Y(Z)??""}`)},[()=>L5(Y(Z))]),V(X,W)}),C(K),V(z,K),_()}var fX=L('<span class="svelte-14wd2aa"><i class="fa-solid fa-retweet svelte-14wd2aa"></i> </span>'),mX=L('<span class="svelte-14wd2aa"><i class="fa-regular fa-message svelte-14wd2aa"></i> <a class="svelte-14wd2aa"> </a></span>'),$X=L('<span class="svelte-14wd2aa"><i class="fa-regular fa-comments svelte-14wd2aa"></i> <a class="svelte-14wd2aa"> </a></span>'),uX=L('<a class="svelte-14wd2aa"><i class="fa-regular fa-comments svelte-14wd2aa"></i> </a>'),lX=L('<span class="svelte-14wd2aa"><i class="fa-solid fa-ban svelte-14wd2aa"></i> Limited replies</span>'),pX=L('<span class="blocked-info svelte-14wd2aa">\uD83D\uDEAB Post unavailable</span>'),dX=L('<p class="stats svelte-14wd2aa"><span class="svelte-14wd2aa"><i></i> <output> </output></span> <!> <!> <!> <!> <!></p>');function Y7(z,J){i(J,!0);let{post:Q,placement:K}=J1(),X=f(x0(Q.liked)),Z=f(x0(Q.likeCount)),W=f(!1);async function G(){try{if(Q.hasViewerInfo)await H();else if(Y1.loggedIn)await w();else P5({showClose:!0})}catch($){H8($)}}async function w(){if(await q0.loadPostViewerInfo(Q))if(Q.liked)R(X,!0);else await H();else R(W,!0)}async function H(){if(!Y(X)){let $=await q0.likePost(Q);Q.viewerLike=$.uri,R(X,!0),R(Z,Y(Z)+1)}else await q0.removeLike(Q.viewerLike),Q.viewerLike=void 0,R(X,!1),R(Z,Y(Z)-1)}var q=dX(),F=x(q),B=x(F);B.__click=G;var U=S(B,2),M=x(U,!0);C(U),C(F);var P=S(F,2);{var O=($)=>{var v=fX(),l=S(x(v));C(v),g(()=>u(l,` ${Q.repostCount??""}`)),V($,v)};D(P,($)=>{if(Q.repostCount>0)$(O)})}var j=S(P,2);{var T=($)=>{var v=mX(),l=S(x(v),2),n=x(l,!0);C(l),C(v),g((o,a)=>{d(l,"href",o),u(n,a)},[()=>v6(Q),()=>tz(Q.replyCount,"reply","replies")]),V($,v)};D(j,($)=>{if(Q.replyCount>0&&(K=="quotes"||K=="feed"))$(T)})}var E=S(j,2);{var N=($)=>{var v=c(),l=h(v);{var n=(a)=>{var X0=$X(),Q0=S(x(X0),2),U0=x(Q0,!0);C(Q0),C(X0),g((D0,M0)=>{d(Q0,"href",D0),u(U0,M0)},[()=>iJ(Q.linkToPost),()=>tz(J.quoteCount,"quote")]),V(a,X0)},o=(a)=>{var X0=uX(),Q0=S(x(X0));C(X0),g((U0)=>{d(X0,"href",U0),u(Q0,` ${J.quoteCount??""}`)},[()=>iJ(Q.linkToPost)]),V(a,X0)};D(l,(a)=>{if(K=="quotes"||K=="feed"||Q.isPageRoot)a(n);else a(o,!1)})}V($,v)};D(E,($)=>{if(J.quoteCount&&K!="quote")$(N)})}var y=S(E,2);{var b=($)=>{var v=lX();V($,v)};D(y,($)=>{if(K=="thread"&&Q.isRestrictingReplies)$(b)})}var A=S(y,2);{var k=($)=>{var v=pX();V($,v)};D(A,($)=>{if(Y(W))$(k)})}C(q),g(()=>{X1(B,1,`fa-solid fa-heart ${Y(X)?"liked":""}`,"svelte-14wd2aa"),u(M,Y(Z))}),V(z,q),_()}I0(["click"]);var iX=L('<details class="image-alt svelte-1d4qxx0"><summary class="svelte-1d4qxx0">Show alt</summary> </details>'),_X=L("<p>[<a>Image</a>]</p> <!>",1),cX=L("<div></div>");function G7(z,J){i(J,!0);let{post:Q}=J1();function K(Z){if(Z.fullsize)return Z.fullsize;else{let W=Z.image.ref.$link;return`https://cdn.bsky.app/img/feed_fullsize/plain/${Q.author.did}/${W}@jpeg`}}var X=cX();A0(X,21,()=>J.embed.images,O1,(Z,W)=>{var G=_X(),w=h(G),H=S(x(w));c0(),C(w);var q=S(w,2);{var F=(B)=>{var U=iX(),M=S(x(U));C(U),g(()=>u(M,` ${Y(W).alt??""}`)),V(B,U)};D(q,(B)=>{if(Y(W).alt)B(F)})}g((B)=>d(H,"href",B),[()=>K(Y(W))]),V(Z,G)}),C(X),V(z,X),_()}var sX=L('<div class="gif svelte-1g38dct"><img/></div>');function B7(z,J){let Q=f(!1),K=f(!1),X=f(500),Z=f(200);function W(F){let B=F.target;if(B.naturalWidth<B.naturalHeight)R(X,200),R(Z,400);R(Q,!0)}function G(){R(K,!Y(K))}var w=sX(),H=x(w);H.__click=G;let q;C(w),g(()=>{d(H,"src",Y(K)?J.staticURL:J.gifURL),X1(H,1,T8(Y(K)?"static":""),"svelte-1g38dct"),d(H,"alt",J.alt?`Gif: ${J.alt}`:"Gif animation"),q=c6(H,"",q,{opacity:Y(Q)?1:0,"max-width":`${Y(X)??""}px`,"max-height":`${Y(Z)??""}px`})}),L1("load",H,W),Y5(H),V(z,w)}I0(["click"]);var oX=L('<p class="description"> </p>'),aX=L('<a class="link-card" target="_blank"><div><p class="domain"> </p> <h2> </h2> <!></div></a>'),rX=L("<p>[Link: <a> </a>]</p>");function H7(z,J){i(J,!0);let{post:Q}=J1(),K=f(!1),X=C0(()=>new URL(J.embed.url).hostname),Z=C0(()=>Y(X)=="media.tenor.com"),W=C0(()=>Y(Z)?G:void 0);function G(U){U.preventDefault(),R(K,!0)}function w(){if(typeof J.embed.thumb=="string")return J.embed.thumb;else return`https://cdn.bsky.app/img/avatar/feed_thumbnail/${Q.author.did}/${J.embed.thumb.ref.$link}@jpeg`}var H=c(),q=h(H);{var F=(U)=>{{let M=C0(w);B7(U,{get gifURL(){return J.embed.url},get staticURL(){return Y(M)},get alt(){return J.embed.title}})}},B=(U)=>{var M=c(),P=h(M);{var O=(T)=>{var E=aX();E.__click=function(...l){Y(W)?.apply(this,l)};var N=x(E),y=x(N),b=x(y,!0);C(y);var A=S(y,2),k=x(A,!0);C(A);var $=S(A,2);{var v=(l)=>{var n=oX(),o=x(n,!0);C(n),g((a)=>u(o,a),[()=>tK(J.embed.description,300)]),V(l,n)};D($,(l)=>{if(J.embed.description)l(v)})}C(N),C(E),g(()=>{d(E,"href",J.embed.url),u(b,Y(X)),u(k,J.embed.title||J.embed.url)}),V(T,E)},j=(T)=>{var E=rX(),N=S(x(E)),y=x(N,!0);C(N),c0(),C(E),g(()=>{d(N,"href",J.embed.url),u(y,J.embed.title||J.embed.url)}),V(T,E)};D(P,(T)=>{if(ez(J.embed.url))T(O);else T(j,!1)})}V(U,M)};D(q,(U)=>{if(Y(K))U(F);else U(B,!1)})}V(z,H),_()}I0(["click"]);var nX=L('<img class="avatar" alt="Avatar"/>'),tX=L('<p class="description"> </p>'),eX=L('<a class="link-card record" target="_blank"><div><!> <h2> <span class="handle"> </span></h2> <!> <p class="stats"><i class="fa-solid fa-heart"></i> <output> </output></p></div></a>');function w7(z,J){i(J,!0);function Q(O){let{repo:j,rkey:T}=m0(O.uri);return`https://bsky.app/profile/${j}/feed/${T}`}var K=eX(),X=x(K),Z=x(X);{var W=(O)=>{var j=nX();g(()=>d(j,"src",J.feed.avatar)),V(O,j)};D(Z,(O)=>{if(J.feed.avatar)O(W)})}var G=S(Z,2),w=x(G),H=S(w),q=x(H);C(H),C(G);var F=S(G,2);{var B=(O)=>{var j=tX(),T=x(j,!0);C(j),g(()=>u(T,J.feed.description)),V(O,j)};D(F,(O)=>{if(J.feed.description)O(B)})}var U=S(F,2),M=S(x(U),2),P=x(M,!0);C(M),C(U),C(X),C(K),g((O)=>{d(K,"href",O),u(w,`${J.feed.title??""} `),u(q,`โ€ข Feed by @${J.feed.author.handle??""}`),u(P,J.feed.likeCount)},[()=>Q(J.feed)]),V(z,K),_()}var zW=L('<p class="description"> </p>'),JW=L('<a class="link-card record" target="_blank"><div><h2> <span class="handle"> </span></h2> <!></div></a>');function U7(z,J){i(J,!0);function Q(F){let{repo:B,rkey:U}=m0(F.uri);return`https://bsky.app/starter-pack/${B}/${U}`}var K=JW(),X=x(K),Z=x(X),W=x(Z),G=S(W),w=x(G);C(G),C(Z);var H=S(Z,2);{var q=(F)=>{var B=zW(),U=x(B,!0);C(B),g(()=>u(U,J.starterPack.description)),V(F,B)};D(H,(F)=>{if(J.starterPack.description)F(q)})}C(X),C(K),g((F)=>{d(K,"href",F),u(W,`${J.starterPack.title??""} `),u(w,`โ€ข Starter pack by @${J.starterPack.author.handle??""}`)},[()=>Q(J.starterPack)]),V(z,K),_()}var QW=L('<img class="avatar" alt="Avatar"/>'),KW=L('<p class="description"> </p>'),ZW=L('<a class="link-card record" target="_blank"><div><!> <h2> <span class="handle"> </span></h2> <!></div></a>');function V7(z,J){i(J,!0);function Q(M){let{repo:P,rkey:O}=m0(M.uri);return`https://bsky.app/profile/${P}/lists/${O}`}function K(M){switch(M.purpose){case"app.bsky.graph.defs#curatelist":return"User list";case"app.bsky.graph.defs#modlist":return"Mute list";default:return"List"}}var X=ZW(),Z=x(X),W=x(Z);{var G=(M)=>{var P=QW();g(()=>d(P,"src",J.list.avatar)),V(M,P)};D(W,(M)=>{if(J.list.avatar)M(G)})}var w=S(W,2),H=x(w),q=S(H),F=x(q);C(q),C(w);var B=S(w,2);{var U=(M)=>{var P=KW(),O=x(P,!0);C(P),g(()=>u(O,J.list.description)),V(M,P)};D(B,(M)=>{if(J.list.description)M(U)})}C(Z),C(X),g((M,P)=>{d(X,"href",M),u(H,`${J.list.title??""} `),u(F,`โ€ข ${P??""} by @${J.list.author.handle??""}`)},[()=>Q(J.list),()=>K(J.list)]),V(z,X),_()}var FZ=(z,J=J6)=>{var Q=c(),K=h(Q);{var X=(W)=>{var G=XW(),w=x(G);m8(w,{get post(){return J()},placement:"quote"}),C(G),V(W,G)},Z=(W)=>{var G=c(),w=h(G);{var H=(F)=>{w7(F,{get feed(){return J()}})},q=(F)=>{var B=c(),U=h(B);{var M=(O)=>{U7(O,{get starterPack(){return J()}})},P=(O)=>{var j=c(),T=h(j);{var E=(y)=>{V7(y,{get list(){return J()}})},N=(y)=>{var b=WW(),A=x(b),k=x(A);C(A),C(b),g(()=>u(k,`[${J().type??""}]`)),V(y,b)};D(T,(y)=>{if(J()instanceof zz)y(E);else y(N,!1)},!0)}V(O,j)};D(U,(O)=>{if(J()instanceof Jz)O(M);else O(P,!1)},!0)}V(F,B)};D(w,(F)=>{if(J()instanceof e5)F(H);else F(q,!1)},!0)}V(W,G)};D(K,(W)=>{if(J()instanceof v8)W(X);else W(Z,!1)})}V(z,Q)},XW=L('<div class="quote-embed svelte-qy2yyv"><!></div>'),WW=L('<div class="quote-embed svelte-qy2yyv"><p> </p></div>'),YW=L('<div class="quote-embed svelte-qy2yyv"><p class="post placeholder svelte-qy2yyv">Error loading quoted post</p></div>'),GW=L('<div class="quote-embed svelte-qy2yyv"><p class="post placeholder svelte-qy2yyv">Loading quoted post...</p></div>');function H9(z,J){i(J,!0);let{post:Q}=J1();async function K(){let{collection:w}=m0(J.record.uri);if(w=="app.bsky.feed.post"){let H=await N0.loadPostIfExists(J.record.uri);if(H)return new S0(H);else return new P6(Q.data)}else{let q=(await N0.loadPostIfExists(Q.uri).then((F)=>F&&new S0(F)))?.embed;if(q instanceof q5||q instanceof M5)return q.record;else return new P6(J.record)}}var X=c(),Z=h(X);{var W=(w)=>{var H=c(),q=h(H);k8(q,K,(F)=>{var B=GW();V(F,B)},(F,B)=>{FZ(F,()=>Y(B))},(F)=>{var B=YW();V(F,B)}),V(w,H)},G=(w)=>{FZ(w,()=>J.record)};D(Z,(w)=>{if(J.record.constructor===_1&&!J.record.type)w(W);else w(G,!1)})}V(z,X),_()}var BW=L('<details class="image-alt"><summary>Show alt</summary> </details>'),HW=L("<div><p>[<a>Video</a>]</p> <!></div>");function F7(z,J){i(J,!0);let{post:Q}=J1();function K(H){if(H instanceof x5)return H.playlistURL;else{let q=H.video.ref.$link;return`https://video.bsky.app/watch/${Q.author.did}/${q}/playlist.m3u8`}}var X=HW(),Z=x(X),W=S(x(Z));c0(),C(Z);var G=S(Z,2);{var w=(H)=>{var q=BW(),F=S(x(q));C(q),g(()=>u(F,` ${J.embed.alt??""}`)),V(H,q)};D(G,(H)=>{if(J.embed.alt)H(w)})}C(X),g((H)=>d(W,"href",H),[()=>K(J.embed)]),V(z,X),_()}var wW=L("<div><!> <!></div>"),UW=L("<p> </p>"),VW=L('<div class="embed svelte-19fytgx"><!></div>');function $8(z,J){i(J,!0);var Q=VW(),K=x(Q);{var X=(W)=>{H9(W,{get record(){return J.embed.record}})},Z=(W)=>{var G=c(),w=h(G);{var H=(F)=>{var B=wW(),U=x(B);$8(U,{get embed(){return J.embed.media}});var M=S(U,2);H9(M,{get record(){return J.embed.record}}),C(B),V(F,B)},q=(F)=>{var B=c(),U=h(B);{var M=(O)=>{G7(O,{get embed(){return J.embed}})},P=(O)=>{var j=c(),T=h(j);{var E=(y)=>{H7(y,{get embed(){return J.embed}})},N=(y)=>{var b=c(),A=h(b);{var k=(v)=>{F7(v,{get embed(){return J.embed}})},$=(v)=>{var l=UW(),n=x(l);C(l),g(()=>u(n,`[${J.embed.type??""}]`)),V(v,l)};D(A,(v)=>{if(J.embed instanceof Q9||J.embed instanceof x5)v(k);else v($,!1)},!0)}V(y,b)};D(T,(y)=>{if(J.embed instanceof J9||J.embed instanceof C5)y(E);else y(N,!1)},!0)}V(O,j)};D(U,(O)=>{if(J.embed instanceof z9||J.embed instanceof X9)O(M);else O(P,!1)},!0)}V(F,B)};D(w,(F)=>{if(J.embed instanceof Z9||J.embed instanceof M5)F(H);else F(q,!1)},!0)}V(W,G)};D(K,(W)=>{if(J.embed instanceof K9||J.embed instanceof q5)W(X);else W(Z,!1)})}C(Q),V(z,Q),_()}var FW=L("<a> </a>"),qW=L("<a>See parent post</a>"),MW=L("<a>See parent post</a>"),CW=L('<p class="back"><i class="fa-solid fa-reply"></i> <!></p>');function Wz(z,J){i(J,!0);let Q=C0(()=>m0(J.uri)),K=C0(()=>Y(Q).repo),X=C0(()=>Y(Q).rkey);var Z=CW(),W=S(x(Z),2);k8(W,()=>N0.fetchHandleForDid(Y(K)),(G)=>{var w=MW();g((H)=>d(w,"href",H),[()=>B6(Y(K),Y(X))]),V(G,w)},(G,w)=>{var H=FW(),q=x(H);C(H),g((F)=>{d(H,"href",F),u(q,`See parent post (@${Y(w)??""})`)},[()=>B6(Y(w),Y(X))]),V(G,H)},(G)=>{var w=qW();g((H)=>d(w,"href",H),[()=>B6(Y(K),Y(X))]),V(G,w)}),C(Z),V(z,Z),_()}var xW=L("<!> <!> <!>",1);function q7(z,J){i(J,!0),M7({post:J.post,placement:J.placement});var Q=xW(),K=h(Q);{var X=(w)=>{Wz(w,{get uri(){return J.post.parentReference.uri}})};D(K,(w)=>{if(J.post.isPageRoot&&J.post.parentReference)w(X)})}var Z=S(K,2);Xz(Z,{});var W=S(Z,2);{var G=(w)=>{$8(w,{get embed(){return J.post.embed}})};D(W,(w)=>{if(J.post.embed)w(G)})}V(z,Q),_()}var OW=L('(<a target="_blank"> </a> ',1),LW=L('(<a target="_blank"> </a>)',1);function R5(z,J){i(J,!0);let Q=R0(J,"status",3,void 0),K=f(void 0),X=C0(()=>Y(K)?`@${Y(K)}`:"see author");Z1(()=>{let H=m0(J.post.uri).repo;N0.fetchHandleForDid(H).then((q)=>{R(K,q,!0)})});var Z=c(),W=h(Z);{var G=(H)=>{var q=OW(),F=S(h(q)),B=x(F,!0);C(F);var U=S(F);g(()=>{d(F,"href",J.post.didLinkToAuthor),u(B,Y(X)),u(U,`, ${Q()??""})`)}),V(H,q)},w=(H)=>{var q=LW(),F=S(h(q)),B=x(F,!0);C(F),c0(),g(()=>{d(F,"href",J.post.didLinkToAuthor),u(B,Y(X))}),V(H,q)};D(W,(H)=>{if(Q())H(G);else H(w,!1)})}V(z,Z),_()}var PW=L('<p class="blocked-header"><i class="fa-solid fa-ban"></i> <span>Deleted post</span> <!></p>');function Yz(z,J){var Q=PW(),K=S(x(Q),4);R5(K,{get post(){return J.post}}),C(Q),V(z,Q)}var IW=L('<a href="#">Load postโ€ฆ</a>'),SW=L('<p class="load-post"><!></p>'),DW=L('<p class="blocked-header"><i class="fa-solid fa-ban"></i> <span> </span> <!></p> <!>',1),RW=L('<span class="separator">&bull;</span> <!>',1),jW=L('<p class="blocked-header"><i class="fa-solid fa-ban"></i> <span> </span> <!> <!></p> <!>',1);function j5(z,J){i(J,!0);let Q=C0(()=>T0.biohazardsEnabled!==!1),K=f(!1),X=f(!1),Z=f(void 0);async function W(U){U.preventDefault(),R(K,!0);let M=await N0.reloadBlockedPost(J.post.uri);if(M)R(Z,M,!0);else R(X,!0)}function G(U){let M=U.author.viewer;if(M)return!(M.blockedBy||M.blocking);else return!0}function w(){if(J.post instanceof O5)return;else if(J.post.blockedByUser)return"has blocked you";else if(J.post.blocksUser)return"you've blocked them";else return}var H=c(),q=h(H);{var F=(U)=>{var M=DW(),P=h(M),O=S(x(P),2),j=x(O,!0);C(O);var T=S(O,2);{var E=(b)=>{{let A=C0(w);R5(b,{get post(){return J.post},get status(){return Y(A)}})}};D(T,(b)=>{if(Y(Q))b(E)})}C(P);var N=S(P,2);{var y=(b)=>{var A=SW(),k=x(A);{var $=(l)=>{var n=IW();n.__click=W,V(l,n)},v=(l)=>{var n=d1("ย ");V(l,n)};D(k,(l)=>{if(!Y(K))l($);else l(v,!1)})}C(A),V(b,A)};D(N,(b)=>{if(Y(Q))b(y)})}g(()=>u(j,J.reason)),V(U,M)},B=(U)=>{var M=c(),P=h(M);{var O=(T)=>{var E=jW(),N=h(E),y=S(x(N),2),b=x(y,!0);C(y);var A=S(y,2);{let l=C0(w);R5(A,{get post(){return J.post},get status(){return Y(l)}})}var k=S(A,2);{var $=(l)=>{var n=RW(),o=S(h(n),2);D5(o,{get post(){return Y(Z)},title:"Load thread"}),V(l,n)};D(k,(l)=>{if(G(Y(Z)))l($)})}C(N);var v=S(N,2);q7(v,{get post(){return Y(Z)},get placement(){return J.placement}}),g(()=>u(b,J.reason)),V(T,E)},j=(T)=>{{let E=C0(()=>new P6(J.post.data));Yz(T,{get post(){return Y(E)}})}};D(P,(T)=>{if(Y(Z))T(O);else T(j,!1)},!0)}V(U,M)};D(q,(U)=>{if(!Y(X)&&!Y(Z))U(F);else U(B,!1)})}V(z,H),_()}I0(["click"]);var AW=L("<div><!></div>");function m8(z,J){var Q=c(),K=h(Q);{var X=(W)=>{I1(W,{get post(){return J.post},get placement(){return J.placement}})},Z=(W)=>{var G=AW(),w=x(G);{var H=(F)=>{j5(F,{get post(){return J.post},get placement(){return J.placement},reason:"Blocked post"})},q=(F)=>{var B=c(),U=h(B);{var M=(O)=>{j5(O,{get post(){return J.post},get placement(){return J.placement},reason:"Hidden quote"})},P=(O)=>{Yz(O,{get post(){return J.post}})};D(U,(O)=>{if(J.post instanceof O5)O(M);else O(P,!1)},!0)}V(F,B)};D(w,(F)=>{if(J.post instanceof y6)F(H);else F(q,!1)})}C(G),g(()=>X1(G,1,`post post-${J.placement??""} blocked`,"svelte-qmmoky")),V(W,G)};D(K,(W)=>{if(J.post instanceof S0)W(X);else W(Z,!1)})}V(z,Q)}var[J1,M7]=v9(),NW=L("<!> <!> <!> <!> <!>",1),kW=L('<details class="svelte-rwn0j1"><summary class="svelte-rwn0j1"> </summary> <!></details>'),bW=L('<p class="missing-replies-info svelte-rwn0j1"><i class="fa-solid fa-ban"></i> <!> (likely taken down by moderation)</p>'),TW=L('<p class="missing-replies-info svelte-rwn0j1"><i class="fa-solid fa-ban"></i> Hidden replies not available (post too old)</p>'),EW=L('<div><!> <!> <div class="content svelte-rwn0j1"><!> <!> <!> <!> <!></div></div>');function I1(z,J){i(J,!0);let Q=(s)=>{var V0=NW(),Y0=h(V0);Xz(Y0,{get highlightedMatches(){return X()}});var e=S(Y0,2);{var t=(k0)=>{W7(k0,{})};D(e,(k0)=>{if(K().tags)k0(t)})}var G0=S(e,2);{var O0=(k0)=>{$8(k0,{get embed(){return K().embed}})};D(G0,(k0)=>{if(K().embed&&M(K().embed))k0(O0)})}var H0=S(G0,2);{var F0=(k0)=>{eJ(k0,{get url(){return K().originalFediURL}})};D(H0,(k0)=>{if(K().originalFediURL&&ez(K().originalFediURL))k0(F0)})}var u0=S(H0,2);{var n0=(k0)=>{Y7(k0,{get quoteCount(){return Y(F)}})};D(u0,(k0)=>{if(K().likeCount!==void 0||K().repostCount!==void 0)k0(n0)})}V(s,V0)},K=R0(J,"post",7),X=R0(J,"highlightedMatches",3,void 0),Z=s6(J,["$$slots","$$events","$$legacy","post","placement","highlightedMatches"]),W=f(!1),G=f(x0(K().replies)),w=f(!1),H=f(void 0),q=f(void 0);M7({post:K(),placement:J.placement});let F=f(x0(K().quoteCount));function B(s){R(F,s,!0)}function U(s){if(s instanceof S0)return!0;else if(s instanceof y6)return T0.biohazardsEnabled!==!1;else return!1}function M(s){if(K().originalFediURL){if(s instanceof C5&&s.title?.startsWith("Original post on "))return!1}return!0}function P(s){K().updateDataFromPost(s),R(G,K().replies,!0)}function O(s){let V0=s.filter((Y0)=>Y0!==null);if(Y(G).push(...V0),K().replies=Y(G),V0.length===s.length&&V0.length>0)R(H,void 0);else R(H,s.length-V0.length);R(w,!0)}function j(s){if(R(w,!0),s instanceof Kz)R(q,s,!0);else setTimeout(()=>H8(s),1)}var T={setQuoteCount:B},E=EW();let N;var y=x(E);X7(y,{});var b=S(y,2);{var A=(s)=>{tJ(s,{get collapsed(){return Y(W)},set collapsed(V0){R(W,V0,!0)}})};D(b,(s)=>{if(J.placement=="thread"&&!K().isPageRoot)s(A)})}var k=S(b,2),$=x(k);{var v=(s)=>{var V0=kW(),Y0=x(V0),e=x(Y0,!0);C(Y0);var t=S(Y0,2);Q(t),C(V0),g(()=>u(e,K().muteList?`Muted (${K().muteList})`:"Muted - click to show")),V(s,V0)},l=(s)=>{Q(s)};D($,(s)=>{if(K().muted)s(v);else s(l,!1)})}var n=S($,2);{var o=(s)=>{I1(s,{get post(){return Y(G)[0]},placement:"thread",class:"flat"})},a=(s)=>{var V0=c(),Y0=h(V0);A0(Y0,17,()=>Y(G),(e)=>e.uri,(e,t)=>{var G0=c(),O0=h(G0);{var H0=(F0)=>{m8(F0,{get post(){return Y(t)},placement:"thread"})};D(O0,(F0)=>{if(U(Y(t)))F0(H0)})}V(e,G0)}),V(s,V0)};D(n,(s)=>{if(K().replyCount==1&&Y(G)[0]instanceof S0&&Y(G)[0].author.did==K().author.did)s(o);else s(a,!1)})}var X0=S(n,2);{var Q0=(s)=>{var V0=c(),Y0=h(V0);UJ(Y0,()=>Y(G),(e)=>{var t=c(),G0=h(t);{var O0=(F0)=>{J7(F0,{onLoad:P,onError:j})},H0=(F0)=>{var u0=c(),n0=h(u0);{var k0=(z6)=>{z7(z6,{onLoad:O,onError:j})};D(n0,(z6)=>{if(K().hasHiddenReplies&&T0.biohazardsEnabled!==!1)z6(k0)},!0)}V(F0,u0)};D(G0,(F0)=>{if(K().hasMoreReplies)F0(O0);else F0(H0,!1)})}V(e,t)}),V(s,V0)};D(X0,(s)=>{if(J.placement=="thread"&&!Y(w))s(Q0)})}var U0=S(X0,2);{var D0=(s)=>{var V0=bW(),Y0=S(x(V0),2);{var e=(G0)=>{var O0=d1();g(()=>u(O0,`${Y(H)??""} replies are missing`)),V(G0,O0)},t=(G0)=>{var O0=c(),H0=h(O0);{var F0=(n0)=>{var k0=d1("1 reply is missing");V(n0,k0)},u0=(n0)=>{var k0=d1("Some replies are missing");V(n0,k0)};D(H0,(n0)=>{if(Y(H)==1)n0(F0);else n0(u0,!1)},!0)}V(G0,O0)};D(Y0,(G0)=>{if(Y(H)>1)G0(e);else G0(t,!1)})}c0(),C(V0),V(s,V0)};D(U0,(s)=>{if(Y(H)!==void 0)s(D0)})}var M0=S(U0,2);{var v0=(s)=>{var V0=TW();V(s,V0)};D(M0,(s)=>{if(Y(q))s(v0)})}return C(k),C(E),g(()=>N=X1(E,1,`post post-${J.placement??""} ${(J.class||"")??""}`,"svelte-rwn0j1",N,{muted:K().muted,collapsed:Y(W)})),V(z,E),_(T)}var yW=L('<main class="hashtag svelte-1l2woaq"><header><h2><!></h2></header> <!></main>');function C7(z,J){i(J,!0);let Q=R0(J,"hashtag",7);Q(Q().replace(/^\#/,""));let K=x0([]),X=f(!1),Z=f(!1),W=!1,G=!1,w;V8(async()=>{if(W||G)return;W=!0;try{let U=await N0.getHashtagFeed(Q(),w),M=U.posts.map((P)=>new S0(P));if(R(X,!0),K.push(...M),W=!1,w=U.cursor,!w||K.length==0)G=!0}catch(U){console.log(U),W=!1,R(Z,!0)}});var H=c();b8("xlrj1v",(U)=>{l5(()=>{Y8.title=`#${Q()??""} - Skythread`})});var q=h(H);{var F=(U)=>{var M=yW(),P=x(M),O=x(P),j=x(O);{var T=(y)=>{var b=d1();g(()=>u(b,`Posts tagged: #${Q()??""}`)),V(y,b)},E=(y)=>{var b=d1();g(()=>u(b,`No posts tagged #${Q()??""}.`)),V(y,b)};D(j,(y)=>{if(K.length>0)y(T);else y(E,!1)})}C(O),C(P);var N=S(P,2);A0(N,17,()=>K,(y)=>y.uri,(y,b)=>{I1(y,{get post(){return Y(b)},placement:"feed"})}),C(M),V(U,M)},B=(U)=>{var M=c(),P=h(M);{var O=(j)=>{n6(j,{})};D(P,(j)=>{if(!Y(Z))j(O)},!0)}V(U,M)};D(q,(U)=>{if(Y(X))U(F);else U(B,!1)})}V(z,H),_()}var vW=L('<div id="search" class="svelte-1drcssc"><form method="get" class="svelte-1drcssc">\uD83C\uDF24 <input type="text" placeholder="Paste a thread link or type a #hashtag" class="svelte-1drcssc"/></form></div>');function w9(z,J){i(J,!0);let Q=f(""),K;Z1(()=>{K.focus()});function X(w){w.preventDefault();let H=Y(Q).trim();if(!H)return;if(H.startsWith("at://")){let q=new URL(h8());q.searchParams.set("q",H),location.assign(q.toString())}else if(H.match(/^#?((\p{Letter}|\p{Number})+)$/u)){let q=H.replace(/^#/,"");location.assign(L5(q))}else try{let{user:q,post:F}=Qz(H);location.assign(B6(q,F))}catch(q){console.log(q),alert(q.message||"This is not a valid URL or hashtag")}}var Z=vW(),W=x(Z),G=S(x(W));$0(G),t1(G,(w)=>K=w,()=>K),C(W),C(Z),L1("submit",W,X),P1(G,()=>Y(Q),(w)=>R(Q,w)),V(z,Z),_()}var gW=L('<tr><td class="no svelte-8hgnpr"></td><td class="handle svelte-8hgnpr"><img class="avatar svelte-8hgnpr" alt="Avatar"/> <a target="_blank"> </a></td><td class="count svelte-8hgnpr"> </td></tr>'),hW=L('<table style="display: table;"><thead><tr><th colspan="3" class="svelte-8hgnpr"> </th></tr></thead><tbody></tbody></table>');function U9(z,J){var Q=hW(),K=x(Q),X=x(K),Z=x(X),W=x(Z,!0);C(Z),C(X),C(K);var G=S(K);A0(G,21,()=>J.users,O1,(w,H,q)=>{var F=gW(),B=x(F);B.textContent=q+1;var U=S(B),M=x(U),P=S(M,2),O=x(P,!0);C(P),C(U);var j=S(U),T=x(j,!0);C(j),C(F),g(()=>{d(M,"src",Y(H).avatar),d(P,"href",`https://bsky.app/profile/${Y(H).handle??""}`),u(O,Y(H).handle),u(T,Y(H).count)}),V(w,F)}),C(G),C(Q),g(()=>{X1(Q,1,`scan-result ${J.cssClass??""}`,"svelte-8hgnpr"),u(W,J.header)}),V(z,Q)}class x7{scanStartTime;appView;progressPosts;progressLikeRecords;progressPostLikes;onProgress;abortController;constructor(){this.appView=new e1("public.api.bsky.app"),this.progressPosts=0,this.progressLikeRecords=0,this.progressPostLikes=0}async findLikes(z,J){this.onProgress=J,this.resetProgress(),this.scanStartTime=new Date().getTime(),this.abortController=new AbortController;let Q=this.fetchGivenLikes(z),K=await this.fetchReceivedLikes(z),X=this.sumUpReceivedLikes(K),Z=this.getTopEntries(X),W=await Q,G=this.sumUpGivenLikes(W),w=this.getTopEntries(G),H=await this.appView.getRequest("app.bsky.actor.getProfiles",{actors:w.map((q)=>q.did)},{abortSignal:this.abortController.signal});for(let q of H.profiles){let F=w.find((B)=>B.did==q.did);F.handle=q.handle,F.avatar=q.avatar}return this.scanStartTime=void 0,{givenLikes:w,receivedLikes:Z}}async fetchGivenLikes(z){let J=this.scanStartTime;return await q0.fetchAll("com.atproto.repo.listRecords",{params:{repo:q0.user.did,collection:"app.bsky.feed.like",limit:100},field:"records",breakWhen:(Q)=>Date.parse(Q.value.createdAt)<J-86400*z*1000,onPageLoad:(Q)=>{let K=Q.at(-1);if(!K)return;let X=Date.parse(K.value.createdAt),Z=(J-X)/86400/1000;this.updateProgress({likeRecords:Math.min(1,Z/z)})},abortSignal:this.abortController.signal})}async fetchReceivedLikes(z){let J=this.scanStartTime,K=(await this.appView.loadUserTimeline(q0.user.did,z,{filter:"posts_with_replies",onPageLoad:(Z)=>{let W=Z.at(-1);if(!W)return;let G=i1(W),w=(J-G)/86400/1000;this.updateProgress({posts:Math.min(1,w/z)})},abortSignal:this.abortController.signal})).filter((Z)=>!Z.reason&&Z.post.likeCount>0),X=[];for(let Z=0;Z<K.length;Z+=10){let W=K.slice(Z,Z+10);this.updateProgress({postLikes:Z/K.length});let G=W.map((H)=>{return this.appView.fetchAll("app.bsky.feed.getLikes",{params:{uri:H.post.uri,limit:100},field:"likes",abortSignal:this.abortController.signal})}),w=await Promise.all(G);X=X.concat(w)}return this.updateProgress({postLikes:1}),X.flat()}sumUpReceivedLikes(z){let J={};for(let Q of z){let K=Q.actor.handle;if(!J[K])J[K]={handle:K,count:0,avatar:Q.actor.avatar};J[K].count+=1}return J}sumUpGivenLikes(z){let J={};for(let Q of z){let K=m0(Q.value.subject.uri).repo;if(!J[K])J[K]={did:K,count:0};J[K].count+=1}return J}getTopEntries(z){return Object.entries(z).sort(this.sortResults).map((J)=>J[1]).slice(0,25)}resetProgress(){this.progressPosts=0,this.progressLikeRecords=0,this.progressPostLikes=0,this.onProgress?.(0)}updateProgress(z){if(z.posts)this.progressPosts=z.posts;if(z.likeRecords)this.progressLikeRecords=z.likeRecords;if(z.postLikes)this.progressPostLikes=z.postLikes;let J=0.1*this.progressPosts+0.65*this.progressLikeRecords+0.25*this.progressPostLikes;this.onProgress?.(J)}sortResults(z,J){if(z[1].count<J[1].count)return 1;else if(z[1].count>J[1].count)return-1;else return 0}abortScan(){this.scanStartTime=void 0,this.onProgress=void 0,this.abortController?.abort(),delete this.abortController}}var fW=L('<progress style="display: inline;" class="svelte-16cw7lp"></progress>'),mW=L("<!> <!>",1),$W=L('<main><h2>Like statistics</h2> <form><p>Time range: <input id="like_stats_range" type="range" min="1" max="60" class="svelte-16cw7lp"/> <label for="like_stats_range"> </label></p> <p><input type="submit" class="svelte-16cw7lp"/> <!></p></form> <!></main>');function O7(z,J){i(J,!0);let Q=f(7),K=f(void 0),X=C0(()=>Y(K)!==void 0),Z=f(void 0),W=f(void 0),G=new x7;async function w(y){y.preventDefault();try{if(!Y(X)){R(Z,void 0),R(W,void 0);let b=await G.findLikes(Y(Q),(A)=>{R(K,A,!0)});R(Z,b.givenLikes,!0),R(W,b.receivedLikes,!0),R(K,void 0)}else G.abortScan(),R(K,void 0)}catch(b){if(b.name!=="AbortError")throw b}}var H=$W(),q=S(x(H),2),F=x(q),B=S(x(F));$0(B);var U=S(B,2),M=x(U,!0);C(U),C(F);var P=S(F,2),O=x(P);$0(O);var j=S(O,2);{var T=(y)=>{var b=fW();g(()=>Y6(b,Y(K))),V(y,b)};D(j,(y)=>{if(Y(X))y(T)})}C(P),C(q);var E=S(q,2);{var N=(y)=>{var b=mW(),A=h(b);U9(A,{cssClass:"given-likes",header:"โค๏ธ Likes from you:",get users(){return Y(Z)}});var k=S(A,2);U9(k,{cssClass:"received-likes",header:"\uD83D\uDC9B Likes on your posts:",get users(){return Y(W)}}),V(y,b)};D(E,(y)=>{if(Y(Z)&&Y(W))y(N)})}C(H),g((y)=>{u(M,y),Y6(O,Y(X)?"Cancel":"Start scan")},[()=>E8(Y(Q))]),L1("submit",q,w),P1(B,()=>Y(Q),(y)=>R(Q,y)),V(z,H),_()}var uW="did:web:lycan.feeds.blue#lycan";class Gz{lycanAddress;constructor(z){this.lycanAddress=z??uW}get proxyHeaders(){return{"atproto-proxy":this.lycanAddress}}async getImportStatus(){return await q0.getRequest("blue.feeds.lycan.getImportStatus",null,{headers:this.proxyHeaders})}async startImport(){await q0.postRequest("blue.feeds.lycan.startImport",null,{headers:this.proxyHeaders})}async makeQuery(z,J,Q){let K={collection:z,query:J};if(Q)K.cursor=Q;return await q0.getRequest("blue.feeds.lycan.searchPosts",K,{headers:this.proxyHeaders})}searchPosts(z,J,Q){let K=!1,X=!1,Z;V8(async()=>{if(K||X)return;K=!0;let W=await this.makeQuery(z,J,Z),w=(await q0.loadPosts(W.posts)).map((H)=>new S0(H));if(K=!1,Q.onPostsLoaded({posts:w,terms:W.terms}),Z=W.cursor,!Z)X=!0,Q.onFinish?.()})}}class V9 extends Gz{localLycan;constructor(z){super();this.localLycan=new e1(z)}async getImportStatus(){return await this.localLycan.getRequest("blue.feeds.lycan.getImportStatus",{user:q0.user.did})}async startImport(){await this.localLycan.postRequest("blue.feeds.lycan.startImport",{user:q0.user.did})}async makeQuery(z,J,Q){let K={collection:z,query:J,user:q0.user.did};if(Q)K.cursor=Q;return await this.localLycan.getRequest("blue.feeds.lycan.searchPosts",K)}}var lW=L('<main class="search-page svelte-p7bb5y"><!></main>');function Bz(z,J){var Q=lW(),K=x(Q);G8(K,()=>J.children),C(Q),V(z,Q)}var pW=L('<input type="radio" name="collection"/> <label class="svelte-1xf0p4l"> </label>',1),dW=L(`<form class="svelte-1xf0p4l"><h4>Data not imported yet</h4> <p class="svelte-1xf0p4l">In order to search within your likes and bookmarks, the posts you've liked or saved need to be imported into a database. 51 + This is a one-time process, but it can take several minutes or more, depending on the age of your account.</p> <p class="svelte-1xf0p4l">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. 52 + After the import is complete, the database will be kept up to date automatically going forward.</p> <p class="svelte-1xf0p4l"><input type="submit" value="Start import"/></p></form>`),iW=L('<p><progress class="svelte-1xf0p4l"></progress> <output class="svelte-1xf0p4l"> </output></p>'),_W=L('<div class="import-progress svelte-1xf0p4l"><h4>Import in progress</h4> <p class="import-status"> </p> <!></div>'),cW=L('<div class="lycan-import svelte-1xf0p4l"><!></div>'),sW=L("<p>...</p>"),oW=L('<p class="results-end"> </p>'),aW=L("<!> <!>",1),rW=L('<h2>Archive search</h2> <form class="search-form"><p class="search">Search: <input type="text" class="search-query" autocomplete="off"/></p> <div class="search-collections svelte-1xf0p4l"></div></form> <!> <div class="results"><!></div>',1);function L7(z,J){i(J,!0);let Q=[],K=[{id:"likes",title:"Likes"},{id:"reposts",title:"Reposts"},{id:"quotes",title:"Quotes"},{id:"pins",title:"Pins"}],X=C0(()=>j(J.lycan)),Z=f(!1),W=f(void 0),G=f(void 0),w=f(0),H=f(!1),q,F=f(x0(K[0].id)),B=f(""),U=f(!1),M=f(!1),P=f(x0([])),O=f(x0([]));N();function j(v){if(!v)return new Gz;else if(v=="local"||v=="localhost")return new V9("http://localhost:3000");else if(v.startsWith("local:")||v.startsWith("localhost:")){let l=v.split(":")[1];return new V9(`http://localhost:${l}`)}else return new Gz(`did:web:${J.lycan}#lycan`)}function T(v){v.preventDefault(),y({status:"requested"}),R(H,!0),Y(X).startImport().catch((l)=>{console.error("Failed to start Lycan import",l),A(`Import failed: ${l}`)})}function E(v){if(v.key=="Enter"){v.preventDefault();let l=Y(B).trim().toLowerCase();if(l.length==0||Y(W)!="finished")return;R(P,[],!0),R(H,!1),R(U,!0),R(M,!1),Y(X).searchPosts(Y(F),l,{onPostsLoaded:({posts:n,terms:o})=>{R(U,!1),Y(P).push(...n),R(O,o,!0)},onFinish:()=>{R(M,!0)}})}}async function N(){if(Y(Z))return;R(Z,!0);try{let v=await Y(X).getImportStatus();y(v)}catch(v){A(`Couldn't check import status: ${v}`)}finally{R(Z,!1)}}function y(v){if(console.log(v),!v.status){A("Error checking import status");return}R(W,v.status,!0);let l=["in_progress","scheduled","requested"].includes(v.status);if(R(H,Y(H)||l,!0),v.status=="not_started");else if(l)b(v);else if(v.status=="finished")b({status:"finished",progress:1});else A("Error checking import status");l?k():$()}function b(v){if(R(w,Math.max(0,Math.min(v.progress||0,1)),!0),v.progress==1)R(G,"Import complete โœ“");else if(v.position){let l=new Date(v.position).toLocaleString(T0.dateLocale,{day:"numeric",month:"short",year:"numeric"});R(G,`Downloaded data until: ${l}`)}else if(v.status=="requested")R(G,"Requesting importโ€ฆ");else R(G,"Import startedโ€ฆ")}function A(v){R(W,"error"),R(H,!0),R(G,v,!0),$()}function k(){if(!q)q=setInterval(N,3000)}function $(){if(q)clearInterval(q),q=void 0}Bz(z,{children:(v,l)=>{var n=rW(),o=S(h(n),2),a=x(o),X0=S(x(a));$0(X0),X0.__keydown=E,C(a);var Q0=S(a,2);A0(Q0,21,()=>K,O1,(Y0,e)=>{var t=pW(),G0=h(t);$0(G0);var O0,H0=S(G0,2),F0=x(H0,!0);C(H0),g(()=>{if(d(G0,"id",`collection-${Y(e).id??""}`),O0!==(O0=Y(e).id))G0.value=(G0.__value=Y(e).id)??"";d(H0,"for",`collection-${Y(e).id??""}`),u(F0,Y(e).title)}),c5(Q,[],G0,()=>{return Y(e).id,Y(F)},(u0)=>R(F,u0)),V(Y0,t)}),C(Q0),C(o);var U0=S(o,2);{var D0=(Y0)=>{var e=cW(),t=x(e);{var G0=(H0)=>{var F0=dW();L1("submit",F0,T),V(H0,F0)},O0=(H0)=>{var F0=_W(),u0=S(x(F0),2),n0=x(u0,!0);C(u0);var k0=S(u0,2);{var z6=(t6)=>{var H6=iW(),u8=x(H6),l8=S(u8,2),e6=x(l8);C(l8),C(H6),g((p8)=>{Y6(u8,Y(w)),u(e6,`${p8??""}%`)},[()=>Math.round(Y(w)*100)]),V(t6,H6)};D(k0,(t6)=>{if(Y(W)!="error")t6(z6)})}C(F0),g(()=>u(n0,Y(G))),V(H0,F0)};D(t,(H0)=>{if(Y(W)=="not_started")H0(G0);else H0(O0,!1)})}C(e),V(Y0,e)};D(U0,(Y0)=>{if(Y(H)||Y(W)=="not_started")Y0(D0)})}var M0=S(U0,2),v0=x(M0);{var s=(Y0)=>{var e=sW();V(Y0,e)},V0=(Y0)=>{var e=aW(),t=h(e);A0(t,17,()=>Y(P),(H0)=>H0.uri,(H0,F0)=>{I1(H0,{get post(){return Y(F0)},placement:"feed",get highlightedMatches(){return Y(O)}})});var G0=S(t,2);{var O0=(H0)=>{var F0=oW(),u0=x(F0,!0);C(F0),g(()=>u(u0,Y(P).length>0?"No more results.":"No results.")),V(H0,F0)};D(G0,(H0)=>{if(Y(M))H0(O0)})}V(Y0,e)};D(v0,(Y0)=>{if(Y(U))Y0(s);else Y0(V0,!1)})}C(M0),g(()=>X0.disabled=Y(W)!="finished"),P1(X0,()=>Y(B),(Y0)=>R(B,Y0)),V(v,n)},$$slots:{default:!0}}),_()}I0(["keydown"]);var nW=L("<a>Reply to you</a>"),tW=L("<a> </a>"),eW=L("<a> </a>"),zY=L("<a>Reply</a>"),JY=L('<p class="back"><i class="fa-solid fa-reply"></i> <!></p>');function Hz(z,J){i(J,!0);let Q=C0(()=>m0(J.uri)),K=C0(()=>Y(Q).repo),X=C0(()=>Y(Q).rkey);var Z=JY(),W=S(x(Z),2);{var G=(H)=>{var q=nW();g((F)=>d(q,"href",F),[()=>B6(Y(K),Y(X))]),V(H,q)},w=(H)=>{var q=c(),F=h(q);k8(F,()=>N0.fetchHandleForDid(Y(K)),(B)=>{var U=zY();g((M)=>d(U,"href",M),[()=>B6(Y(K),Y(X))]),V(B,U)},(B,U)=>{var M=tW(),P=x(M);C(M),g((O)=>{d(M,"href",O),u(P,`Reply to @${Y(U)??""}`)},[()=>B6(Y(U),Y(X))]),V(B,M)},(B)=>{var U=eW(),M=x(U);C(U),g((P)=>{d(U,"href",P),u(M,`Reply to ${Y(K)??""}`)},[()=>B6(Y(K),Y(X))]),V(B,U)}),V(H,q)};D(W,(H)=>{if(q0&&Y(K)==q0.user.did)H(G);else H(w,!1)})}C(Z),V(z,Z),_()}var QY=L("<!> <!>",1),KY=L('<main class="notifications svelte-95g2ry"><header><h2>Replies & Mentions:</h2></header> <!></main>');function P7(z,J){i(J,!0);let Q=x0([]),K=f(!1),X=f(!1),Z=!1,W=!1,G;V8(async(B)=>{if(Z||W)return;Z=!0;try{let U=await q0.loadMentions(G),M=U.posts.map((P)=>new S0(P));if(!Y(K)&&M.length>0)R(K,!0);if(Q.push(...M),Z=!1,G=U.cursor,!G)W=!0;else if(M.length==0)B()}catch(U){console.log(U),Z=!1,R(X,!0)}});var w=c();b8("387i67",(B)=>{V1(()=>{Y8.title="Notifications - Skythread"})});var H=h(w);{var q=(B)=>{var U=KY(),M=S(x(U),2);A0(M,17,()=>Q,(P)=>P.uri,(P,O)=>{var j=QY(),T=h(j);{var E=(y)=>{Hz(y,{get uri(){return Y(O).parentReference.uri}})};D(T,(y)=>{if(Y(O).parentReference)y(E)})}var N=S(T,2);I1(N,{get post(){return Y(O)},placement:"feed"}),V(P,j)}),C(U),V(B,U)},F=(B)=>{var U=c(),M=h(U);{var P=(O)=>{n6(O,{})};D(M,(O)=>{if(!Y(X))O(P)},!0)}V(B,U)};D(H,(B)=>{if(Y(K))B(q);else B(F,!1)})}V(z,w),_()}var qZ=(z,J=J6)=>{var Q=ZY(),K=h(Q),X=S(K,2),Z=x(X,!0);C(X);var W=S(X,2),G=x(W,!0);C(W),g(()=>{d(K,"src",J().avatar),u(Z,J().displayName||"โ€“"),u(G,J().handle)}),V(z,Q)},ZY=L('<img class="avatar svelte-1cm32f6" alt="Avatar"/> <span class="name svelte-1cm32f6"> </span> <span class="handle svelte-1cm32f6"> </span>',1),XY=L("<div><!></div>"),WY=L('<div class="autocomplete svelte-1cm32f6"></div>'),YY=L('<div class="user-row svelte-1cm32f6"><!> <a class="remove svelte-1cm32f6" href="#">โœ•</a></div>'),GY=L('<div class="user-choice svelte-1cm32f6"><input type="text" placeholder="Add user" autocomplete="off" class="svelte-1cm32f6"/> <!> <div class="selected-users svelte-1cm32f6"></div></div>');function I7(z,J){i(J,!0);let Q=R0(J,"selectedUsers",27,()=>x0([])),K=f(""),X=f(x0([])),Z=f(-1),W=C0(()=>Q().map((A)=>A.did)),G=C0(()=>Y(X).length>0),w=f(0),H;Z1(()=>{let A=document.body.parentNode;return A.addEventListener("click",U),()=>{A.removeEventListener("click",U)}});function q(){if(H)clearTimeout(H);let A=Y(K).trim();if(A.length>0)H=setTimeout(()=>B(A),100);else U(),H=void 0}function F(A){if(A.key=="Enter"){if(A.preventDefault(),Y(Z)>=0)O(Y(Z))}else if(A.key=="Escape")U();else if(A.key=="ArrowDown"&&Y(X).length>0)A.preventDefault(),M(1);else if(A.key=="ArrowUp"&&Y(X).length>0)A.preventDefault(),M(-1)}async function B(A){let k=await N0.autocompleteUsers(A),$=new Set(Y(W));if(k=k.filter((v)=>!$.has(v.did)),k.length>0)R(X,k,!0),R(Z,0);else U()}function U(){R(X,[],!0),R(Z,-1)}function M(A){if(Y(X).length==0)return;let k=Y(Z)+A;if(k<0)k=Y(X).length-1;else if(k>=Y(X).length)k=0;R(Z,k,!0)}function P(A,k){A.preventDefault(),O(k)}function O(A){let k=Y(X)[A];if(!k)return;Q().push(k),R(K,""),U()}function j(A,k){A.preventDefault(),Q().splice(k,1)}var T=GY(),E=x(T);$0(E),N8(E,!0),E.__input=q,E.__keydown=F;var N=S(E,2);{var y=(A)=>{var k=WY();let $;A0(k,23,()=>Y(X),(v)=>v.did,(v,l,n)=>{var o=XY();let a;o.__mousedown=(Q0)=>{P(Q0,Y(n))};var X0=x(o);qZ(X0,()=>Y(l)),C(o),g(()=>a=X1(o,1,"user-row svelte-1cm32f6",null,a,{highlighted:Y(Z)==Y(n)})),L1("mouseenter",o,()=>{R(Z,Y(n),!0)}),V(v,o)}),C(k),g(()=>$=c6(k,"",$,{display:Y(G)?"block":"none",top:`${Y(w)??""}px`})),V(A,k)};D(N,(A)=>{if(Y(G))A(y)})}var b=S(N,2);A0(b,23,Q,(A)=>A.did,(A,k,$)=>{var v=YY(),l=x(v);qZ(l,()=>Y(k));var n=S(l,2);n.__click=(o)=>{j(o,Y($))},C(v),V(A,v)}),C(b),C(T),P1(E,()=>Y(K),(A)=>R(K,A)),DJ(E,"offsetHeight",(A)=>R(w,A)),V(z,T),_()}I0(["input","keydown","mousedown","click"]);var BY=L('<th class="svelte-vhh361">All posts /d</th> <th class="svelte-vhh361">Own posts /d</th> <th class="svelte-vhh361">Reposts /d</th>',1),HY=L('<th class="svelte-vhh361">Posts /d</th>'),wY=L('<th class="svelte-vhh361">% of timeline</th>'),UY=L('<td class="svelte-vhh361"> </td>'),VY=L('<td class="svelte-vhh361"> </td>'),FY=L('<td class="percent svelte-vhh361"></td>'),qY=L('<tr class="total svelte-vhh361"><td class="no svelte-vhh361"></td><td class="handle svelte-vhh361">Total:</td><!><td class="svelte-vhh361"> </td><!><!></tr>'),MY=L('<td class="svelte-vhh361"> </td>'),CY=L('<td class="svelte-vhh361"> </td>'),xY=L('<td class="percent svelte-vhh361"> </td>'),OY=L('<tr><td class="no svelte-vhh361"></td><td class="handle svelte-vhh361"><img class="avatar svelte-vhh361" alt="Avatar"/> <a target="_blank"> </a></td><!><td class="svelte-vhh361"> </td><!><!></tr>'),LY=L('<table class="scan-result svelte-vhh361"><thead><tr><th class="svelte-vhh361">#</th><th class="svelte-vhh361">Handle</th><!><!></tr></thead><tbody><!><!></tbody></table>');function S7(z,J){i(J,!0);let Q=R0(J,"showReposts",3,!0),K=R0(J,"showPercentages",3,!0),X=R0(J,"showTotal",3,!0);function Z(T){return T>0?T.toFixed(1):"โ€“"}var W=LY(),G=x(W),w=x(G),H=S(x(w),2);{var q=(T)=>{var E=BY();c0(4),V(T,E)},F=(T)=>{var E=HY();V(T,E)};D(H,(T)=>{if(Q())T(q);else T(F,!1)})}var B=S(H);{var U=(T)=>{var E=wY();V(T,E)};D(B,(T)=>{if(K())T(U)})}C(w),C(G);var M=S(G),P=x(M);{var O=(T)=>{var E=qY(),N=S(x(E),2);{var y=(n)=>{var o=UY(),a=x(o,!0);C(o),g((X0)=>u(a,X0),[()=>Z(J.sums.all/J.daysBack)]),V(n,o)};D(N,(n)=>{if(Q())n(y)})}var b=S(N),A=x(b,!0);C(b);var k=S(b);{var $=(n)=>{var o=VY(),a=x(o,!0);C(o),g((X0)=>u(a,X0),[()=>Z(J.sums.reposts/J.daysBack)]),V(n,o)};D(k,(n)=>{if(Q())n($)})}var v=S(k);{var l=(n)=>{var o=FY();V(n,o)};D(v,(n)=>{if(K())n(l)})}C(E),g((n)=>u(A,n),[()=>Z(J.sums.own/J.daysBack)]),V(T,E)};D(P,(T)=>{if(X())T(O)})}var j=S(P);A0(j,17,()=>J.users,O1,(T,E,N)=>{var y=OY(),b=x(y);b.textContent=N+1;var A=S(b),k=x(A),$=S(k,2),v=x($,!0);C($),C(A);var l=S(A);{var n=(M0)=>{var v0=MY(),s=x(v0,!0);C(v0),g((V0)=>u(s,V0),[()=>Z(Y(E).all/J.daysBack)]),V(M0,v0)};D(l,(M0)=>{if(Q())M0(n)})}var o=S(l),a=x(o,!0);C(o);var X0=S(o);{var Q0=(M0)=>{var v0=CY(),s=x(v0,!0);C(v0),g((V0)=>u(s,V0),[()=>Z(Y(E).reposts/J.daysBack)]),V(M0,v0)};D(X0,(M0)=>{if(Q())M0(Q0)})}var U0=S(X0);{var D0=(M0)=>{var v0=xY(),s=x(v0);C(v0),g((V0)=>u(s,`${V0??""}%`),[()=>Z(Y(E).all*100/J.sums.all)]),V(M0,v0)};D(U0,(M0)=>{if(K())M0(D0)})}C(y),g((M0)=>{d(k,"src",Y(E).avatar),d($,"href",`https://bsky.app/profile/${Y(E).handle??""}`),u(v,Y(E).handle),u(a,M0)},[()=>Z(Y(E).own/J.daysBack)]),V(T,y)}),C(M),C(W),V(z,W),_()}class D7{appView;userProgress;onProgress;abortController;constructor(z){this.onProgress=z,this.appView=new e1("public.api.bsky.app"),this.userProgress={}}async scanHomeTimeline(z){let J=new Date().getTime();this.abortController=new AbortController;let Q=await q0.loadHomeTimeline(z,{onPageLoad:(K)=>this.updateProgress(K,J),abortSignal:this.abortController.signal,keepLastPage:!0});return this.generateResults(Q,z,J)}async scanListTimeline(z,J){let Q=new Date().getTime();this.abortController=new AbortController;let K=await q0.loadListTimeline(z,J,{onPageLoad:(X)=>this.updateProgress(X,Q),abortSignal:this.abortController.signal,keepLastPage:!0});return this.generateResults(K,J,Q)}async scanUserTimelines(z,J){let Q=new Date().getTime(),K=z.map((w)=>w.did);this.resetUserProgress(K),this.abortController=new AbortController;let X=this.abortController.signal,Z=K.map((w)=>this.appView.loadUserTimeline(w,J,{filter:"posts_and_author_threads",onPageLoad:(H)=>this.updateUserProgress(w,H,Q,J),abortSignal:X,keepLastPage:!0})),G=(await Promise.all(Z)).flat();return this.generateResults(G,J,Q,{countFetchedDays:!1,users:z})}async scanYourTimeline(z){let J=new Date().getTime();this.abortController=new AbortController;let Q=await q0.loadUserTimeline(q0.user.did,z,{filter:"posts_no_replies",onPageLoad:(K)=>this.updateProgress(K,J),abortSignal:this.abortController.signal,keepLastPage:!0});return this.generateResults(Q,z,J)}generateResults(z,J,Q,K={}){let X=z.at(-1);if(!X)return null;let Z={},W=i1(X),G=(Q-W)/86400/1000,w;if(K.countFetchedDays!==!1)w=Math.min(J,G);else w=J;let H=Q-J*86400*1000;if(z=z.filter((U)=>i1(U)>H),z.reverse(),K.users)for(let U of K.users)Z[U.handle]={handle:U.handle,own:0,reposts:0,avatar:U.avatar};let q=new Set,F={own:0,reposts:0,all:0};for(let U of z){if(U.reply){if(!q.has(U.reply.parent.uri))continue}let M=U.reason?U.reason.by:U.post.author,P=M.handle;if(Z[P]=Z[P]??{handle:P,own:0,reposts:0,avatar:M.avatar},U.reason)Z[P].reposts+=1,F.reposts+=1;else Z[P].own+=1,F.own+=1,q.add(U.post.uri)}let B=Object.values(Z);return B.forEach((U)=>{U.all=U.own+U.reposts}),B.sort((U,M)=>M.all-U.all),F.all=F.own+F.reposts,{users:B,sums:F,fetchedDays:G,daysBack:w}}updateProgress(z,J){let Q=z.at(-1);if(!Q)return;let K=i1(Q),X=(J-K)/86400/1000;this.onProgress?.(X)}resetUserProgress(z){this.userProgress={};for(let J of z)this.userProgress[J]={pages:0,progress:0}}updateUserProgress(z,J,Q,K){let X=J.at(-1);if(!X)return;let Z=i1(X),W=(Q-Z)/86400/1000;this.userProgress[z].pages+=1,this.userProgress[z].progress=Math.min(W/K,1);let G=Object.values(this.userProgress).map((B)=>B.pages/B.progress),w=G.filter((B)=>!isNaN(B)),H=w.reduce((B,U)=>B+U)/w.length*G.length,F=Object.values(this.userProgress).map((B)=>B.pages).reduce((B,U)=>B+U)/H*K;this.onProgress?.(F)}abortScan(){this.abortController?.abort(),delete this.abortController}}var PY=L('<input type="radio" name="scan_type" class="svelte-1khgu5y"/> <label class="svelte-1khgu5y"> </label>',1),IY=L("<option> </option>"),SY=L('<p class="list-choice"><label for="posting_stats_list">Select list:</label> <select id="posting_stats_list" name="scan_list" class="svelte-1khgu5y"></select></p>'),DY=L('<progress class="svelte-1khgu5y"></progress>'),RY=L('<p class="scan-info svelte-1khgu5y"> </p>'),jY=L('<main><h2>Bluesky posting statistics</h2> <form><p>Scan posts from: <!></p> <p>Time range: <input id="posting_stats_range" type="range" min="1" max="60" class="svelte-1khgu5y"/> <label for="posting_stats_range"> </label></p> <!> <!> <p><input type="submit" class="svelte-1khgu5y"/> <!></p></form> <!> <!></main>');function R7(z,J){i(J,!0);let Q=[],K=[{id:"home",title:"Home timeline"},{id:"list",title:"List feed"},{id:"users",title:"Selected users"},{id:"you",title:"Your profile"}],X=f(x0([])),Z=f(7),W=f(x0(K[0].id)),G=f(x0([])),w=f(void 0),H=f(!1),q=f(void 0),F=f(void 0),B=f(void 0),U=f(x0({})),M=f(null),P=new D7((e)=>{R(F,Math.max(Y(F)||0,e),!0)});Z1(()=>{j()});function O(){R(M,null)}async function j(){let e=await q0.loadUserLists();R(X,e.sort((t,G0)=>{let O0=t.name.toLocaleLowerCase(),H0=G0.name.toLocaleLowerCase();return O0.localeCompare(H0)}),!0),R(w,Y(X)[0]?.uri,!0)}async function T(e){e.preventDefault();try{if(!Y(H))await E();else R(H,!1),P.abortScan()}catch(t){if(t.name!=="AbortError")throw t}}async function E(){if(Y(W)=="list"&&!Y(w)||Y(W)=="users"&&Y(G).length==0)return;R(B,void 0),R(M,null),R(q,Y(Z),!0),R(F,0),R(H,!0);let e=new Date().getTime(),t,G0;if(Y(W)=="home")G0={},t=await P.scanHomeTimeline(Y(q));else if(Y(W)=="list")G0={showReposts:!1},t=await P.scanListTimeline(Y(w),Y(q));else if(Y(W)=="users")G0={showTotal:!1,showPercentages:!1},t=await P.scanUserTimelines(Y(G),Y(q));else G0={showTotal:!1,showPercentages:!1},t=await P.scanYourTimeline(Y(q));if(new Date().getTime()-e<150)await new Promise((H0)=>setTimeout(H0,150));R(U,G0,!0),R(M,t,!0),R(H,!1)}var N=jY(),y=S(x(N),2),b=x(y),A=S(x(b));A0(A,17,()=>K,O1,(e,t)=>{var G0=PY(),O0=h(G0);$0(O0),O0.__click=O;var H0,F0=S(O0,2),u0=x(F0,!0);C(F0),g(()=>{if(d(O0,"id",`scan_type_${Y(t).id??""}`),H0!==(H0=Y(t).id))O0.value=(O0.__value=Y(t).id)??"";d(F0,"for",`scan_type_${Y(t).id??""}`),u(u0,Y(t).title)}),c5(Q,[],O0,()=>{return Y(t).id,Y(W)},(n0)=>R(W,n0)),V(e,G0)}),C(b);var k=S(b,2),$=S(x(k));$0($);var v=S($,2),l=x(v,!0);C(v),C(k);var n=S(k,2);{var o=(e)=>{var t=SY(),G0=S(x(t),2);A0(G0,21,()=>Y(X),O1,(O0,H0)=>{var F0=IY(),u0=x(F0);C(F0);var n0={};g(()=>{if(u(u0,`${Y(H0).name??""}ย `),n0!==(n0=Y(H0).uri))F0.value=(F0.__value=Y(H0).uri)??""}),V(O0,F0)}),C(G0),C(t),CJ(G0,()=>Y(w),(O0)=>R(w,O0)),V(e,t)};D(n,(e)=>{if(Y(W)=="list")e(o)})}var a=S(n,2);{var X0=(e)=>{I7(e,{get selectedUsers(){return Y(G)},set selectedUsers(t){R(G,t,!0)}})};D(a,(e)=>{if(Y(W)=="users")e(X0)})}var Q0=S(a,2),U0=x(Q0);$0(U0);var D0=S(U0,2);{var M0=(e)=>{var t=DY();g(()=>{d(t,"max",Y(q)),Y6(t,Y(F))}),V(e,t)};D(D0,(e)=>{if(Y(H))e(M0)})}C(Q0),C(y);var v0=S(y,2);{var s=(e)=>{var t=RY(),G0=x(t,!0);C(t),g(()=>u(G0,Y(B))),V(e,t)};D(v0,(e)=>{if(Y(B))e(s)})}var V0=S(v0,2);{var Y0=(e)=>{S7(e,AJ(()=>Y(U),()=>Y(M)))};D(V0,(e)=>{if(Y(M))e(Y0)})}C(N),g((e)=>{u(l,e),Y6(U0,!Y(H)?"Start scan":"Cancel")},[()=>E8(Y(Z))]),L1("submit",y,T),P1($,()=>Y(Z),(e)=>R(Z,e)),V(z,N),_()}I0(["click"]);var AY=L("<!> <!>",1),NY=L('<main class="quotes svelte-13teqqd"><header><h2><!></h2></header> <!></main>');function j7(z,J){i(J,!0);let Q=!1,K,X=!1,Z=x0([]),W=f(void 0),G=f(!1);V8(async()=>{if(Q||X)return;Q=!0;try{let B=await g8.getQuotes(J.postURL,K),M=(await N0.loadPosts(B.posts)).map((P)=>new S0(P));if(Y(W)===void 0)R(W,B.quoteCount,!0);if(Z.push(...M),Q=!1,K=B.cursor,!K||Z.length==0)X=!0}catch(B){console.log(B),Q=!1,R(G,!0),H8(B)}});var w=c(),H=h(w);{var q=(B)=>{var U=NY(),M=x(U),P=x(M),O=x(P);{var j=(N)=>{var y=d1();g(()=>u(y,`${Y(W)??""} quotes:`)),V(N,y)},T=(N)=>{var y=c(),b=h(y);{var A=($)=>{var v=d1("1 quote:");V($,v)},k=($)=>{var v=d1("No quotes found.");V($,v)};D(b,($)=>{if(Y(W)==1)$(A);else $(k,!1)},!0)}V(N,y)};D(O,(N)=>{if(Y(W)>1)N(j);else N(T,!1)})}C(P),C(M);var E=S(M,2);A0(E,17,()=>Z,(N)=>N.uri,(N,y)=>{var b=AY(),A=h(b);{var k=(v)=>{Hz(v,{get uri(){return Y(y).parentReference.uri}})};D(A,(v)=>{if(Y(y).parentReference)v(k)})}var $=S(A,2);I1($,{get post(){return Y(y)},placement:"quotes"}),V(N,b)}),C(U),V(B,U)},F=(B)=>{var U=c(),M=h(U);{var P=(O)=>{n6(O,{})};D(M,(O)=>{if(!Y(G))O(P)},!0)}V(B,U)};D(H,(B)=>{if(Y(W)!==void 0)B(q);else B(F,!1)})}V(z,w),_()}var kY=L('<div id="tangled" class="svelte-18p55jz"><a href="https://tangled.org/mackuba.eu/skythread" target="_blank" class="svelte-18p55jz"><img src="icons/tangled_dolly.svg" alt="Tangled" class="svelte-18p55jz"/></a></div>');function A7(z){var J=kY();V(z,J)}var bY=L('<p class="back"><i class="fa-solid fa-reply"></i> <a> </a></p>'),TY=L('<div class="back"><!></div>'),EY=L('<p class="back"><i class="fa-solid fa-ban"></i> parent post has been deleted</p>'),yY=L(`<p class="back"><i class="fa-solid fa-ban"></i> something went wrong, this shouldn't happen</p>`);function N7(z,J){i(J,!0);var Q=c(),K=h(Q);{var X=(W)=>{var G=bY(),w=S(x(G),2),H=x(w);C(w),C(G),g((q)=>{d(w,"href",q),u(H,`See parent post (@${J.post.author.handle??""})`)},[()=>v6(J.post)]),V(W,G)},Z=(W)=>{var G=c(),w=h(G);{var H=(F)=>{var B=TY(),U=x(B);j5(U,{get post(){return J.post},placement:"parent",reason:"Parent post blocked"}),C(B),V(F,B)},q=(F)=>{var B=c(),U=h(B);{var M=(O)=>{var j=EY();V(O,j)},P=(O)=>{var j=yY();V(O,j)};D(U,(O)=>{if(J.post instanceof P6)O(M);else O(P,!1)},!0)}V(F,B)};D(w,(F)=>{if(J.post instanceof y6)F(H);else F(q,!1)},!0)}V(W,G)};D(K,(W)=>{if(J.post instanceof S0)W(X);else W(Z,!1)})}V(z,Q),_()}var vY=L("<!> <!>",1),gY=L("<main><!></main>");function F9(z,J){i(J,!0);let Q=s6(J,["$$slots","$$events","$$legacy"]),K=f(void 0),X=f(!1),Z=f(void 0),W;if("url"in Q){let{url:F}=Q;if(F.startsWith("at://"))W=N0.loadThreadByAtURI(F);else W=N0.loadThreadByURL(F)}else{let{author:F,rkey:B}=Q;W=N0.loadThreadById(F,B)}W.then((F)=>{let B=w8(F.thread);if(window.root=B,window.subtreeRoot=B,R(K,B,!0),B instanceof S0)B.data.quoteCount=void 0,g8.getQuoteCount(B.uri).then((U)=>{Y(Z)?.setQuoteCount(U)}).catch((U)=>{console.warn("Couldn't load quote count: "+U)})}).catch((F)=>{H8(F),R(X,!0)});var G=c();b8("64euhl",(F)=>{var B=c(),U=h(B);{var M=(P)=>{l5(()=>{Y8.title=`${Y(K).author.displayName??""}: "${Y(K).text??""}" - Skythread`})};D(U,(P)=>{if(Y(K)instanceof S0)P(M)})}V(F,B)});var w=h(G);{var H=(F)=>{var B=gY(),U=x(B);{var M=(O)=>{var j=vY(),T=h(j);{var E=(b)=>{N7(b,{get post(){return Y(K).parent}})},N=(b)=>{var A=c(),k=h(A);{var $=(v)=>{Wz(v,{get uri(){return Y(K).parentReference.uri}})};D(k,(v)=>{if(Y(K).parentReference)v($)},!0)}V(b,A)};D(T,(b)=>{if(Y(K).parent)b(E);else b(N,!1)})}var y=S(T,2);t1(I1(y,{get post(){return Y(K)},placement:"thread"}),(b)=>R(Z,b,!0),()=>Y(Z)),V(O,j)},P=(O)=>{m8(O,{get post(){return Y(K)},placement:"thread"})};D(U,(O)=>{if(Y(K)instanceof S0)O(M);else O(P,!1)})}C(B),V(F,B)},q=(F)=>{var B=c(),U=h(B);{var M=(P)=>{n6(P,{})};D(U,(P)=>{if(!Y(X))P(M)},!0)}V(F,B)};D(w,(F)=>{if(Y(K))F(H);else F(q,!1)})}V(z,G),_()}class k7{timelinePosts;abortController;constructor(){this.timelinePosts=[]}async fetchTimeline(z,J){let Q=new Date().getTime();this.abortController=new AbortController;let K=await q0.loadHomeTimeline(z,{abortSignal:this.abortController.signal,onPageLoad:(X)=>{let Z=this.calculateProgress(X,Q);if(Z)J(Z)}});this.timelinePosts=K}calculateProgress(z,J){let Q=z.at(-1);if(!Q)return null;let K=i1(Q);return(J-K)/86400/1000}searchPosts(z){if(z.length==0)return[];return this.timelinePosts.filter((Q)=>Q.post.record.text.toLowerCase().includes(z)).map((Q)=>JZ(Q))}abortFetch(){this.abortController?.abort(),delete this.abortController}}var hY=L("<progress></progress>"),fY=L('<p class="archive-status"> </p>'),mY=L('<form class="search-form"><p class="search">Search: <input type="text" class="search-query" autocomplete="off"/></p></form> <div class="results"></div>',1),$Y=L('<h2>Timeline search</h2> <div class="timeline-search"><form><p>Fetch timeline posts: <input id="timeline_search_range" type="range" min="1" max="60" class="svelte-ba7vy9"/> <label for="timeline_search_range"> </label></p> <p><input type="submit"/> <!></p></form> <!> <hr/></div> <!>',1);function b7(z,J){i(J,!0);let Q=f(7),K=f(void 0),X=f(void 0),Z=C0(()=>Y(X)!==void 0),W=f(void 0),G=f(""),w=f(x0([])),H=new k7;async function q(B){B.preventDefault();try{if(!Y(Z))R(K,Y(Q),!0),R(X,0),await H.fetchTimeline(Y(Q),(U)=>{R(X,U,!0)}),R(W,Y(X),!0),R(X,void 0);else R(X,void 0),H.abortFetch()}catch(U){if(U.name!=="AbortError")throw U}}function F(B){if(B.key=="Enter"){B.preventDefault();let U=Y(G).trim().toLowerCase();R(w,H.searchPosts(U),!0)}}Bz(z,{children:(B,U)=>{var M=$Y(),P=S(h(M),2),O=x(P),j=x(O),T=S(x(j));$0(T);var E=S(T,2),N=x(E,!0);C(E),C(j);var y=S(j,2),b=x(y);$0(b);var A=S(b,2);{var k=(o)=>{var a=hY();g(()=>{d(a,"max",Y(K)),Y6(a,Y(X))}),V(o,a)};D(A,(o)=>{if(Y(Z))o(k)})}C(y),C(O);var $=S(O,2);{var v=(o)=>{var a=fY(),X0=x(a);C(a),g((Q0)=>u(X0,`Timeline archive fetched: ${Q0??""}`),[()=>E8(Math.round(Y(W)))]),V(o,a)};D($,(o)=>{if(Y(W))o(v)})}c0(2),C(P);var l=S(P,2);{var n=(o)=>{var a=mY(),X0=h(a),Q0=x(X0),U0=S(x(Q0));$0(U0),U0.__keydown=F,C(Q0),C(X0);var D0=S(X0,2);A0(D0,21,()=>Y(w),(M0)=>M0.uri,(M0,v0)=>{I1(M0,{get post(){return Y(v0)},placement:"feed"})}),C(D0),P1(U0,()=>Y(G),(M0)=>R(G,M0)),V(o,a)};D(l,(o)=>{if(Y(W))o(n)})}g((o)=>{u(N,o),Y6(b,Y(Z)?"Cancel":"Fetch timeline")},[()=>E8(Y(Q))]),L1("submit",O,q),P1(T,()=>Y(Q),(o)=>R(Q,o)),V(B,M)},$$slots:{default:!0}}),_()}I0(["keydown"]);var uY=L("<!> <!> <!> <!>",1);function T7(z,J){i(J,!0);let Q=(q)=>{var F=c(),B=h(F);{var U=(P)=>{P7(P,{})},M=(P)=>{var O=c(),j=h(O);{var T=(N)=>{R7(N,{})},E=(N)=>{var y=c(),b=h(y);{var A=($)=>{O7($,{})},k=($)=>{var v=c(),l=h(v);{var n=(a)=>{var X0=c(),Q0=h(X0);{var U0=(M0)=>{L7(M0,{get lycan(){return J.params.lycan}})},D0=(M0)=>{b7(M0,{})};D(Q0,(M0)=>{if(J.params.mode=="likes")M0(U0);else M0(D0,!1)})}V(a,X0)},o=(a)=>{w9(a,{})};D(l,(a)=>{if(J.params.page=="search")a(n);else a(o,!1)},!0)}V($,v)};D(b,($)=>{if(J.params.page=="like_stats")$(A);else $(k,!1)},!0)}V(N,y)};D(j,(N)=>{if(J.params.page=="posting_stats")N(T);else N(E,!1)},!0)}V(P,O)};D(B,(P)=>{if(J.params.page=="notif")P(U);else P(M,!1)})}V(q,F)};if(J.params.page&&!Y1.loggedIn)P5({showClose:!1});var K=uY(),X=h(K);rJ(X,{});var Z=S(X,2);oJ(Z,{});var W=S(Z,2);A7(W,{});var G=S(W,2);{var w=(q)=>{F9(q,{get url(){return J.params.q}})},H=(q)=>{var F=c(),B=h(F);{var U=(P)=>{F9(P,{get author(){return J.params.author},get rkey(){return J.params.post}})},M=(P)=>{var O=c(),j=h(O);{var T=(N)=>{j7(N,{get postURL(){return J.params.quotes}})},E=(N)=>{var y=c(),b=h(y);{var A=($)=>{C7($,{get hashtag(){return J.params.hash}})},k=($)=>{var v=c(),l=h(v);{var n=(a)=>{var X0=c(),Q0=h(X0);{var U0=(D0)=>{Q(D0)};D(Q0,(D0)=>{if(Y1.loggedIn)D0(U0)})}V(a,X0)},o=(a)=>{w9(a,{})};D(l,(a)=>{if(J.params.page)a(n);else a(o,!1)},!0)}V($,v)};D(b,($)=>{if(J.params.hash)$(A);else $(k,!1)},!0)}V(N,y)};D(j,(N)=>{if(J.params.quotes)N(T);else N(E,!1)},!0)}V(P,O)};D(B,(P)=>{if(J.params.author&&J.params.post)P(U);else P(M,!1)},!0)}V(q,F)};D(G,(q)=>{if(J.params.q)q(w);else q(H,!1)})}V(z,K),_()}function lY(){let z=ZZ(location.search);H5(T7,{target:document.body,props:{params:z}})}document.addEventListener("DOMContentLoaded",lY);})(); 53 + 54 + //# debugId=5F5EE1B7C176518A64756E2164756E21 55 + //# sourceMappingURL=skythread.js.map
-368
embed_component.js
··· 1 - /** 2 - * Renders an embed (e.g. image or quoted post) inside the post view. 3 - */ 4 - 5 - class EmbedComponent { 6 - 7 - /** @param {Post} post, @param {Embed} embed */ 8 - constructor(post, embed) { 9 - this.post = post; 10 - this.embed = embed; 11 - } 12 - 13 - /** @returns {HTMLElement} */ 14 - 15 - buildElement() { 16 - if (this.embed instanceof RawRecordEmbed) { 17 - let quoteView = this.quotedPostPlaceholder(); 18 - this.loadQuotedPost(this.embed.record.uri, quoteView); 19 - return quoteView; 20 - 21 - } else if (this.embed instanceof RawRecordWithMediaEmbed) { 22 - let wrapper = $tag('div'); 23 - 24 - let mediaView = new EmbedComponent(this.post, this.embed.media).buildElement(); 25 - let quoteView = this.quotedPostPlaceholder(); 26 - this.loadQuotedPost(this.embed.record.uri, quoteView); 27 - 28 - wrapper.append(mediaView, quoteView); 29 - return wrapper; 30 - 31 - } else if (this.embed instanceof InlineRecordEmbed) { 32 - return this.buildQuotedPostElement(this.embed); 33 - 34 - } else if (this.embed instanceof InlineRecordWithMediaEmbed) { 35 - let wrapper = $tag('div'); 36 - 37 - let mediaView = new EmbedComponent(this.post, this.embed.media).buildElement(); 38 - let quoteView = this.buildQuotedPostElement(this.embed); 39 - 40 - wrapper.append(mediaView, quoteView); 41 - return wrapper; 42 - 43 - } else if (this.embed instanceof RawImageEmbed || this.embed instanceof InlineImageEmbed) { 44 - return this.buildImagesComponent(this.embed); 45 - 46 - } else if (this.embed instanceof RawLinkEmbed || this.embed instanceof InlineLinkEmbed) { 47 - return this.buildLinkComponent(this.embed); 48 - 49 - } else if (this.embed instanceof RawVideoEmbed || this.embed instanceof InlineVideoEmbed) { 50 - return this.buildVideoComponent(this.embed); 51 - 52 - } else { 53 - return $tag('p', { text: `[${this.embed.type}]` }); 54 - } 55 - } 56 - 57 - /** @returns {HTMLElement} */ 58 - 59 - quotedPostPlaceholder() { 60 - return $tag('div.quote-embed', { 61 - html: '<p class="post placeholder">Loading quoted post...</p>' 62 - }); 63 - } 64 - 65 - /** @param {InlineRecordEmbed | InlineRecordWithMediaEmbed} embed, @returns {HTMLElement} */ 66 - 67 - buildQuotedPostElement(embed) { 68 - let div = $tag('div.quote-embed'); 69 - 70 - if ([Post, BlockedPost, MissingPost, DetachedQuotePost].some(c => embed.post instanceof c)) { 71 - let postView = new PostComponent(embed.post, 'quote').buildElement(); 72 - div.appendChild(postView); 73 - 74 - } else if (embed.post instanceof FeedGeneratorRecord) { 75 - return this.buildFeedGeneratorView(embed.post); 76 - 77 - } else if (embed.post instanceof UserListRecord) { 78 - return this.buildUserListView(embed.post); 79 - 80 - } else if (embed.post instanceof StarterPackRecord) { 81 - return this.buildStarterPackView(embed.post); 82 - 83 - } else { 84 - let p = $tag('p', { text: `[${embed.post.type}]` }); 85 - div.appendChild(p); 86 - } 87 - 88 - return div; 89 - } 90 - 91 - /** @params {RawLinkEmbed | InlineLinkEmbed} embed, @returns {HTMLElement} */ 92 - 93 - buildLinkComponent(embed) { 94 - let hostname; 95 - 96 - try { 97 - hostname = new URL(embed.url).hostname; 98 - } catch (error) { 99 - console.log("Invalid URL:" + error); 100 - 101 - let a = $tag('a', { href: embed.url, text: embed.title || embed.url }); 102 - let p = $tag('p'); 103 - p.append('[Link: ', a, ']'); 104 - return p; 105 - } 106 - 107 - let a = $tag('a.link-card', { href: embed.url, target: '_blank' }); 108 - let box = $tag('div'); 109 - 110 - let domain = $tag('p.domain', { text: hostname }); 111 - let title = $tag('h2', { text: embed.title || embed.url }); 112 - box.append(domain, title); 113 - 114 - if (embed.description) { 115 - let text; 116 - 117 - if (embed.description.length <= 300) { 118 - text = embed.description; 119 - } else { 120 - text = embed.description.slice(0, 300) + 'โ€ฆ'; 121 - } 122 - 123 - box.append($tag('p.description', { text: text })); 124 - } 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); 182 - 183 - let a = $tag('a.link-card.record', { href: link, target: '_blank' }); 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 - } 191 - 192 - let title = $tag('h2', { text: feedgen.title }); 193 - title.append($tag('span.handle', { text: `โ€ข Feed by @${feedgen.author.handle}` })); 194 - box.append(title); 195 - 196 - if (feedgen.description) { 197 - let description = $tag('p.description', { text: feedgen.description }); 198 - box.append(description); 199 - } 200 - 201 - let stats = $tag('p.stats'); 202 - stats.append($tag('i', 'fa-solid fa-heart'), ' '); 203 - stats.append($tag('output', { text: feedgen.likeCount })); 204 - box.append(stats); 205 - 206 - a.append(box); 207 - return a; 208 - } 209 - 210 - /** @param {FeedGeneratorRecord} feedgen, @returns {string} */ 211 - 212 - linkToFeedGenerator(feedgen) { 213 - let { repo, rkey } = atURI(feedgen.uri); 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); 221 - 222 - let a = $tag('a.link-card.record', { href: link, target: '_blank' }); 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 - } 230 - 231 - let listType; 232 - 233 - switch (list.purpose) { 234 - case 'app.bsky.graph.defs#curatelist': 235 - listType = "User list"; 236 - break; 237 - case 'app.bsky.graph.defs#modlist': 238 - listType = "Mute list"; 239 - break; 240 - default: 241 - listType = "List"; 242 - } 243 - 244 - let title = $tag('h2', { text: list.title }); 245 - title.append($tag('span.handle', { text: `โ€ข ${listType} by @${list.author.handle}` })); 246 - box.append(title); 247 - 248 - if (list.description) { 249 - let description = $tag('p.description', { text: list.description }); 250 - box.append(description); 251 - } 252 - 253 - a.append(box); 254 - return a; 255 - } 256 - 257 - /** @param {StarterPackRecord} pack, @returns {HTMLElement} */ 258 - 259 - buildStarterPackView(pack) { 260 - let { repo, rkey } = atURI(pack.uri); 261 - let link = `https://bsky.app/starter-pack/${repo}/${rkey}`; 262 - 263 - let a = $tag('a.link-card.record', { href: link, target: '_blank' }); 264 - let box = $tag('div'); 265 - 266 - let title = $tag('h2', { text: pack.title }); 267 - title.append($tag('span.handle', { text: `โ€ข Starter pack by @${pack.author.handle}` })); 268 - box.append(title); 269 - 270 - if (pack.description) { 271 - let description = $tag('p.description', { text: pack.description }); 272 - box.append(description); 273 - } 274 - 275 - a.append(box); 276 - return a; 277 - } 278 - 279 - /** @param {UserListRecord} list, @returns {string} */ 280 - 281 - linkToUserList(list) { 282 - let { repo, rkey } = atURI(list.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'); 290 - 291 - for (let image of embed.images) { 292 - let p = $tag('p'); 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; 300 - } else { 301 - let cid = image.image.ref['$link']; 302 - a.href = `https://cdn.bsky.app/img/feed_fullsize/plain/${this.post.author.did}/${cid}@jpeg`; 303 - } 304 - 305 - p.append(a); 306 - p.append('] '); 307 - wrapper.append(p); 308 - 309 - if (image.alt) { 310 - let details = $tag('details.image-alt'); 311 - details.append( 312 - $tag('summary', { text: 'Show alt' }), 313 - image.alt 314 - ); 315 - wrapper.appendChild(details); 316 - } 317 - } 318 - 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; 332 - } else { 333 - let cid = embed.video.ref['$link']; 334 - a.href = `https://video.bsky.app/watch/${this.post.author.did}/${cid}/playlist.m3u8`; 335 - } 336 - 337 - let p = $tag('p'); 338 - p.append('[', a, ']'); 339 - wrapper.append(p); 340 - 341 - if (embed.alt) { 342 - let details = $tag('details.image-alt'); 343 - details.append( 344 - $tag('summary', { text: 'Show alt' }), 345 - embed.alt 346 - ); 347 - wrapper.appendChild(details); 348 - } 349 - 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); 357 - 358 - if (record) { 359 - let post = new Post(record); 360 - let postView = new PostComponent(post, 'quote').buildElement(); 361 - div.replaceChildren(postView); 362 - } else { 363 - let post = new MissingPost(this.embed.record); 364 - let postView = new PostComponent(post, 'quote').buildElement(); 365 - div.replaceChildren(postView); 366 - } 367 - } 368 - }
icons/github.png

This is a binary file and will not be displayed.

+56
icons/tangled_dolly.svg
··· 1 + <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 + <!-- Created with Inkscape (http://www.inkscape.org/) --> 3 + 4 + <svg 5 + version="1.1" 6 + id="svg1" 7 + width="24.122343" 8 + height="23.274094" 9 + viewBox="0 0 24.122343 23.274094" 10 + sodipodi:docname="tangled_dolly_face_only.svg" 11 + inkscape:export-filename="tangled_dolly_face_only_white_on_trans.svg" 12 + inkscape:export-xdpi="96" 13 + inkscape:export-ydpi="96" 14 + inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 15 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 16 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 17 + xmlns="http://www.w3.org/2000/svg" 18 + xmlns:svg="http://www.w3.org/2000/svg"> 19 + <sodipodi:namedview 20 + id="namedview1" 21 + pagecolor="#ffffff" 22 + bordercolor="#000000" 23 + borderopacity="0.25" 24 + inkscape:showpageshadow="2" 25 + inkscape:pageopacity="0.0" 26 + inkscape:pagecheckerboard="true" 27 + inkscape:deskcolor="#d5d5d5" 28 + inkscape:zoom="7.0916564" 29 + inkscape:cx="38.84847" 30 + inkscape:cy="31.515909" 31 + inkscape:window-width="1920" 32 + inkscape:window-height="1080" 33 + inkscape:window-x="0" 34 + inkscape:window-y="0" 35 + inkscape:window-maximized="0" 36 + inkscape:current-layer="g1"> 37 + <inkscape:page 38 + x="0" 39 + y="0" 40 + width="24.122343" 41 + height="23.274094" 42 + id="page2" 43 + margin="0" 44 + bleed="0" /> 45 + </sodipodi:namedview> 46 + <g 47 + inkscape:groupmode="layer" 48 + inkscape:label="Image" 49 + id="g1" 50 + transform="translate(-0.4388285,-0.8629527)"> 51 + <path 52 + style="fill:#000000;fill-opacity:1;stroke-width:0.111183;" 53 + d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z" 54 + id="path4" /> 55 + </g> 56 + </svg>
+7 -228
index.html
··· 4 4 <meta charset="UTF-8"> 5 5 <meta http-equiv="Content-Security-Policy" content=" 6 6 default-src 'none'; 7 - script-src 'self' 'sha256-C5RUxaoIkpRux1/UhIgLL5RalHWo6EOGHzWOhCMr8Fs='; 7 + script-src 'self'; 8 8 style-src 'self'; 9 9 img-src 'self' https:; 10 10 font-src 'self'; 11 11 script-src-attr 'none'; 12 12 style-src-attr 'none'; 13 - connect-src https: http://localhost:3000; 13 + connect-src http: https:; 14 14 base-uri 'none'; 15 15 form-action 'none';"> 16 16 17 17 <title>Skythread</title> 18 + 18 19 <link href="./fontawesome/fontawesome.min.css" rel="stylesheet"> 19 20 <link href="./fontawesome/solid.min.css" rel="stylesheet"> 20 21 <link href="./fontawesome/regular.min.css" rel="stylesheet"> 21 22 <link href="style.css" rel="stylesheet"> 23 + <link href="dist/skythread.css" rel="stylesheet"> 24 + <link rel="preload" href="./fontawesome/fa-regular-400.woff2" as="font" type="font/woff2"> 25 + <link rel="preload" href="./fontawesome/fa-solid-900.woff2" as="font" type="font/woff2"> 22 26 </head> 23 27 <body> 24 - <div id="loader"><img src="icons/sunny.png"></div> 25 - 26 - <div id="search"> 27 - <form method="get"> 28 - ๐ŸŒค <input type="text" placeholder="Paste a thread link or type a #hashtag" name="q"> 29 - </form> 30 - </div> 31 - 32 - <div id="github"> 33 - <a href="https://github.com/mackuba/skythread" target="_blank"> 34 - <img src="icons/github.png"> 35 - </a> 36 - </div> 37 - 38 - <div id="account"> 39 - <i class="fa-regular fa-user-circle fa-xl"></i> 40 - </div> 41 - 42 - <div id="account_menu"> 43 - <ul> 44 - <li><a href="#" data-action="incognito" 45 - title="Temporarily load threads as a logged-out user"><span class="check">โœ“ </span>Incognito mode</a></li> 46 - 47 - <li><a href="#" data-action="biohazard" 48 - title="Show links to blocked and hidden comments"><span class="check">โœ“ </span>Show infohazards</a></li> 49 - 50 - <li><a href="#" data-action="login">Log in</a></li> 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 - 61 - <div id="thread"> 62 - </div> 63 - 64 - <div id="login" class="dialog"> 65 - <form method="get"> 66 - <i class="close fa-circle-xmark fa-regular"></i> 67 - <h2>๐ŸŒค Skythread</h2> 68 - <p><input type="text" id="login_handle" required placeholder="name.bsky.social"></p> 69 - <p><input type="password" id="login_password" required 70 - placeholder="&#x2731;&#x2731;&#x2731;&#x2731;&#x2731;&#x2731;&#x2731;&#x2731;"></p> 71 - <p class="info"><a href="#"><i class="fa-regular fa-circle-question"></i> Use an "app password" here</a></p> 72 - <div class="info-box"> 73 - <p>Bluesky API currently doesn't allow apps to request fine-grained permissions, only access to the whole account. However, you can generate an "app password" in the Bluesky app settings for this specific app that you can later revoke at any time.</p> 74 - <p>The password you enter here is only passed to the Bluesky API and isn't saved anywhere. The returned access token is only stored in your browser's local storage. You can see the complete source code of this app <a href="http://github.com/mackuba/skythread" target="_blank">on GitHub</a>.</p> 75 - </div> 76 - <p class="submit"> 77 - <input id="login_submit" type="submit" value="Log in"> 78 - <i id="cloudy" class="fa-solid fa-cloud fa-beat fa-xl"></i> 79 - </p> 80 - </form> 81 - </div> 82 - 83 - <div id="biohazard_dialog" class="dialog"> 84 - <form method="get"> 85 - <i class="close fa-circle-xmark fa-regular"></i> 86 - <h2>โ˜ฃ๏ธ Infohazard Warning</h2> 87 - <p>&ldquo;<em>This thread is not a place of honor... no highly esteemed post is commemorated here... nothing valued is here.</em>&rdquo;</p> 88 - <p>This feature allows access to comments in a thread which were hidden because one of the commenters has blocked another. Bluesky currently hides such comments to avoid escalating conflicts.</p> 89 - <p>Are you sure you want to enter?<br>(You can toggle this in the menu in top-left corner.)</p> 90 - <p class="submit"> 91 - <input type="submit" id="biohazard_show" value="Show me the drama ๐Ÿ˜ˆ"> 92 - <input type="submit" id="biohazard_hide" value="Nope, I'd rather not ๐Ÿ™ˆ"> 93 - </p> 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> 246 - 247 - <script> 248 - init(); 249 - </script> 28 + <script src="dist/skythread.js"></script> 250 29 </body> 251 30 </html>
-11
jsconfig.json
··· 1 - { 2 - "compilerOptions": { 3 - "target": "es2022", 4 - "checkJs": true, 5 - "strict": true, 6 - "noImplicitAny": false, 7 - "exactOptionalPropertyTypes": true, 8 - "useUnknownInCatchVariables": false 9 - }, 10 - "include": ["*.js", "*.d.ts", "lib/**/*.d.ts", "test/**/*.js"] 11 - }
-138
lib/purify.d.ts
··· 1 - /// <reference types="trusted-types"/> 2 - 3 - export as namespace DOMPurify; 4 - export = DOMPurify; 5 - 6 - declare const DOMPurify: createDOMPurifyI; 7 - 8 - type WindowLike = Pick< 9 - typeof globalThis, 10 - | "NodeFilter" 11 - | "Node" 12 - | "Element" 13 - | "HTMLTemplateElement" 14 - | "DocumentFragment" 15 - | "HTMLFormElement" 16 - | "DOMParser" 17 - | "NamedNodeMap" 18 - >; 19 - 20 - interface createDOMPurifyI extends DOMPurify.DOMPurifyI { 21 - (window?: Window | WindowLike): DOMPurify.DOMPurifyI; 22 - } 23 - 24 - declare namespace DOMPurify { 25 - interface DOMPurifyI { 26 - sanitize(source: string | Node): string; 27 - sanitize(source: string | Node, config: Config & { RETURN_TRUSTED_TYPE: true }): TrustedHTML; 28 - sanitize( 29 - source: string | Node, 30 - config: Config & { RETURN_DOM_FRAGMENT?: false | undefined; RETURN_DOM?: false | undefined }, 31 - ): string; 32 - sanitize(source: string | Node, config: Config & { RETURN_DOM_FRAGMENT: true }): DocumentFragment; 33 - sanitize(source: string | Node, config: Config & { RETURN_DOM: true }): HTMLElement; 34 - sanitize(source: string | Node, config: Config): string | HTMLElement | DocumentFragment; 35 - 36 - addHook( 37 - hook: "uponSanitizeElement", 38 - cb: (currentNode: Element, data: SanitizeElementHookEvent, config: Config) => void, 39 - ): void; 40 - addHook( 41 - hook: "uponSanitizeAttribute", 42 - cb: (currentNode: Element, data: SanitizeAttributeHookEvent, config: Config) => void, 43 - ): void; 44 - addHook(hook: HookName, cb: (currentNode: Element, data: HookEvent, config: Config) => void): void; 45 - 46 - setConfig(cfg: Config): void; 47 - clearConfig(): void; 48 - isValidAttribute(tag: string, attr: string, value: string): boolean; 49 - 50 - removeHook(entryPoint: HookName): void; 51 - removeHooks(entryPoint: HookName): void; 52 - removeAllHooks(): void; 53 - 54 - version: string; 55 - removed: any[]; 56 - isSupported: boolean; 57 - } 58 - 59 - interface Config { 60 - ADD_ATTR?: string[] | undefined; 61 - ADD_DATA_URI_TAGS?: string[] | undefined; 62 - ADD_TAGS?: string[] | undefined; 63 - ADD_URI_SAFE_ATTR?: string[] | undefined; 64 - ALLOW_ARIA_ATTR?: boolean | undefined; 65 - ALLOW_DATA_ATTR?: boolean | undefined; 66 - ALLOW_UNKNOWN_PROTOCOLS?: boolean | undefined; 67 - ALLOW_SELF_CLOSE_IN_ATTR?: boolean | undefined; 68 - ALLOWED_ATTR?: string[] | undefined; 69 - ALLOWED_TAGS?: string[] | undefined; 70 - ALLOWED_NAMESPACES?: string[] | undefined; 71 - ALLOWED_URI_REGEXP?: RegExp | undefined; 72 - FORBID_ATTR?: string[] | undefined; 73 - FORBID_CONTENTS?: string[] | undefined; 74 - FORBID_TAGS?: string[] | undefined; 75 - FORCE_BODY?: boolean | undefined; 76 - IN_PLACE?: boolean | undefined; 77 - KEEP_CONTENT?: boolean | undefined; 78 - /** 79 - * change the default namespace from HTML to something different 80 - */ 81 - NAMESPACE?: string | undefined; 82 - PARSER_MEDIA_TYPE?: string | undefined; 83 - RETURN_DOM_FRAGMENT?: boolean | undefined; 84 - /** 85 - * This defaults to `true` starting DOMPurify 2.2.0. Note that setting it to `false` 86 - * might cause XSS from attacks hidden in closed shadowroots in case the browser 87 - * supports Declarative Shadow: DOM https://web.dev/declarative-shadow-dom/ 88 - */ 89 - RETURN_DOM_IMPORT?: boolean | undefined; 90 - RETURN_DOM?: boolean | undefined; 91 - RETURN_TRUSTED_TYPE?: boolean | undefined; 92 - SAFE_FOR_TEMPLATES?: boolean | undefined; 93 - SANITIZE_DOM?: boolean | undefined; 94 - /** @default false */ 95 - SANITIZE_NAMED_PROPS?: boolean | undefined; 96 - USE_PROFILES?: 97 - | false 98 - | { 99 - mathMl?: boolean | undefined; 100 - svg?: boolean | undefined; 101 - svgFilters?: boolean | undefined; 102 - html?: boolean | undefined; 103 - } 104 - | undefined; 105 - WHOLE_DOCUMENT?: boolean | undefined; 106 - CUSTOM_ELEMENT_HANDLING?: { 107 - tagNameCheck?: RegExp | ((tagName: string) => boolean) | null | undefined; 108 - attributeNameCheck?: RegExp | ((lcName: string) => boolean) | null | undefined; 109 - allowCustomizedBuiltInElements?: boolean | undefined; 110 - }; 111 - } 112 - 113 - type HookName = 114 - | "beforeSanitizeElements" 115 - | "uponSanitizeElement" 116 - | "afterSanitizeElements" 117 - | "beforeSanitizeAttributes" 118 - | "uponSanitizeAttribute" 119 - | "afterSanitizeAttributes" 120 - | "beforeSanitizeShadowDOM" 121 - | "uponSanitizeShadowNode" 122 - | "afterSanitizeShadowDOM"; 123 - 124 - type HookEvent = SanitizeElementHookEvent | SanitizeAttributeHookEvent | null; 125 - 126 - interface SanitizeElementHookEvent { 127 - tagName: string; 128 - allowedTags: { [key: string]: boolean }; 129 - } 130 - 131 - interface SanitizeAttributeHookEvent { 132 - attrName: string; 133 - attrValue: string; 134 - keepAttr: boolean; 135 - allowedAttributes: { [key: string]: boolean }; 136 - forceKeepAttr?: boolean | undefined; 137 - } 138 - }
-3
lib/purify.min.js
··· 1 - /*! @license DOMPurify 3.1.5 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.1.5/LICENSE */ 2 - !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=b(Array.prototype.forEach),m=b(Array.prototype.pop),p=b(Array.prototype.push),f=b(String.prototype.toLowerCase),d=b(String.prototype.toString),h=b(String.prototype.match),g=b(String.prototype.replace),T=b(String.prototype.indexOf),y=b(String.prototype.trim),E=b(Object.prototype.hasOwnProperty),_=b(RegExp.prototype.test),A=(N=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];return s(N,t)});var N;function b(e){return function(t){for(var n=arguments.length,o=new Array(n>1?n-1:0),r=1;r<n;r++)o[r-1]=arguments[r];return c(e,t,o)}}function S(e,o){let r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:f;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function R(e){for(let t=0;t<e.length;t++){E(e,t)||(e[t]=null)}return e}function w(t){const n=l(null);for(const[o,r]of e(t)){E(t,o)&&(Array.isArray(r)?n[o]=R(r):r&&"object"==typeof r&&r.constructor===Object?n[o]=w(r):n[o]=r)}return n}function C(e,t){for(;null!==e;){const n=r(e,t);if(n){if(n.get)return b(n.get);if("function"==typeof n.value)return b(n.value)}e=o(e)}return function(){return null}}const L=i(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),D=i(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","filter","font","g","glyph","glyphref","hkern","image","line","lineargradient","marker","mask","metadata","mpath","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),v=i(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),O=i(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),x=i(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),k=i(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),M=i(["#text"]),I=i(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","face","for","headers","height","hidden","high","href","hreflang","id","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","pattern","placeholder","playsinline","popover","popovertarget","popovertargetaction","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","wrap","xmlns","slot"]),U=i(["accent-height","accumulate","additive","alignment-baseline","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dur","edgemode","elevation","end","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-rendering","textlength","type","u1","u2","unicode","values","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),P=i(["accent","accentunder","align","bevelled","close","columnsalign","columnlines","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lspace","lquote","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),F=i(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),H=a(/\{\{[\w\W]*|[\w\W]*\}\}/gm),z=a(/<%[\w\W]*|[\w\W]*%>/gm),B=a(/\${[\w\W]*}/gm),W=a(/^data-[\-\w.\u00B7-\uFFFF]/),G=a(/^aria-[\-\w]+$/),Y=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),j=a(/^(?:\w+script|data):/i),X=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),q=a(/^html$/i),$=a(/^[a-z][.\w]*(-[.\w]+)+$/i);var K=Object.freeze({__proto__:null,MUSTACHE_EXPR:H,ERB_EXPR:z,TMPLIT_EXPR:B,DATA_ATTR:W,ARIA_ATTR:G,IS_ALLOWED_URI:Y,IS_SCRIPT_OR_DATA:j,ATTR_WHITESPACE:X,DOCTYPE_NAME:q,CUSTOM_ELEMENT:$});const V=1,Z=3,J=7,Q=8,ee=9,te=function(){return"undefined"==typeof window?null:window},ne=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}};var oe=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:te();const o=e=>t(e);if(o.version="3.1.5",o.removed=[],!n||!n.document||n.document.nodeType!==ee)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:N,Node:b,Element:R,NodeFilter:H,NamedNodeMap:z=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:B,DOMParser:W,trustedTypes:G}=n,j=R.prototype,X=C(j,"cloneNode"),$=C(j,"nextSibling"),oe=C(j,"childNodes"),re=C(j,"parentNode");if("function"==typeof N){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let ie,ae="";const{implementation:le,createNodeIterator:ce,createDocumentFragment:se,getElementsByTagName:ue}=r,{importNode:me}=a;let pe={};o.isSupported="function"==typeof e&&"function"==typeof re&&le&&void 0!==le.createHTMLDocument;const{MUSTACHE_EXPR:fe,ERB_EXPR:de,TMPLIT_EXPR:he,DATA_ATTR:ge,ARIA_ATTR:Te,IS_SCRIPT_OR_DATA:ye,ATTR_WHITESPACE:Ee,CUSTOM_ELEMENT:_e}=K;let{IS_ALLOWED_URI:Ae}=K,Ne=null;const be=S({},[...L,...D,...v,...x,...M]);let Se=null;const Re=S({},[...I,...U,...P,...F]);let we=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Ce=null,Le=null,De=!0,ve=!0,Oe=!1,xe=!0,ke=!1,Me=!0,Ie=!1,Ue=!1,Pe=!1,Fe=!1,He=!1,ze=!1,Be=!0,We=!1;const Ge="user-content-";let Ye=!0,je=!1,Xe={},qe=null;const $e=S({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Ke=null;const Ve=S({},["audio","video","img","source","image","track"]);let Ze=null;const Je=S({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),Qe="http://www.w3.org/1998/Math/MathML",et="http://www.w3.org/2000/svg",tt="http://www.w3.org/1999/xhtml";let nt=tt,ot=!1,rt=null;const it=S({},[Qe,et,tt],d);let at=null;const lt=["application/xhtml+xml","text/html"],ct="text/html";let st=null,ut=null;const mt=r.createElement("form"),pt=function(e){return e instanceof RegExp||e instanceof Function},ft=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ut||ut!==e){if(e&&"object"==typeof e||(e={}),e=w(e),at=-1===lt.indexOf(e.PARSER_MEDIA_TYPE)?ct:e.PARSER_MEDIA_TYPE,st="application/xhtml+xml"===at?d:f,Ne=E(e,"ALLOWED_TAGS")?S({},e.ALLOWED_TAGS,st):be,Se=E(e,"ALLOWED_ATTR")?S({},e.ALLOWED_ATTR,st):Re,rt=E(e,"ALLOWED_NAMESPACES")?S({},e.ALLOWED_NAMESPACES,d):it,Ze=E(e,"ADD_URI_SAFE_ATTR")?S(w(Je),e.ADD_URI_SAFE_ATTR,st):Je,Ke=E(e,"ADD_DATA_URI_TAGS")?S(w(Ve),e.ADD_DATA_URI_TAGS,st):Ve,qe=E(e,"FORBID_CONTENTS")?S({},e.FORBID_CONTENTS,st):$e,Ce=E(e,"FORBID_TAGS")?S({},e.FORBID_TAGS,st):{},Le=E(e,"FORBID_ATTR")?S({},e.FORBID_ATTR,st):{},Xe=!!E(e,"USE_PROFILES")&&e.USE_PROFILES,De=!1!==e.ALLOW_ARIA_ATTR,ve=!1!==e.ALLOW_DATA_ATTR,Oe=e.ALLOW_UNKNOWN_PROTOCOLS||!1,xe=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,ke=e.SAFE_FOR_TEMPLATES||!1,Me=!1!==e.SAFE_FOR_XML,Ie=e.WHOLE_DOCUMENT||!1,Fe=e.RETURN_DOM||!1,He=e.RETURN_DOM_FRAGMENT||!1,ze=e.RETURN_TRUSTED_TYPE||!1,Pe=e.FORCE_BODY||!1,Be=!1!==e.SANITIZE_DOM,We=e.SANITIZE_NAMED_PROPS||!1,Ye=!1!==e.KEEP_CONTENT,je=e.IN_PLACE||!1,Ae=e.ALLOWED_URI_REGEXP||Y,nt=e.NAMESPACE||tt,we=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&pt(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(we.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&pt(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(we.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(we.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),ke&&(ve=!1),He&&(Fe=!0),Xe&&(Ne=S({},M),Se=[],!0===Xe.html&&(S(Ne,L),S(Se,I)),!0===Xe.svg&&(S(Ne,D),S(Se,U),S(Se,F)),!0===Xe.svgFilters&&(S(Ne,v),S(Se,U),S(Se,F)),!0===Xe.mathMl&&(S(Ne,x),S(Se,P),S(Se,F))),e.ADD_TAGS&&(Ne===be&&(Ne=w(Ne)),S(Ne,e.ADD_TAGS,st)),e.ADD_ATTR&&(Se===Re&&(Se=w(Se)),S(Se,e.ADD_ATTR,st)),e.ADD_URI_SAFE_ATTR&&S(Ze,e.ADD_URI_SAFE_ATTR,st),e.FORBID_CONTENTS&&(qe===$e&&(qe=w(qe)),S(qe,e.FORBID_CONTENTS,st)),Ye&&(Ne["#text"]=!0),Ie&&S(Ne,["html","head","body"]),Ne.table&&(S(Ne,["tbody"]),delete Ce.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');ie=e.TRUSTED_TYPES_POLICY,ae=ie.createHTML("")}else void 0===ie&&(ie=ne(G,c)),null!==ie&&"string"==typeof ae&&(ae=ie.createHTML(""));i&&i(e),ut=e}},dt=S({},["mi","mo","mn","ms","mtext"]),ht=S({},["foreignobject","annotation-xml"]),gt=S({},["title","style","font","a","script"]),Tt=S({},[...D,...v,...O]),yt=S({},[...x,...k]),Et=function(e){let t=re(e);t&&t.tagName||(t={namespaceURI:nt,tagName:"template"});const n=f(e.tagName),o=f(t.tagName);return!!rt[e.namespaceURI]&&(e.namespaceURI===et?t.namespaceURI===tt?"svg"===n:t.namespaceURI===Qe?"svg"===n&&("annotation-xml"===o||dt[o]):Boolean(Tt[n]):e.namespaceURI===Qe?t.namespaceURI===tt?"math"===n:t.namespaceURI===et?"math"===n&&ht[o]:Boolean(yt[n]):e.namespaceURI===tt?!(t.namespaceURI===et&&!ht[o])&&(!(t.namespaceURI===Qe&&!dt[o])&&(!yt[n]&&(gt[n]||!Tt[n]))):!("application/xhtml+xml"!==at||!rt[e.namespaceURI]))},_t=function(e){p(o.removed,{element:e});try{e.parentNode.removeChild(e)}catch(t){e.remove()}},At=function(e,t){try{p(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){p(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e&&!Se[e])if(Fe||He)try{_t(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},Nt=function(e){let t=null,n=null;if(Pe)e="<remove></remove>"+e;else{const t=h(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===at&&nt===tt&&(e='<html xmlns="http://www.w3.org/1999/xhtml"><head></head><body>'+e+"</body></html>");const o=ie?ie.createHTML(e):e;if(nt===tt)try{t=(new W).parseFromString(o,at)}catch(e){}if(!t||!t.documentElement){t=le.createDocument(nt,"template",null);try{t.documentElement.innerHTML=ot?ae:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),nt===tt?ue.call(t,Ie?"html":"body")[0]:Ie?t.documentElement:i},bt=function(e){return ce.call(e.ownerDocument||e,e,H.SHOW_ELEMENT|H.SHOW_COMMENT|H.SHOW_TEXT|H.SHOW_PROCESSING_INSTRUCTION|H.SHOW_CDATA_SECTION,null)},St=function(e){return e instanceof B&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof z)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Rt=function(e){return"function"==typeof b&&e instanceof b},wt=function(e,t,n){pe[e]&&u(pe[e],(e=>{e.call(o,t,n,ut)}))},Ct=function(e){let t=null;if(wt("beforeSanitizeElements",e,null),St(e))return _t(e),!0;const n=st(e.nodeName);if(wt("uponSanitizeElement",e,{tagName:n,allowedTags:Ne}),e.hasChildNodes()&&!Rt(e.firstElementChild)&&_(/<[/\w]/g,e.innerHTML)&&_(/<[/\w]/g,e.textContent))return _t(e),!0;if(e.nodeType===J)return _t(e),!0;if(Me&&e.nodeType===Q&&_(/<[/\w]/g,e.data))return _t(e),!0;if(!Ne[n]||Ce[n]){if(!Ce[n]&&Dt(n)){if(we.tagNameCheck instanceof RegExp&&_(we.tagNameCheck,n))return!1;if(we.tagNameCheck instanceof Function&&we.tagNameCheck(n))return!1}if(Ye&&!qe[n]){const t=re(e)||e.parentNode,n=oe(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o){const r=X(n[o],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,$(e))}}}return _t(e),!0}return e instanceof R&&!Et(e)?(_t(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!_(/<\/no(script|embed|frames)/i,e.innerHTML)?(ke&&e.nodeType===Z&&(t=e.textContent,u([fe,de,he],(e=>{t=g(t,e," ")})),e.textContent!==t&&(p(o.removed,{element:e.cloneNode()}),e.textContent=t)),wt("afterSanitizeElements",e,null),!1):(_t(e),!0)},Lt=function(e,t,n){if(Be&&("id"===t||"name"===t)&&(n in r||n in mt))return!1;if(ve&&!Le[t]&&_(ge,t));else if(De&&_(Te,t));else if(!Se[t]||Le[t]){if(!(Dt(e)&&(we.tagNameCheck instanceof RegExp&&_(we.tagNameCheck,e)||we.tagNameCheck instanceof Function&&we.tagNameCheck(e))&&(we.attributeNameCheck instanceof RegExp&&_(we.attributeNameCheck,t)||we.attributeNameCheck instanceof Function&&we.attributeNameCheck(t))||"is"===t&&we.allowCustomizedBuiltInElements&&(we.tagNameCheck instanceof RegExp&&_(we.tagNameCheck,n)||we.tagNameCheck instanceof Function&&we.tagNameCheck(n))))return!1}else if(Ze[t]);else if(_(Ae,g(n,Ee,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==T(n,"data:")||!Ke[e]){if(Oe&&!_(ye,g(n,Ee,"")));else if(n)return!1}else;return!0},Dt=function(e){return"annotation-xml"!==e&&h(e,_e)},vt=function(e){wt("beforeSanitizeAttributes",e,null);const{attributes:t}=e;if(!t)return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Se};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=st(a);let p="value"===a?c:y(c);if(n.attrName=s,n.attrValue=p,n.keepAttr=!0,n.forceKeepAttr=void 0,wt("uponSanitizeAttribute",e,n),p=n.attrValue,n.forceKeepAttr)continue;if(At(a,e),!n.keepAttr)continue;if(!xe&&_(/\/>/i,p)){At(a,e);continue}if(Me&&_(/((--!?|])>)|<\/(style|title)/i,p)){At(a,e);continue}ke&&u([fe,de,he],(e=>{p=g(p,e," ")}));const f=st(e.nodeName);if(Lt(f,s,p)){if(!We||"id"!==s&&"name"!==s||(At(a,e),p=Ge+p),ie&&"object"==typeof G&&"function"==typeof G.getAttributeType)if(l);else switch(G.getAttributeType(f,s)){case"TrustedHTML":p=ie.createHTML(p);break;case"TrustedScriptURL":p=ie.createScriptURL(p)}try{l?e.setAttributeNS(l,a,p):e.setAttribute(a,p),St(e)?_t(e):m(o.removed)}catch(e){}}}wt("afterSanitizeAttributes",e,null)},Ot=function e(t){let n=null;const o=bt(t);for(wt("beforeSanitizeShadowDOM",t,null);n=o.nextNode();)wt("uponSanitizeShadowNode",n,null),Ct(n)||(n.content instanceof s&&e(n.content),vt(n));wt("afterSanitizeShadowDOM",t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(ot=!e,ot&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Rt(e)){if("function"!=typeof e.toString)throw A("toString is not a function");if("string"!=typeof(e=e.toString()))throw A("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Ue||ft(t),o.removed=[],"string"==typeof e&&(je=!1),je){if(e.nodeName){const t=st(e.nodeName);if(!Ne[t]||Ce[t])throw A("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof b)n=Nt("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===V&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!Fe&&!ke&&!Ie&&-1===e.indexOf("<"))return ie&&ze?ie.createHTML(e):e;if(n=Nt(e),!n)return Fe?null:ze?ae:""}n&&Pe&&_t(n.firstChild);const c=bt(je?e:n);for(;i=c.nextNode();)Ct(i)||(i.content instanceof s&&Ot(i.content),vt(i));if(je)return e;if(Fe){if(He)for(l=se.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(Se.shadowroot||Se.shadowrootmode)&&(l=me.call(a,l,!0)),l}let m=Ie?n.outerHTML:n.innerHTML;return Ie&&Ne["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&_(q,n.ownerDocument.doctype.name)&&(m="<!DOCTYPE "+n.ownerDocument.doctype.name+">\n"+m),ke&&u([fe,de,he],(e=>{m=g(m,e," ")})),ie&&ze?ie.createHTML(m):m},o.setConfig=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};ft(e),Ue=!0},o.clearConfig=function(){ut=null,Ue=!1},o.isValidAttribute=function(e,t,n){ut||ft({});const o=st(e),r=st(t);return Lt(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&(pe[e]=pe[e]||[],p(pe[e],t))},o.removeHook=function(e){if(pe[e])return m(pe[e])},o.removeHooks=function(e){pe[e]&&(pe[e]=[])},o.removeAllHooks=function(){pe={}},o}();return oe})); 3 - //# sourceMappingURL=purify.min.js.map
-1
lib/purify.min.js.map
··· 1 - {"version":3,"file":"purify.min.js","sources":["../src/utils.js","../src/tags.js","../src/attrs.js","../src/regexp.js","../src/purify.js"],"sourcesContent":["const {\n entries,\n setPrototypeOf,\n isFrozen,\n getPrototypeOf,\n getOwnPropertyDescriptor,\n} = Object;\n\nlet { freeze, seal, create } = Object; // eslint-disable-line import/no-mutable-exports\nlet { apply, construct } = typeof Reflect !== 'undefined' && Reflect;\n\nif (!freeze) {\n freeze = function (x) {\n return x;\n };\n}\n\nif (!seal) {\n seal = function (x) {\n return x;\n };\n}\n\nif (!apply) {\n apply = function (fun, thisValue, args) {\n return fun.apply(thisValue, args);\n };\n}\n\nif (!construct) {\n construct = function (Func, args) {\n return new Func(...args);\n };\n}\n\nconst arrayForEach = unapply(Array.prototype.forEach);\nconst arrayIndexOf = unapply(Array.prototype.indexOf);\nconst arrayPop = unapply(Array.prototype.pop);\nconst arrayPush = unapply(Array.prototype.push);\nconst arraySlice = unapply(Array.prototype.slice);\n\nconst stringToLowerCase = unapply(String.prototype.toLowerCase);\nconst stringToString = unapply(String.prototype.toString);\nconst stringMatch = unapply(String.prototype.match);\nconst stringReplace = unapply(String.prototype.replace);\nconst stringIndexOf = unapply(String.prototype.indexOf);\nconst stringTrim = unapply(String.prototype.trim);\n\nconst objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty);\n\nconst regExpTest = unapply(RegExp.prototype.test);\n\nconst typeErrorCreate = unconstruct(TypeError);\n\n/**\n * Creates a new function that calls the given function with a specified thisArg and arguments.\n *\n * @param {Function} func - The function to be wrapped and called.\n * @returns {Function} A new function that calls the given function with a specified thisArg and arguments.\n */\nfunction unapply(func) {\n return (thisArg, ...args) => apply(func, thisArg, args);\n}\n\n/**\n * Creates a new function that constructs an instance of the given constructor function with the provided arguments.\n *\n * @param {Function} func - The constructor function to be wrapped and called.\n * @returns {Function} A new function that constructs an instance of the given constructor function with the provided arguments.\n */\nfunction unconstruct(func) {\n return (...args) => construct(func, args);\n}\n\n/**\n * Add properties to a lookup table\n *\n * @param {Object} set - The set to which elements will be added.\n * @param {Array} array - The array containing elements to be added to the set.\n * @param {Function} transformCaseFunc - An optional function to transform the case of each element before adding to the set.\n * @returns {Object} The modified set with added elements.\n */\nfunction addToSet(set, array, transformCaseFunc = stringToLowerCase) {\n if (setPrototypeOf) {\n // Make 'in' and truthy checks like Boolean(set.constructor)\n // independent of any properties defined on Object.prototype.\n // Prevent prototype setters from intercepting set as a this value.\n setPrototypeOf(set, null);\n }\n\n let l = array.length;\n while (l--) {\n let element = array[l];\n if (typeof element === 'string') {\n const lcElement = transformCaseFunc(element);\n if (lcElement !== element) {\n // Config presets (e.g. tags.js, attrs.js) are immutable.\n if (!isFrozen(array)) {\n array[l] = lcElement;\n }\n\n element = lcElement;\n }\n }\n\n set[element] = true;\n }\n\n return set;\n}\n\n/**\n * Clean up an array to harden against CSPP\n *\n * @param {Array} array - The array to be cleaned.\n * @returns {Array} The cleaned version of the array\n */\nfunction cleanArray(array) {\n for (let index = 0; index < array.length; index++) {\n const isPropertyExist = objectHasOwnProperty(array, index);\n\n if (!isPropertyExist) {\n array[index] = null;\n }\n }\n\n return array;\n}\n\n/**\n * Shallow clone an object\n *\n * @param {Object} object - The object to be cloned.\n * @returns {Object} A new object that copies the original.\n */\nfunction clone(object) {\n const newObject = create(null);\n\n for (const [property, value] of entries(object)) {\n const isPropertyExist = objectHasOwnProperty(object, property);\n\n if (isPropertyExist) {\n if (Array.isArray(value)) {\n newObject[property] = cleanArray(value);\n } else if (\n value &&\n typeof value === 'object' &&\n value.constructor === Object\n ) {\n newObject[property] = clone(value);\n } else {\n newObject[property] = value;\n }\n }\n }\n\n return newObject;\n}\n\n/**\n * This method automatically checks if the prop is function or getter and behaves accordingly.\n *\n * @param {Object} object - The object to look up the getter function in its prototype chain.\n * @param {String} prop - The property name for which to find the getter function.\n * @returns {Function} The getter function found in the prototype chain or a fallback function.\n */\nfunction lookupGetter(object, prop) {\n while (object !== null) {\n const desc = getOwnPropertyDescriptor(object, prop);\n\n if (desc) {\n if (desc.get) {\n return unapply(desc.get);\n }\n\n if (typeof desc.value === 'function') {\n return unapply(desc.value);\n }\n }\n\n object = getPrototypeOf(object);\n }\n\n function fallbackValue() {\n return null;\n }\n\n return fallbackValue;\n}\n\nexport {\n // Array\n arrayForEach,\n arrayIndexOf,\n arrayPop,\n arrayPush,\n arraySlice,\n // Object\n entries,\n freeze,\n getPrototypeOf,\n getOwnPropertyDescriptor,\n isFrozen,\n setPrototypeOf,\n seal,\n clone,\n create,\n objectHasOwnProperty,\n // RegExp\n regExpTest,\n // String\n stringIndexOf,\n stringMatch,\n stringReplace,\n stringToLowerCase,\n stringToString,\n stringTrim,\n // Errors\n typeErrorCreate,\n // Other\n lookupGetter,\n addToSet,\n // Reflect\n unapply,\n unconstruct,\n};\n","import { freeze } from './utils.js';\n\nexport const html = freeze([\n 'a',\n 'abbr',\n 'acronym',\n 'address',\n 'area',\n 'article',\n 'aside',\n 'audio',\n 'b',\n 'bdi',\n 'bdo',\n 'big',\n 'blink',\n 'blockquote',\n 'body',\n 'br',\n 'button',\n 'canvas',\n 'caption',\n 'center',\n 'cite',\n 'code',\n 'col',\n 'colgroup',\n 'content',\n 'data',\n 'datalist',\n 'dd',\n 'decorator',\n 'del',\n 'details',\n 'dfn',\n 'dialog',\n 'dir',\n 'div',\n 'dl',\n 'dt',\n 'element',\n 'em',\n 'fieldset',\n 'figcaption',\n 'figure',\n 'font',\n 'footer',\n 'form',\n 'h1',\n 'h2',\n 'h3',\n 'h4',\n 'h5',\n 'h6',\n 'head',\n 'header',\n 'hgroup',\n 'hr',\n 'html',\n 'i',\n 'img',\n 'input',\n 'ins',\n 'kbd',\n 'label',\n 'legend',\n 'li',\n 'main',\n 'map',\n 'mark',\n 'marquee',\n 'menu',\n 'menuitem',\n 'meter',\n 'nav',\n 'nobr',\n 'ol',\n 'optgroup',\n 'option',\n 'output',\n 'p',\n 'picture',\n 'pre',\n 'progress',\n 'q',\n 'rp',\n 'rt',\n 'ruby',\n 's',\n 'samp',\n 'section',\n 'select',\n 'shadow',\n 'small',\n 'source',\n 'spacer',\n 'span',\n 'strike',\n 'strong',\n 'style',\n 'sub',\n 'summary',\n 'sup',\n 'table',\n 'tbody',\n 'td',\n 'template',\n 'textarea',\n 'tfoot',\n 'th',\n 'thead',\n 'time',\n 'tr',\n 'track',\n 'tt',\n 'u',\n 'ul',\n 'var',\n 'video',\n 'wbr',\n]);\n\n// SVG\nexport const svg = freeze([\n 'svg',\n 'a',\n 'altglyph',\n 'altglyphdef',\n 'altglyphitem',\n 'animatecolor',\n 'animatemotion',\n 'animatetransform',\n 'circle',\n 'clippath',\n 'defs',\n 'desc',\n 'ellipse',\n 'filter',\n 'font',\n 'g',\n 'glyph',\n 'glyphref',\n 'hkern',\n 'image',\n 'line',\n 'lineargradient',\n 'marker',\n 'mask',\n 'metadata',\n 'mpath',\n 'path',\n 'pattern',\n 'polygon',\n 'polyline',\n 'radialgradient',\n 'rect',\n 'stop',\n 'style',\n 'switch',\n 'symbol',\n 'text',\n 'textpath',\n 'title',\n 'tref',\n 'tspan',\n 'view',\n 'vkern',\n]);\n\nexport const svgFilters = freeze([\n 'feBlend',\n 'feColorMatrix',\n 'feComponentTransfer',\n 'feComposite',\n 'feConvolveMatrix',\n 'feDiffuseLighting',\n 'feDisplacementMap',\n 'feDistantLight',\n 'feDropShadow',\n 'feFlood',\n 'feFuncA',\n 'feFuncB',\n 'feFuncG',\n 'feFuncR',\n 'feGaussianBlur',\n 'feImage',\n 'feMerge',\n 'feMergeNode',\n 'feMorphology',\n 'feOffset',\n 'fePointLight',\n 'feSpecularLighting',\n 'feSpotLight',\n 'feTile',\n 'feTurbulence',\n]);\n\n// List of SVG elements that are disallowed by default.\n// We still need to know them so that we can do namespace\n// checks properly in case one wants to add them to\n// allow-list.\nexport const svgDisallowed = freeze([\n 'animate',\n 'color-profile',\n 'cursor',\n 'discard',\n 'font-face',\n 'font-face-format',\n 'font-face-name',\n 'font-face-src',\n 'font-face-uri',\n 'foreignobject',\n 'hatch',\n 'hatchpath',\n 'mesh',\n 'meshgradient',\n 'meshpatch',\n 'meshrow',\n 'missing-glyph',\n 'script',\n 'set',\n 'solidcolor',\n 'unknown',\n 'use',\n]);\n\nexport const mathMl = freeze([\n 'math',\n 'menclose',\n 'merror',\n 'mfenced',\n 'mfrac',\n 'mglyph',\n 'mi',\n 'mlabeledtr',\n 'mmultiscripts',\n 'mn',\n 'mo',\n 'mover',\n 'mpadded',\n 'mphantom',\n 'mroot',\n 'mrow',\n 'ms',\n 'mspace',\n 'msqrt',\n 'mstyle',\n 'msub',\n 'msup',\n 'msubsup',\n 'mtable',\n 'mtd',\n 'mtext',\n 'mtr',\n 'munder',\n 'munderover',\n 'mprescripts',\n]);\n\n// Similarly to SVG, we want to know all MathML elements,\n// even those that we disallow by default.\nexport const mathMlDisallowed = freeze([\n 'maction',\n 'maligngroup',\n 'malignmark',\n 'mlongdiv',\n 'mscarries',\n 'mscarry',\n 'msgroup',\n 'mstack',\n 'msline',\n 'msrow',\n 'semantics',\n 'annotation',\n 'annotation-xml',\n 'mprescripts',\n 'none',\n]);\n\nexport const text = freeze(['#text']);\n","import { freeze } from './utils.js';\n\nexport const html = freeze([\n 'accept',\n 'action',\n 'align',\n 'alt',\n 'autocapitalize',\n 'autocomplete',\n 'autopictureinpicture',\n 'autoplay',\n 'background',\n 'bgcolor',\n 'border',\n 'capture',\n 'cellpadding',\n 'cellspacing',\n 'checked',\n 'cite',\n 'class',\n 'clear',\n 'color',\n 'cols',\n 'colspan',\n 'controls',\n 'controlslist',\n 'coords',\n 'crossorigin',\n 'datetime',\n 'decoding',\n 'default',\n 'dir',\n 'disabled',\n 'disablepictureinpicture',\n 'disableremoteplayback',\n 'download',\n 'draggable',\n 'enctype',\n 'enterkeyhint',\n 'face',\n 'for',\n 'headers',\n 'height',\n 'hidden',\n 'high',\n 'href',\n 'hreflang',\n 'id',\n 'inputmode',\n 'integrity',\n 'ismap',\n 'kind',\n 'label',\n 'lang',\n 'list',\n 'loading',\n 'loop',\n 'low',\n 'max',\n 'maxlength',\n 'media',\n 'method',\n 'min',\n 'minlength',\n 'multiple',\n 'muted',\n 'name',\n 'nonce',\n 'noshade',\n 'novalidate',\n 'nowrap',\n 'open',\n 'optimum',\n 'pattern',\n 'placeholder',\n 'playsinline',\n 'popover',\n 'popovertarget',\n 'popovertargetaction',\n 'poster',\n 'preload',\n 'pubdate',\n 'radiogroup',\n 'readonly',\n 'rel',\n 'required',\n 'rev',\n 'reversed',\n 'role',\n 'rows',\n 'rowspan',\n 'spellcheck',\n 'scope',\n 'selected',\n 'shape',\n 'size',\n 'sizes',\n 'span',\n 'srclang',\n 'start',\n 'src',\n 'srcset',\n 'step',\n 'style',\n 'summary',\n 'tabindex',\n 'title',\n 'translate',\n 'type',\n 'usemap',\n 'valign',\n 'value',\n 'width',\n 'wrap',\n 'xmlns',\n 'slot',\n]);\n\nexport const svg = freeze([\n 'accent-height',\n 'accumulate',\n 'additive',\n 'alignment-baseline',\n 'ascent',\n 'attributename',\n 'attributetype',\n 'azimuth',\n 'basefrequency',\n 'baseline-shift',\n 'begin',\n 'bias',\n 'by',\n 'class',\n 'clip',\n 'clippathunits',\n 'clip-path',\n 'clip-rule',\n 'color',\n 'color-interpolation',\n 'color-interpolation-filters',\n 'color-profile',\n 'color-rendering',\n 'cx',\n 'cy',\n 'd',\n 'dx',\n 'dy',\n 'diffuseconstant',\n 'direction',\n 'display',\n 'divisor',\n 'dur',\n 'edgemode',\n 'elevation',\n 'end',\n 'fill',\n 'fill-opacity',\n 'fill-rule',\n 'filter',\n 'filterunits',\n 'flood-color',\n 'flood-opacity',\n 'font-family',\n 'font-size',\n 'font-size-adjust',\n 'font-stretch',\n 'font-style',\n 'font-variant',\n 'font-weight',\n 'fx',\n 'fy',\n 'g1',\n 'g2',\n 'glyph-name',\n 'glyphref',\n 'gradientunits',\n 'gradienttransform',\n 'height',\n 'href',\n 'id',\n 'image-rendering',\n 'in',\n 'in2',\n 'k',\n 'k1',\n 'k2',\n 'k3',\n 'k4',\n 'kerning',\n 'keypoints',\n 'keysplines',\n 'keytimes',\n 'lang',\n 'lengthadjust',\n 'letter-spacing',\n 'kernelmatrix',\n 'kernelunitlength',\n 'lighting-color',\n 'local',\n 'marker-end',\n 'marker-mid',\n 'marker-start',\n 'markerheight',\n 'markerunits',\n 'markerwidth',\n 'maskcontentunits',\n 'maskunits',\n 'max',\n 'mask',\n 'media',\n 'method',\n 'mode',\n 'min',\n 'name',\n 'numoctaves',\n 'offset',\n 'operator',\n 'opacity',\n 'order',\n 'orient',\n 'orientation',\n 'origin',\n 'overflow',\n 'paint-order',\n 'path',\n 'pathlength',\n 'patterncontentunits',\n 'patterntransform',\n 'patternunits',\n 'points',\n 'preservealpha',\n 'preserveaspectratio',\n 'primitiveunits',\n 'r',\n 'rx',\n 'ry',\n 'radius',\n 'refx',\n 'refy',\n 'repeatcount',\n 'repeatdur',\n 'restart',\n 'result',\n 'rotate',\n 'scale',\n 'seed',\n 'shape-rendering',\n 'specularconstant',\n 'specularexponent',\n 'spreadmethod',\n 'startoffset',\n 'stddeviation',\n 'stitchtiles',\n 'stop-color',\n 'stop-opacity',\n 'stroke-dasharray',\n 'stroke-dashoffset',\n 'stroke-linecap',\n 'stroke-linejoin',\n 'stroke-miterlimit',\n 'stroke-opacity',\n 'stroke',\n 'stroke-width',\n 'style',\n 'surfacescale',\n 'systemlanguage',\n 'tabindex',\n 'targetx',\n 'targety',\n 'transform',\n 'transform-origin',\n 'text-anchor',\n 'text-decoration',\n 'text-rendering',\n 'textlength',\n 'type',\n 'u1',\n 'u2',\n 'unicode',\n 'values',\n 'viewbox',\n 'visibility',\n 'version',\n 'vert-adv-y',\n 'vert-origin-x',\n 'vert-origin-y',\n 'width',\n 'word-spacing',\n 'wrap',\n 'writing-mode',\n 'xchannelselector',\n 'ychannelselector',\n 'x',\n 'x1',\n 'x2',\n 'xmlns',\n 'y',\n 'y1',\n 'y2',\n 'z',\n 'zoomandpan',\n]);\n\nexport const mathMl = freeze([\n 'accent',\n 'accentunder',\n 'align',\n 'bevelled',\n 'close',\n 'columnsalign',\n 'columnlines',\n 'columnspan',\n 'denomalign',\n 'depth',\n 'dir',\n 'display',\n 'displaystyle',\n 'encoding',\n 'fence',\n 'frame',\n 'height',\n 'href',\n 'id',\n 'largeop',\n 'length',\n 'linethickness',\n 'lspace',\n 'lquote',\n 'mathbackground',\n 'mathcolor',\n 'mathsize',\n 'mathvariant',\n 'maxsize',\n 'minsize',\n 'movablelimits',\n 'notation',\n 'numalign',\n 'open',\n 'rowalign',\n 'rowlines',\n 'rowspacing',\n 'rowspan',\n 'rspace',\n 'rquote',\n 'scriptlevel',\n 'scriptminsize',\n 'scriptsizemultiplier',\n 'selection',\n 'separator',\n 'separators',\n 'stretchy',\n 'subscriptshift',\n 'supscriptshift',\n 'symmetric',\n 'voffset',\n 'width',\n 'xmlns',\n]);\n\nexport const xml = freeze([\n 'xlink:href',\n 'xml:id',\n 'xlink:title',\n 'xml:space',\n 'xmlns:xlink',\n]);\n","import { seal } from './utils.js';\n\n// eslint-disable-next-line unicorn/better-regex\nexport const MUSTACHE_EXPR = seal(/\\{\\{[\\w\\W]*|[\\w\\W]*\\}\\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode\nexport const ERB_EXPR = seal(/<%[\\w\\W]*|[\\w\\W]*%>/gm);\nexport const TMPLIT_EXPR = seal(/\\${[\\w\\W]*}/gm);\nexport const DATA_ATTR = seal(/^data-[\\-\\w.\\u00B7-\\uFFFF]/); // eslint-disable-line no-useless-escape\nexport const ARIA_ATTR = seal(/^aria-[\\-\\w]+$/); // eslint-disable-line no-useless-escape\nexport const IS_ALLOWED_URI = seal(\n /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\\-]+(?:[^a-z+.\\-:]|$))/i // eslint-disable-line no-useless-escape\n);\nexport const IS_SCRIPT_OR_DATA = seal(/^(?:\\w+script|data):/i);\nexport const ATTR_WHITESPACE = seal(\n /[\\u0000-\\u0020\\u00A0\\u1680\\u180E\\u2000-\\u2029\\u205F\\u3000]/g // eslint-disable-line no-control-regex\n);\nexport const DOCTYPE_NAME = seal(/^html$/i);\nexport const CUSTOM_ELEMENT = seal(/^[a-z][.\\w]*(-[.\\w]+)+$/i);\n","import * as TAGS from './tags.js';\nimport * as ATTRS from './attrs.js';\nimport * as EXPRESSIONS from './regexp.js';\nimport {\n addToSet,\n clone,\n entries,\n freeze,\n arrayForEach,\n arrayPop,\n arrayPush,\n stringMatch,\n stringReplace,\n stringToLowerCase,\n stringToString,\n stringIndexOf,\n stringTrim,\n regExpTest,\n typeErrorCreate,\n lookupGetter,\n create,\n objectHasOwnProperty,\n} from './utils.js';\n\n// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType\nconst NODE_TYPE = {\n element: 1,\n attribute: 2,\n text: 3,\n cdataSection: 4,\n entityReference: 5, // Deprecated\n entityNode: 6, // Deprecated\n progressingInstruction: 7,\n comment: 8,\n document: 9,\n documentType: 10,\n documentFragment: 11,\n notation: 12, // Deprecated\n};\n\nconst getGlobal = function () {\n return typeof window === 'undefined' ? null : window;\n};\n\n/**\n * Creates a no-op policy for internal use only.\n * Don't export this function outside this module!\n * @param {TrustedTypePolicyFactory} trustedTypes The policy factory.\n * @param {HTMLScriptElement} purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix).\n * @return {TrustedTypePolicy} The policy created (or null, if Trusted Types\n * are not supported or creating the policy failed).\n */\nconst _createTrustedTypesPolicy = function (trustedTypes, purifyHostElement) {\n if (\n typeof trustedTypes !== 'object' ||\n typeof trustedTypes.createPolicy !== 'function'\n ) {\n return null;\n }\n\n // Allow the callers to control the unique policy name\n // by adding a data-tt-policy-suffix to the script element with the DOMPurify.\n // Policy creation with duplicate names throws in Trusted Types.\n let suffix = null;\n const ATTR_NAME = 'data-tt-policy-suffix';\n if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) {\n suffix = purifyHostElement.getAttribute(ATTR_NAME);\n }\n\n const policyName = 'dompurify' + (suffix ? '#' + suffix : '');\n\n try {\n return trustedTypes.createPolicy(policyName, {\n createHTML(html) {\n return html;\n },\n createScriptURL(scriptUrl) {\n return scriptUrl;\n },\n });\n } catch (_) {\n // Policy creation failed (most likely another DOMPurify script has\n // already run). Skip creating the policy, as this will only cause errors\n // if TT are enforced.\n console.warn(\n 'TrustedTypes policy ' + policyName + ' could not be created.'\n );\n return null;\n }\n};\n\nfunction createDOMPurify(window = getGlobal()) {\n const DOMPurify = (root) => createDOMPurify(root);\n\n /**\n * Version label, exposed for easier checks\n * if DOMPurify is up to date or not\n */\n DOMPurify.version = VERSION;\n\n /**\n * Array of elements that DOMPurify removed during sanitation.\n * Empty if nothing was removed.\n */\n DOMPurify.removed = [];\n\n if (\n !window ||\n !window.document ||\n window.document.nodeType !== NODE_TYPE.document\n ) {\n // Not running in a browser, provide a factory function\n // so that you can pass your own Window\n DOMPurify.isSupported = false;\n\n return DOMPurify;\n }\n\n let { document } = window;\n\n const originalDocument = document;\n const currentScript = originalDocument.currentScript;\n const {\n DocumentFragment,\n HTMLTemplateElement,\n Node,\n Element,\n NodeFilter,\n NamedNodeMap = window.NamedNodeMap || window.MozNamedAttrMap,\n HTMLFormElement,\n DOMParser,\n trustedTypes,\n } = window;\n\n const ElementPrototype = Element.prototype;\n\n const cloneNode = lookupGetter(ElementPrototype, 'cloneNode');\n const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling');\n const getChildNodes = lookupGetter(ElementPrototype, 'childNodes');\n const getParentNode = lookupGetter(ElementPrototype, 'parentNode');\n\n // As per issue #47, the web-components registry is inherited by a\n // new document created via createHTMLDocument. As per the spec\n // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)\n // a new empty registry is used when creating a template contents owner\n // document, so we use that as our parent document to ensure nothing\n // is inherited.\n if (typeof HTMLTemplateElement === 'function') {\n const template = document.createElement('template');\n if (template.content && template.content.ownerDocument) {\n document = template.content.ownerDocument;\n }\n }\n\n let trustedTypesPolicy;\n let emptyHTML = '';\n\n const {\n implementation,\n createNodeIterator,\n createDocumentFragment,\n getElementsByTagName,\n } = document;\n const { importNode } = originalDocument;\n\n let hooks = {};\n\n /**\n * Expose whether this browser supports running the full DOMPurify.\n */\n DOMPurify.isSupported =\n typeof entries === 'function' &&\n typeof getParentNode === 'function' &&\n implementation &&\n implementation.createHTMLDocument !== undefined;\n\n const {\n MUSTACHE_EXPR,\n ERB_EXPR,\n TMPLIT_EXPR,\n DATA_ATTR,\n ARIA_ATTR,\n IS_SCRIPT_OR_DATA,\n ATTR_WHITESPACE,\n CUSTOM_ELEMENT,\n } = EXPRESSIONS;\n\n let { IS_ALLOWED_URI } = EXPRESSIONS;\n\n /**\n * We consider the elements and attributes below to be safe. Ideally\n * don't add any new ones but feel free to remove unwanted ones.\n */\n\n /* allowed element names */\n let ALLOWED_TAGS = null;\n const DEFAULT_ALLOWED_TAGS = addToSet({}, [\n ...TAGS.html,\n ...TAGS.svg,\n ...TAGS.svgFilters,\n ...TAGS.mathMl,\n ...TAGS.text,\n ]);\n\n /* Allowed attribute names */\n let ALLOWED_ATTR = null;\n const DEFAULT_ALLOWED_ATTR = addToSet({}, [\n ...ATTRS.html,\n ...ATTRS.svg,\n ...ATTRS.mathMl,\n ...ATTRS.xml,\n ]);\n\n /*\n * Configure how DOMPUrify should handle custom elements and their attributes as well as customized built-in elements.\n * @property {RegExp|Function|null} tagNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any custom elements)\n * @property {RegExp|Function|null} attributeNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any attributes not on the allow list)\n * @property {boolean} allowCustomizedBuiltInElements allow custom elements derived from built-ins if they pass CUSTOM_ELEMENT_HANDLING.tagNameCheck. Default: `false`.\n */\n let CUSTOM_ELEMENT_HANDLING = Object.seal(\n create(null, {\n tagNameCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n attributeNameCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n allowCustomizedBuiltInElements: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: false,\n },\n })\n );\n\n /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */\n let FORBID_TAGS = null;\n\n /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */\n let FORBID_ATTR = null;\n\n /* Decide if ARIA attributes are okay */\n let ALLOW_ARIA_ATTR = true;\n\n /* Decide if custom data attributes are okay */\n let ALLOW_DATA_ATTR = true;\n\n /* Decide if unknown protocols are okay */\n let ALLOW_UNKNOWN_PROTOCOLS = false;\n\n /* Decide if self-closing tags in attributes are allowed.\n * Usually removed due to a mXSS issue in jQuery 3.0 */\n let ALLOW_SELF_CLOSE_IN_ATTR = true;\n\n /* Output should be safe for common template engines.\n * This means, DOMPurify removes data attributes, mustaches and ERB\n */\n let SAFE_FOR_TEMPLATES = false;\n\n /* Output should be safe even for XML used within HTML and alike.\n * This means, DOMPurify removes comments when containing risky content.\n */\n let SAFE_FOR_XML = true;\n\n /* Decide if document with <html>... should be returned */\n let WHOLE_DOCUMENT = false;\n\n /* Track whether config is already set on this instance of DOMPurify. */\n let SET_CONFIG = false;\n\n /* Decide if all elements (e.g. style, script) must be children of\n * document.body. By default, browsers might move them to document.head */\n let FORCE_BODY = false;\n\n /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html\n * string (or a TrustedHTML object if Trusted Types are supported).\n * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead\n */\n let RETURN_DOM = false;\n\n /* Decide if a DOM `DocumentFragment` should be returned, instead of a html\n * string (or a TrustedHTML object if Trusted Types are supported) */\n let RETURN_DOM_FRAGMENT = false;\n\n /* Try to return a Trusted Type object instead of a string, return a string in\n * case Trusted Types are not supported */\n let RETURN_TRUSTED_TYPE = false;\n\n /* Output should be free from DOM clobbering attacks?\n * This sanitizes markups named with colliding, clobberable built-in DOM APIs.\n */\n let SANITIZE_DOM = true;\n\n /* Achieve full DOM Clobbering protection by isolating the namespace of named\n * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules.\n *\n * HTML/DOM spec rules that enable DOM Clobbering:\n * - Named Access on Window (ยง7.3.3)\n * - DOM Tree Accessors (ยง3.1.5)\n * - Form Element Parent-Child Relations (ยง4.10.3)\n * - Iframe srcdoc / Nested WindowProxies (ยง4.8.5)\n * - HTMLCollection (ยง4.2.10.2)\n *\n * Namespace isolation is implemented by prefixing `id` and `name` attributes\n * with a constant string, i.e., `user-content-`\n */\n let SANITIZE_NAMED_PROPS = false;\n const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-';\n\n /* Keep element content when removing element? */\n let KEEP_CONTENT = true;\n\n /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead\n * of importing it into a new Document and returning a sanitized copy */\n let IN_PLACE = false;\n\n /* Allow usage of profiles like html, svg and mathMl */\n let USE_PROFILES = {};\n\n /* Tags to ignore content of when KEEP_CONTENT is true */\n let FORBID_CONTENTS = null;\n const DEFAULT_FORBID_CONTENTS = addToSet({}, [\n 'annotation-xml',\n 'audio',\n 'colgroup',\n 'desc',\n 'foreignobject',\n 'head',\n 'iframe',\n 'math',\n 'mi',\n 'mn',\n 'mo',\n 'ms',\n 'mtext',\n 'noembed',\n 'noframes',\n 'noscript',\n 'plaintext',\n 'script',\n 'style',\n 'svg',\n 'template',\n 'thead',\n 'title',\n 'video',\n 'xmp',\n ]);\n\n /* Tags that are safe for data: URIs */\n let DATA_URI_TAGS = null;\n const DEFAULT_DATA_URI_TAGS = addToSet({}, [\n 'audio',\n 'video',\n 'img',\n 'source',\n 'image',\n 'track',\n ]);\n\n /* Attributes safe for values like \"javascript:\" */\n let URI_SAFE_ATTRIBUTES = null;\n const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, [\n 'alt',\n 'class',\n 'for',\n 'id',\n 'label',\n 'name',\n 'pattern',\n 'placeholder',\n 'role',\n 'summary',\n 'title',\n 'value',\n 'style',\n 'xmlns',\n ]);\n\n const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';\n const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';\n const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';\n /* Document namespace */\n let NAMESPACE = HTML_NAMESPACE;\n let IS_EMPTY_INPUT = false;\n\n /* Allowed XHTML+XML namespaces */\n let ALLOWED_NAMESPACES = null;\n const DEFAULT_ALLOWED_NAMESPACES = addToSet(\n {},\n [MATHML_NAMESPACE, SVG_NAMESPACE, HTML_NAMESPACE],\n stringToString\n );\n\n /* Parsing of strict XHTML documents */\n let PARSER_MEDIA_TYPE = null;\n const SUPPORTED_PARSER_MEDIA_TYPES = ['application/xhtml+xml', 'text/html'];\n const DEFAULT_PARSER_MEDIA_TYPE = 'text/html';\n let transformCaseFunc = null;\n\n /* Keep a reference to config to pass to hooks */\n let CONFIG = null;\n\n /* Ideally, do not touch anything below this line */\n /* ______________________________________________ */\n\n const formElement = document.createElement('form');\n\n const isRegexOrFunction = function (testValue) {\n return testValue instanceof RegExp || testValue instanceof Function;\n };\n\n /**\n * _parseConfig\n *\n * @param {Object} cfg optional config literal\n */\n // eslint-disable-next-line complexity\n const _parseConfig = function (cfg = {}) {\n if (CONFIG && CONFIG === cfg) {\n return;\n }\n\n /* Shield configuration object from tampering */\n if (!cfg || typeof cfg !== 'object') {\n cfg = {};\n }\n\n /* Shield configuration object from prototype pollution */\n cfg = clone(cfg);\n\n PARSER_MEDIA_TYPE =\n // eslint-disable-next-line unicorn/prefer-includes\n SUPPORTED_PARSER_MEDIA_TYPES.indexOf(cfg.PARSER_MEDIA_TYPE) === -1\n ? DEFAULT_PARSER_MEDIA_TYPE\n : cfg.PARSER_MEDIA_TYPE;\n\n // HTML tags and attributes are not case-sensitive, converting to lowercase. Keeping XHTML as is.\n transformCaseFunc =\n PARSER_MEDIA_TYPE === 'application/xhtml+xml'\n ? stringToString\n : stringToLowerCase;\n\n /* Set configuration parameters */\n ALLOWED_TAGS = objectHasOwnProperty(cfg, 'ALLOWED_TAGS')\n ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc)\n : DEFAULT_ALLOWED_TAGS;\n ALLOWED_ATTR = objectHasOwnProperty(cfg, 'ALLOWED_ATTR')\n ? addToSet({}, cfg.ALLOWED_ATTR, transformCaseFunc)\n : DEFAULT_ALLOWED_ATTR;\n ALLOWED_NAMESPACES = objectHasOwnProperty(cfg, 'ALLOWED_NAMESPACES')\n ? addToSet({}, cfg.ALLOWED_NAMESPACES, stringToString)\n : DEFAULT_ALLOWED_NAMESPACES;\n URI_SAFE_ATTRIBUTES = objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR')\n ? addToSet(\n clone(DEFAULT_URI_SAFE_ATTRIBUTES), // eslint-disable-line indent\n cfg.ADD_URI_SAFE_ATTR, // eslint-disable-line indent\n transformCaseFunc // eslint-disable-line indent\n ) // eslint-disable-line indent\n : DEFAULT_URI_SAFE_ATTRIBUTES;\n DATA_URI_TAGS = objectHasOwnProperty(cfg, 'ADD_DATA_URI_TAGS')\n ? addToSet(\n clone(DEFAULT_DATA_URI_TAGS), // eslint-disable-line indent\n cfg.ADD_DATA_URI_TAGS, // eslint-disable-line indent\n transformCaseFunc // eslint-disable-line indent\n ) // eslint-disable-line indent\n : DEFAULT_DATA_URI_TAGS;\n FORBID_CONTENTS = objectHasOwnProperty(cfg, 'FORBID_CONTENTS')\n ? addToSet({}, cfg.FORBID_CONTENTS, transformCaseFunc)\n : DEFAULT_FORBID_CONTENTS;\n FORBID_TAGS = objectHasOwnProperty(cfg, 'FORBID_TAGS')\n ? addToSet({}, cfg.FORBID_TAGS, transformCaseFunc)\n : {};\n FORBID_ATTR = objectHasOwnProperty(cfg, 'FORBID_ATTR')\n ? addToSet({}, cfg.FORBID_ATTR, transformCaseFunc)\n : {};\n USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES')\n ? cfg.USE_PROFILES\n : false;\n ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; // Default true\n ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; // Default true\n ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; // Default false\n ALLOW_SELF_CLOSE_IN_ATTR = cfg.ALLOW_SELF_CLOSE_IN_ATTR !== false; // Default true\n SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false; // Default false\n SAFE_FOR_XML = cfg.SAFE_FOR_XML !== false; // Default true\n WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false; // Default false\n RETURN_DOM = cfg.RETURN_DOM || false; // Default false\n RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false; // Default false\n RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false; // Default false\n FORCE_BODY = cfg.FORCE_BODY || false; // Default false\n SANITIZE_DOM = cfg.SANITIZE_DOM !== false; // Default true\n SANITIZE_NAMED_PROPS = cfg.SANITIZE_NAMED_PROPS || false; // Default false\n KEEP_CONTENT = cfg.KEEP_CONTENT !== false; // Default true\n IN_PLACE = cfg.IN_PLACE || false; // Default false\n IS_ALLOWED_URI = cfg.ALLOWED_URI_REGEXP || EXPRESSIONS.IS_ALLOWED_URI;\n NAMESPACE = cfg.NAMESPACE || HTML_NAMESPACE;\n CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {};\n if (\n cfg.CUSTOM_ELEMENT_HANDLING &&\n isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck)\n ) {\n CUSTOM_ELEMENT_HANDLING.tagNameCheck =\n cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck;\n }\n\n if (\n cfg.CUSTOM_ELEMENT_HANDLING &&\n isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)\n ) {\n CUSTOM_ELEMENT_HANDLING.attributeNameCheck =\n cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck;\n }\n\n if (\n cfg.CUSTOM_ELEMENT_HANDLING &&\n typeof cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements ===\n 'boolean'\n ) {\n CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements =\n cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements;\n }\n\n if (SAFE_FOR_TEMPLATES) {\n ALLOW_DATA_ATTR = false;\n }\n\n if (RETURN_DOM_FRAGMENT) {\n RETURN_DOM = true;\n }\n\n /* Parse profile info */\n if (USE_PROFILES) {\n ALLOWED_TAGS = addToSet({}, TAGS.text);\n ALLOWED_ATTR = [];\n if (USE_PROFILES.html === true) {\n addToSet(ALLOWED_TAGS, TAGS.html);\n addToSet(ALLOWED_ATTR, ATTRS.html);\n }\n\n if (USE_PROFILES.svg === true) {\n addToSet(ALLOWED_TAGS, TAGS.svg);\n addToSet(ALLOWED_ATTR, ATTRS.svg);\n addToSet(ALLOWED_ATTR, ATTRS.xml);\n }\n\n if (USE_PROFILES.svgFilters === true) {\n addToSet(ALLOWED_TAGS, TAGS.svgFilters);\n addToSet(ALLOWED_ATTR, ATTRS.svg);\n addToSet(ALLOWED_ATTR, ATTRS.xml);\n }\n\n if (USE_PROFILES.mathMl === true) {\n addToSet(ALLOWED_TAGS, TAGS.mathMl);\n addToSet(ALLOWED_ATTR, ATTRS.mathMl);\n addToSet(ALLOWED_ATTR, ATTRS.xml);\n }\n }\n\n /* Merge configuration parameters */\n if (cfg.ADD_TAGS) {\n if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) {\n ALLOWED_TAGS = clone(ALLOWED_TAGS);\n }\n\n addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc);\n }\n\n if (cfg.ADD_ATTR) {\n if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) {\n ALLOWED_ATTR = clone(ALLOWED_ATTR);\n }\n\n addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc);\n }\n\n if (cfg.ADD_URI_SAFE_ATTR) {\n addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc);\n }\n\n if (cfg.FORBID_CONTENTS) {\n if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {\n FORBID_CONTENTS = clone(FORBID_CONTENTS);\n }\n\n addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc);\n }\n\n /* Add #text in case KEEP_CONTENT is set to true */\n if (KEEP_CONTENT) {\n ALLOWED_TAGS['#text'] = true;\n }\n\n /* Add html, head and body to ALLOWED_TAGS in case WHOLE_DOCUMENT is true */\n if (WHOLE_DOCUMENT) {\n addToSet(ALLOWED_TAGS, ['html', 'head', 'body']);\n }\n\n /* Add tbody to ALLOWED_TAGS in case tables are permitted, see #286, #365 */\n if (ALLOWED_TAGS.table) {\n addToSet(ALLOWED_TAGS, ['tbody']);\n delete FORBID_TAGS.tbody;\n }\n\n if (cfg.TRUSTED_TYPES_POLICY) {\n if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') {\n throw typeErrorCreate(\n 'TRUSTED_TYPES_POLICY configuration option must provide a \"createHTML\" hook.'\n );\n }\n\n if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') {\n throw typeErrorCreate(\n 'TRUSTED_TYPES_POLICY configuration option must provide a \"createScriptURL\" hook.'\n );\n }\n\n // Overwrite existing TrustedTypes policy.\n trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY;\n\n // Sign local variables required by `sanitize`.\n emptyHTML = trustedTypesPolicy.createHTML('');\n } else {\n // Uninitialized policy, attempt to initialize the internal dompurify policy.\n if (trustedTypesPolicy === undefined) {\n trustedTypesPolicy = _createTrustedTypesPolicy(\n trustedTypes,\n currentScript\n );\n }\n\n // If creating the internal policy succeeded sign internal variables.\n if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') {\n emptyHTML = trustedTypesPolicy.createHTML('');\n }\n }\n\n // Prevent further manipulation of configuration.\n // Not available in IE8, Safari 5, etc.\n if (freeze) {\n freeze(cfg);\n }\n\n CONFIG = cfg;\n };\n\n const MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, [\n 'mi',\n 'mo',\n 'mn',\n 'ms',\n 'mtext',\n ]);\n\n const HTML_INTEGRATION_POINTS = addToSet({}, [\n 'foreignobject',\n 'annotation-xml',\n ]);\n\n // Certain elements are allowed in both SVG and HTML\n // namespace. We need to specify them explicitly\n // so that they don't get erroneously deleted from\n // HTML namespace.\n const COMMON_SVG_AND_HTML_ELEMENTS = addToSet({}, [\n 'title',\n 'style',\n 'font',\n 'a',\n 'script',\n ]);\n\n /* Keep track of all possible SVG and MathML tags\n * so that we can perform the namespace checks\n * correctly. */\n const ALL_SVG_TAGS = addToSet({}, [\n ...TAGS.svg,\n ...TAGS.svgFilters,\n ...TAGS.svgDisallowed,\n ]);\n const ALL_MATHML_TAGS = addToSet({}, [\n ...TAGS.mathMl,\n ...TAGS.mathMlDisallowed,\n ]);\n\n /**\n * @param {Element} element a DOM element whose namespace is being checked\n * @returns {boolean} Return false if the element has a\n * namespace that a spec-compliant parser would never\n * return. Return true otherwise.\n */\n const _checkValidNamespace = function (element) {\n let parent = getParentNode(element);\n\n // In JSDOM, if we're inside shadow DOM, then parentNode\n // can be null. We just simulate parent in this case.\n if (!parent || !parent.tagName) {\n parent = {\n namespaceURI: NAMESPACE,\n tagName: 'template',\n };\n }\n\n const tagName = stringToLowerCase(element.tagName);\n const parentTagName = stringToLowerCase(parent.tagName);\n\n if (!ALLOWED_NAMESPACES[element.namespaceURI]) {\n return false;\n }\n\n if (element.namespaceURI === SVG_NAMESPACE) {\n // The only way to switch from HTML namespace to SVG\n // is via <svg>. If it happens via any other tag, then\n // it should be killed.\n if (parent.namespaceURI === HTML_NAMESPACE) {\n return tagName === 'svg';\n }\n\n // The only way to switch from MathML to SVG is via`\n // svg if parent is either <annotation-xml> or MathML\n // text integration points.\n if (parent.namespaceURI === MATHML_NAMESPACE) {\n return (\n tagName === 'svg' &&\n (parentTagName === 'annotation-xml' ||\n MATHML_TEXT_INTEGRATION_POINTS[parentTagName])\n );\n }\n\n // We only allow elements that are defined in SVG\n // spec. All others are disallowed in SVG namespace.\n return Boolean(ALL_SVG_TAGS[tagName]);\n }\n\n if (element.namespaceURI === MATHML_NAMESPACE) {\n // The only way to switch from HTML namespace to MathML\n // is via <math>. If it happens via any other tag, then\n // it should be killed.\n if (parent.namespaceURI === HTML_NAMESPACE) {\n return tagName === 'math';\n }\n\n // The only way to switch from SVG to MathML is via\n // <math> and HTML integration points\n if (parent.namespaceURI === SVG_NAMESPACE) {\n return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName];\n }\n\n // We only allow elements that are defined in MathML\n // spec. All others are disallowed in MathML namespace.\n return Boolean(ALL_MATHML_TAGS[tagName]);\n }\n\n if (element.namespaceURI === HTML_NAMESPACE) {\n // The only way to switch from SVG to HTML is via\n // HTML integration points, and from MathML to HTML\n // is via MathML text integration points\n if (\n parent.namespaceURI === SVG_NAMESPACE &&\n !HTML_INTEGRATION_POINTS[parentTagName]\n ) {\n return false;\n }\n\n if (\n parent.namespaceURI === MATHML_NAMESPACE &&\n !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]\n ) {\n return false;\n }\n\n // We disallow tags that are specific for MathML\n // or SVG and should never appear in HTML namespace\n return (\n !ALL_MATHML_TAGS[tagName] &&\n (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName])\n );\n }\n\n // For XHTML and XML documents that support custom namespaces\n if (\n PARSER_MEDIA_TYPE === 'application/xhtml+xml' &&\n ALLOWED_NAMESPACES[element.namespaceURI]\n ) {\n return true;\n }\n\n // The code should never reach this place (this means\n // that the element somehow got namespace that is not\n // HTML, SVG, MathML or allowed via ALLOWED_NAMESPACES).\n // Return false just in case.\n return false;\n };\n\n /**\n * _forceRemove\n *\n * @param {Node} node a DOM node\n */\n const _forceRemove = function (node) {\n arrayPush(DOMPurify.removed, { element: node });\n\n try {\n // eslint-disable-next-line unicorn/prefer-dom-node-remove\n node.parentNode.removeChild(node);\n } catch (_) {\n node.remove();\n }\n };\n\n /**\n * _removeAttribute\n *\n * @param {String} name an Attribute name\n * @param {Node} node a DOM node\n */\n const _removeAttribute = function (name, node) {\n try {\n arrayPush(DOMPurify.removed, {\n attribute: node.getAttributeNode(name),\n from: node,\n });\n } catch (_) {\n arrayPush(DOMPurify.removed, {\n attribute: null,\n from: node,\n });\n }\n\n node.removeAttribute(name);\n\n // We void attribute values for unremovable \"is\"\" attributes\n if (name === 'is' && !ALLOWED_ATTR[name]) {\n if (RETURN_DOM || RETURN_DOM_FRAGMENT) {\n try {\n _forceRemove(node);\n } catch (_) {}\n } else {\n try {\n node.setAttribute(name, '');\n } catch (_) {}\n }\n }\n };\n\n /**\n * _initDocument\n *\n * @param {String} dirty a string of dirty markup\n * @return {Document} a DOM, filled with the dirty markup\n */\n const _initDocument = function (dirty) {\n /* Create a HTML document */\n let doc = null;\n let leadingWhitespace = null;\n\n if (FORCE_BODY) {\n dirty = '<remove></remove>' + dirty;\n } else {\n /* If FORCE_BODY isn't used, leading whitespace needs to be preserved manually */\n const matches = stringMatch(dirty, /^[\\r\\n\\t ]+/);\n leadingWhitespace = matches && matches[0];\n }\n\n if (\n PARSER_MEDIA_TYPE === 'application/xhtml+xml' &&\n NAMESPACE === HTML_NAMESPACE\n ) {\n // Root of XHTML doc must contain xmlns declaration (see https://www.w3.org/TR/xhtml1/normative.html#strict)\n dirty =\n '<html xmlns=\"http://www.w3.org/1999/xhtml\"><head></head><body>' +\n dirty +\n '</body></html>';\n }\n\n const dirtyPayload = trustedTypesPolicy\n ? trustedTypesPolicy.createHTML(dirty)\n : dirty;\n /*\n * Use the DOMParser API by default, fallback later if needs be\n * DOMParser not work for svg when has multiple root element.\n */\n if (NAMESPACE === HTML_NAMESPACE) {\n try {\n doc = new DOMParser().parseFromString(dirtyPayload, PARSER_MEDIA_TYPE);\n } catch (_) {}\n }\n\n /* Use createHTMLDocument in case DOMParser is not available */\n if (!doc || !doc.documentElement) {\n doc = implementation.createDocument(NAMESPACE, 'template', null);\n try {\n doc.documentElement.innerHTML = IS_EMPTY_INPUT\n ? emptyHTML\n : dirtyPayload;\n } catch (_) {\n // Syntax error if dirtyPayload is invalid xml\n }\n }\n\n const body = doc.body || doc.documentElement;\n\n if (dirty && leadingWhitespace) {\n body.insertBefore(\n document.createTextNode(leadingWhitespace),\n body.childNodes[0] || null\n );\n }\n\n /* Work on whole document or just its body */\n if (NAMESPACE === HTML_NAMESPACE) {\n return getElementsByTagName.call(\n doc,\n WHOLE_DOCUMENT ? 'html' : 'body'\n )[0];\n }\n\n return WHOLE_DOCUMENT ? doc.documentElement : body;\n };\n\n /**\n * Creates a NodeIterator object that you can use to traverse filtered lists of nodes or elements in a document.\n *\n * @param {Node} root The root element or node to start traversing on.\n * @return {NodeIterator} The created NodeIterator\n */\n const _createNodeIterator = function (root) {\n return createNodeIterator.call(\n root.ownerDocument || root,\n root,\n // eslint-disable-next-line no-bitwise\n NodeFilter.SHOW_ELEMENT |\n NodeFilter.SHOW_COMMENT |\n NodeFilter.SHOW_TEXT |\n NodeFilter.SHOW_PROCESSING_INSTRUCTION |\n NodeFilter.SHOW_CDATA_SECTION,\n null\n );\n };\n\n /**\n * _isClobbered\n *\n * @param {Node} elm element to check for clobbering attacks\n * @return {Boolean} true if clobbered, false if safe\n */\n const _isClobbered = function (elm) {\n return (\n elm instanceof HTMLFormElement &&\n (typeof elm.nodeName !== 'string' ||\n typeof elm.textContent !== 'string' ||\n typeof elm.removeChild !== 'function' ||\n !(elm.attributes instanceof NamedNodeMap) ||\n typeof elm.removeAttribute !== 'function' ||\n typeof elm.setAttribute !== 'function' ||\n typeof elm.namespaceURI !== 'string' ||\n typeof elm.insertBefore !== 'function' ||\n typeof elm.hasChildNodes !== 'function')\n );\n };\n\n /**\n * Checks whether the given object is a DOM node.\n *\n * @param {Node} object object to check whether it's a DOM node\n * @return {Boolean} true is object is a DOM node\n */\n const _isNode = function (object) {\n return typeof Node === 'function' && object instanceof Node;\n };\n\n /**\n * _executeHook\n * Execute user configurable hooks\n *\n * @param {String} entryPoint Name of the hook's entry point\n * @param {Node} currentNode node to work on with the hook\n * @param {Object} data additional hook parameters\n */\n const _executeHook = function (entryPoint, currentNode, data) {\n if (!hooks[entryPoint]) {\n return;\n }\n\n arrayForEach(hooks[entryPoint], (hook) => {\n hook.call(DOMPurify, currentNode, data, CONFIG);\n });\n };\n\n /**\n * _sanitizeElements\n *\n * @protect nodeName\n * @protect textContent\n * @protect removeChild\n *\n * @param {Node} currentNode to check for permission to exist\n * @return {Boolean} true if node was killed, false if left alive\n */\n const _sanitizeElements = function (currentNode) {\n let content = null;\n\n /* Execute a hook if present */\n _executeHook('beforeSanitizeElements', currentNode, null);\n\n /* Check if element is clobbered or can clobber */\n if (_isClobbered(currentNode)) {\n _forceRemove(currentNode);\n return true;\n }\n\n /* Now let's check the element's type and name */\n const tagName = transformCaseFunc(currentNode.nodeName);\n\n /* Execute a hook if present */\n _executeHook('uponSanitizeElement', currentNode, {\n tagName,\n allowedTags: ALLOWED_TAGS,\n });\n\n /* Detect mXSS attempts abusing namespace confusion */\n if (\n currentNode.hasChildNodes() &&\n !_isNode(currentNode.firstElementChild) &&\n regExpTest(/<[/\\w]/g, currentNode.innerHTML) &&\n regExpTest(/<[/\\w]/g, currentNode.textContent)\n ) {\n _forceRemove(currentNode);\n return true;\n }\n\n /* Remove any ocurrence of processing instructions */\n if (currentNode.nodeType === NODE_TYPE.progressingInstruction) {\n _forceRemove(currentNode);\n return true;\n }\n\n /* Remove any kind of possibly harmful comments */\n if (\n SAFE_FOR_XML &&\n currentNode.nodeType === NODE_TYPE.comment &&\n regExpTest(/<[/\\w]/g, currentNode.data)\n ) {\n _forceRemove(currentNode);\n return true;\n }\n\n /* Remove element if anything forbids its presence */\n if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {\n /* Check if we have a custom element to handle */\n if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {\n if (\n CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&\n regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)\n ) {\n return false;\n }\n\n if (\n CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&\n CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)\n ) {\n return false;\n }\n }\n\n /* Keep content except for bad-listed elements */\n if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) {\n const parentNode = getParentNode(currentNode) || currentNode.parentNode;\n const childNodes = getChildNodes(currentNode) || currentNode.childNodes;\n\n if (childNodes && parentNode) {\n const childCount = childNodes.length;\n\n for (let i = childCount - 1; i >= 0; --i) {\n const childClone = cloneNode(childNodes[i], true);\n childClone.__removalCount = (currentNode.__removalCount || 0) + 1;\n parentNode.insertBefore(childClone, getNextSibling(currentNode));\n }\n }\n }\n\n _forceRemove(currentNode);\n return true;\n }\n\n /* Check whether element has a valid namespace */\n if (currentNode instanceof Element && !_checkValidNamespace(currentNode)) {\n _forceRemove(currentNode);\n return true;\n }\n\n /* Make sure that older browsers don't get fallback-tag mXSS */\n if (\n (tagName === 'noscript' ||\n tagName === 'noembed' ||\n tagName === 'noframes') &&\n regExpTest(/<\\/no(script|embed|frames)/i, currentNode.innerHTML)\n ) {\n _forceRemove(currentNode);\n return true;\n }\n\n /* Sanitize element content to be template-safe */\n if (SAFE_FOR_TEMPLATES && currentNode.nodeType === NODE_TYPE.text) {\n /* Get the element's text content */\n content = currentNode.textContent;\n\n arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], (expr) => {\n content = stringReplace(content, expr, ' ');\n });\n\n if (currentNode.textContent !== content) {\n arrayPush(DOMPurify.removed, { element: currentNode.cloneNode() });\n currentNode.textContent = content;\n }\n }\n\n /* Execute a hook if present */\n _executeHook('afterSanitizeElements', currentNode, null);\n\n return false;\n };\n\n /**\n * _isValidAttribute\n *\n * @param {string} lcTag Lowercase tag name of containing element.\n * @param {string} lcName Lowercase attribute name.\n * @param {string} value Attribute value.\n * @return {Boolean} Returns true if `value` is valid, otherwise false.\n */\n // eslint-disable-next-line complexity\n const _isValidAttribute = function (lcTag, lcName, value) {\n /* Make sure attribute cannot clobber */\n if (\n SANITIZE_DOM &&\n (lcName === 'id' || lcName === 'name') &&\n (value in document || value in formElement)\n ) {\n return false;\n }\n\n /* Allow valid data-* attributes: At least one character after \"-\"\n (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes)\n XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804)\n We don't need to check the value; it's always URI safe. */\n if (\n ALLOW_DATA_ATTR &&\n !FORBID_ATTR[lcName] &&\n regExpTest(DATA_ATTR, lcName)\n ) {\n // This attribute is safe\n } else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) {\n // This attribute is safe\n /* Otherwise, check the name is permitted */\n } else if (!ALLOWED_ATTR[lcName] || FORBID_ATTR[lcName]) {\n if (\n // First condition does a very basic check if a) it's basically a valid custom element tagname AND\n // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck\n // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck\n (_isBasicCustomElement(lcTag) &&\n ((CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&\n regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag)) ||\n (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&\n CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag))) &&\n ((CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp &&\n regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName)) ||\n (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function &&\n CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName)))) ||\n // Alternative, second condition checks if it's an `is`-attribute, AND\n // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck\n (lcName === 'is' &&\n CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements &&\n ((CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&\n regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value)) ||\n (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&\n CUSTOM_ELEMENT_HANDLING.tagNameCheck(value))))\n ) {\n // If user has supplied a regexp or function in CUSTOM_ELEMENT_HANDLING.tagNameCheck, we need to also allow derived custom elements using the same tagName test.\n // Additionally, we need to allow attributes passing the CUSTOM_ELEMENT_HANDLING.attributeNameCheck user has configured, as custom elements can define these at their own discretion.\n } else {\n return false;\n }\n /* Check value is safe. First, is attr inert? If so, is safe */\n } else if (URI_SAFE_ATTRIBUTES[lcName]) {\n // This attribute is safe\n /* Check no script, data or unknown possibly unsafe URI\n unless we know URI values are safe for that attribute */\n } else if (\n regExpTest(IS_ALLOWED_URI, stringReplace(value, ATTR_WHITESPACE, ''))\n ) {\n // This attribute is safe\n /* Keep image data URIs alive if src/xlink:href is allowed */\n /* Further prevent gadget XSS for dynamically built script tags */\n } else if (\n (lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') &&\n lcTag !== 'script' &&\n stringIndexOf(value, 'data:') === 0 &&\n DATA_URI_TAGS[lcTag]\n ) {\n // This attribute is safe\n /* Allow unknown protocols: This provides support for links that\n are handled by protocol handlers which may be unknown ahead of\n time, e.g. fb:, spotify: */\n } else if (\n ALLOW_UNKNOWN_PROTOCOLS &&\n !regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))\n ) {\n // This attribute is safe\n /* Check for binary attributes */\n } else if (value) {\n return false;\n } else {\n // Binary attributes are safe at this point\n /* Anything else, presume unsafe, do not add it back */\n }\n\n return true;\n };\n\n /**\n * _isBasicCustomElement\n * checks if at least one dash is included in tagName, and it's not the first char\n * for more sophisticated checking see https://github.com/sindresorhus/validate-element-name\n *\n * @param {string} tagName name of the tag of the node to sanitize\n * @returns {boolean} Returns true if the tag name meets the basic criteria for a custom element, otherwise false.\n */\n const _isBasicCustomElement = function (tagName) {\n return tagName !== 'annotation-xml' && stringMatch(tagName, CUSTOM_ELEMENT);\n };\n\n /**\n * _sanitizeAttributes\n *\n * @protect attributes\n * @protect nodeName\n * @protect removeAttribute\n * @protect setAttribute\n *\n * @param {Node} currentNode to sanitize\n */\n const _sanitizeAttributes = function (currentNode) {\n /* Execute a hook if present */\n _executeHook('beforeSanitizeAttributes', currentNode, null);\n\n const { attributes } = currentNode;\n\n /* Check if we have attributes; if not we might have a text node */\n if (!attributes) {\n return;\n }\n\n const hookEvent = {\n attrName: '',\n attrValue: '',\n keepAttr: true,\n allowedAttributes: ALLOWED_ATTR,\n };\n let l = attributes.length;\n\n /* Go backwards over all attributes; safely remove bad ones */\n while (l--) {\n const attr = attributes[l];\n const { name, namespaceURI, value: attrValue } = attr;\n const lcName = transformCaseFunc(name);\n\n let value = name === 'value' ? attrValue : stringTrim(attrValue);\n\n /* Execute a hook if present */\n hookEvent.attrName = lcName;\n hookEvent.attrValue = value;\n hookEvent.keepAttr = true;\n hookEvent.forceKeepAttr = undefined; // Allows developers to see this is a property they can set\n _executeHook('uponSanitizeAttribute', currentNode, hookEvent);\n value = hookEvent.attrValue;\n /* Did the hooks approve of the attribute? */\n if (hookEvent.forceKeepAttr) {\n continue;\n }\n\n /* Remove attribute */\n _removeAttribute(name, currentNode);\n\n /* Did the hooks approve of the attribute? */\n if (!hookEvent.keepAttr) {\n continue;\n }\n\n /* Work around a security issue in jQuery 3.0 */\n if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(/\\/>/i, value)) {\n _removeAttribute(name, currentNode);\n continue;\n }\n\n /* Work around a security issue with comments inside attributes */\n if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\\/(style|title)/i, value)) {\n _removeAttribute(name, currentNode);\n continue;\n }\n\n /* Sanitize attribute content to be template-safe */\n if (SAFE_FOR_TEMPLATES) {\n arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], (expr) => {\n value = stringReplace(value, expr, ' ');\n });\n }\n\n /* Is `value` valid for this attribute? */\n const lcTag = transformCaseFunc(currentNode.nodeName);\n if (!_isValidAttribute(lcTag, lcName, value)) {\n continue;\n }\n\n /* Full DOM Clobbering protection via namespace isolation,\n * Prefix id and name attributes with `user-content-`\n */\n if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name')) {\n // Remove the attribute with this value\n _removeAttribute(name, currentNode);\n\n // Prefix the value and later re-create the attribute with the sanitized value\n value = SANITIZE_NAMED_PROPS_PREFIX + value;\n }\n\n /* Handle attributes that require Trusted Types */\n if (\n trustedTypesPolicy &&\n typeof trustedTypes === 'object' &&\n typeof trustedTypes.getAttributeType === 'function'\n ) {\n if (namespaceURI) {\n /* Namespaces are not yet supported, see https://bugs.chromium.org/p/chromium/issues/detail?id=1305293 */\n } else {\n switch (trustedTypes.getAttributeType(lcTag, lcName)) {\n case 'TrustedHTML': {\n value = trustedTypesPolicy.createHTML(value);\n break;\n }\n\n case 'TrustedScriptURL': {\n value = trustedTypesPolicy.createScriptURL(value);\n break;\n }\n\n default: {\n break;\n }\n }\n }\n }\n\n /* Handle invalid data-* attribute set by try-catching it */\n try {\n if (namespaceURI) {\n currentNode.setAttributeNS(namespaceURI, name, value);\n } else {\n /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. \"x-schema\". */\n currentNode.setAttribute(name, value);\n }\n\n if (_isClobbered(currentNode)) {\n _forceRemove(currentNode);\n } else {\n arrayPop(DOMPurify.removed);\n }\n } catch (_) {}\n }\n\n /* Execute a hook if present */\n _executeHook('afterSanitizeAttributes', currentNode, null);\n };\n\n /**\n * _sanitizeShadowDOM\n *\n * @param {DocumentFragment} fragment to iterate over recursively\n */\n const _sanitizeShadowDOM = function (fragment) {\n let shadowNode = null;\n const shadowIterator = _createNodeIterator(fragment);\n\n /* Execute a hook if present */\n _executeHook('beforeSanitizeShadowDOM', fragment, null);\n\n while ((shadowNode = shadowIterator.nextNode())) {\n /* Execute a hook if present */\n _executeHook('uponSanitizeShadowNode', shadowNode, null);\n\n /* Sanitize tags and elements */\n if (_sanitizeElements(shadowNode)) {\n continue;\n }\n\n /* Deep shadow DOM detected */\n if (shadowNode.content instanceof DocumentFragment) {\n _sanitizeShadowDOM(shadowNode.content);\n }\n\n /* Check attributes, sanitize if necessary */\n _sanitizeAttributes(shadowNode);\n }\n\n /* Execute a hook if present */\n _executeHook('afterSanitizeShadowDOM', fragment, null);\n };\n\n /**\n * Sanitize\n * Public method providing core sanitation functionality\n *\n * @param {String|Node} dirty string or DOM node\n * @param {Object} cfg object\n */\n // eslint-disable-next-line complexity\n DOMPurify.sanitize = function (dirty, cfg = {}) {\n let body = null;\n let importedNode = null;\n let currentNode = null;\n let returnNode = null;\n /* Make sure we have a string to sanitize.\n DO NOT return early, as this will return the wrong type if\n the user has requested a DOM object rather than a string */\n IS_EMPTY_INPUT = !dirty;\n if (IS_EMPTY_INPUT) {\n dirty = '<!-->';\n }\n\n /* Stringify, in case dirty is an object */\n if (typeof dirty !== 'string' && !_isNode(dirty)) {\n if (typeof dirty.toString === 'function') {\n dirty = dirty.toString();\n if (typeof dirty !== 'string') {\n throw typeErrorCreate('dirty is not a string, aborting');\n }\n } else {\n throw typeErrorCreate('toString is not a function');\n }\n }\n\n /* Return dirty HTML if DOMPurify cannot run */\n if (!DOMPurify.isSupported) {\n return dirty;\n }\n\n /* Assign config vars */\n if (!SET_CONFIG) {\n _parseConfig(cfg);\n }\n\n /* Clean up removed elements */\n DOMPurify.removed = [];\n\n /* Check if dirty is correctly typed for IN_PLACE */\n if (typeof dirty === 'string') {\n IN_PLACE = false;\n }\n\n if (IN_PLACE) {\n /* Do some early pre-sanitization to avoid unsafe root nodes */\n if (dirty.nodeName) {\n const tagName = transformCaseFunc(dirty.nodeName);\n if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {\n throw typeErrorCreate(\n 'root node is forbidden and cannot be sanitized in-place'\n );\n }\n }\n } else if (dirty instanceof Node) {\n /* If dirty is a DOM element, append to an empty document to avoid\n elements being stripped by the parser */\n body = _initDocument('<!---->');\n importedNode = body.ownerDocument.importNode(dirty, true);\n if (\n importedNode.nodeType === NODE_TYPE.element &&\n importedNode.nodeName === 'BODY'\n ) {\n /* Node is already a body, use as is */\n body = importedNode;\n } else if (importedNode.nodeName === 'HTML') {\n body = importedNode;\n } else {\n // eslint-disable-next-line unicorn/prefer-dom-node-append\n body.appendChild(importedNode);\n }\n } else {\n /* Exit directly if we have nothing to do */\n if (\n !RETURN_DOM &&\n !SAFE_FOR_TEMPLATES &&\n !WHOLE_DOCUMENT &&\n // eslint-disable-next-line unicorn/prefer-includes\n dirty.indexOf('<') === -1\n ) {\n return trustedTypesPolicy && RETURN_TRUSTED_TYPE\n ? trustedTypesPolicy.createHTML(dirty)\n : dirty;\n }\n\n /* Initialize the document to work on */\n body = _initDocument(dirty);\n\n /* Check we have a DOM node from the data */\n if (!body) {\n return RETURN_DOM ? null : RETURN_TRUSTED_TYPE ? emptyHTML : '';\n }\n }\n\n /* Remove first element node (ours) if FORCE_BODY is set */\n if (body && FORCE_BODY) {\n _forceRemove(body.firstChild);\n }\n\n /* Get node iterator */\n const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body);\n\n /* Now start iterating over the created document */\n while ((currentNode = nodeIterator.nextNode())) {\n /* Sanitize tags and elements */\n if (_sanitizeElements(currentNode)) {\n continue;\n }\n\n /* Shadow DOM detected, sanitize it */\n if (currentNode.content instanceof DocumentFragment) {\n _sanitizeShadowDOM(currentNode.content);\n }\n\n /* Check attributes, sanitize if necessary */\n _sanitizeAttributes(currentNode);\n }\n\n /* If we sanitized `dirty` in-place, return it. */\n if (IN_PLACE) {\n return dirty;\n }\n\n /* Return sanitized string or DOM */\n if (RETURN_DOM) {\n if (RETURN_DOM_FRAGMENT) {\n returnNode = createDocumentFragment.call(body.ownerDocument);\n\n while (body.firstChild) {\n // eslint-disable-next-line unicorn/prefer-dom-node-append\n returnNode.appendChild(body.firstChild);\n }\n } else {\n returnNode = body;\n }\n\n if (ALLOWED_ATTR.shadowroot || ALLOWED_ATTR.shadowrootmode) {\n /*\n AdoptNode() is not used because internal state is not reset\n (e.g. the past names map of a HTMLFormElement), this is safe\n in theory but we would rather not risk another attack vector.\n The state that is cloned by importNode() is explicitly defined\n by the specs.\n */\n returnNode = importNode.call(originalDocument, returnNode, true);\n }\n\n return returnNode;\n }\n\n let serializedHTML = WHOLE_DOCUMENT ? body.outerHTML : body.innerHTML;\n\n /* Serialize doctype if allowed */\n if (\n WHOLE_DOCUMENT &&\n ALLOWED_TAGS['!doctype'] &&\n body.ownerDocument &&\n body.ownerDocument.doctype &&\n body.ownerDocument.doctype.name &&\n regExpTest(EXPRESSIONS.DOCTYPE_NAME, body.ownerDocument.doctype.name)\n ) {\n serializedHTML =\n '<!DOCTYPE ' + body.ownerDocument.doctype.name + '>\\n' + serializedHTML;\n }\n\n /* Sanitize final string template-safe */\n if (SAFE_FOR_TEMPLATES) {\n arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], (expr) => {\n serializedHTML = stringReplace(serializedHTML, expr, ' ');\n });\n }\n\n return trustedTypesPolicy && RETURN_TRUSTED_TYPE\n ? trustedTypesPolicy.createHTML(serializedHTML)\n : serializedHTML;\n };\n\n /**\n * Public method to set the configuration once\n * setConfig\n *\n * @param {Object} cfg configuration object\n */\n DOMPurify.setConfig = function (cfg = {}) {\n _parseConfig(cfg);\n SET_CONFIG = true;\n };\n\n /**\n * Public method to remove the configuration\n * clearConfig\n *\n */\n DOMPurify.clearConfig = function () {\n CONFIG = null;\n SET_CONFIG = false;\n };\n\n /**\n * Public method to check if an attribute value is valid.\n * Uses last set config, if any. Otherwise, uses config defaults.\n * isValidAttribute\n *\n * @param {String} tag Tag name of containing element.\n * @param {String} attr Attribute name.\n * @param {String} value Attribute value.\n * @return {Boolean} Returns true if `value` is valid. Otherwise, returns false.\n */\n DOMPurify.isValidAttribute = function (tag, attr, value) {\n /* Initialize shared config vars if necessary. */\n if (!CONFIG) {\n _parseConfig({});\n }\n\n const lcTag = transformCaseFunc(tag);\n const lcName = transformCaseFunc(attr);\n return _isValidAttribute(lcTag, lcName, value);\n };\n\n /**\n * AddHook\n * Public method to add DOMPurify hooks\n *\n * @param {String} entryPoint entry point for the hook to add\n * @param {Function} hookFunction function to execute\n */\n DOMPurify.addHook = function (entryPoint, hookFunction) {\n if (typeof hookFunction !== 'function') {\n return;\n }\n\n hooks[entryPoint] = hooks[entryPoint] || [];\n arrayPush(hooks[entryPoint], hookFunction);\n };\n\n /**\n * RemoveHook\n * Public method to remove a DOMPurify hook at a given entryPoint\n * (pops it from the stack of hooks if more are present)\n *\n * @param {String} entryPoint entry point for the hook to remove\n * @return {Function} removed(popped) hook\n */\n DOMPurify.removeHook = function (entryPoint) {\n if (hooks[entryPoint]) {\n return arrayPop(hooks[entryPoint]);\n }\n };\n\n /**\n * RemoveHooks\n * Public method to remove all DOMPurify hooks at a given entryPoint\n *\n * @param {String} entryPoint entry point for the hooks to remove\n */\n DOMPurify.removeHooks = function (entryPoint) {\n if (hooks[entryPoint]) {\n hooks[entryPoint] = [];\n }\n };\n\n /**\n * RemoveAllHooks\n * Public method to remove all DOMPurify hooks\n */\n DOMPurify.removeAllHooks = function () {\n hooks = {};\n };\n\n return DOMPurify;\n}\n\nexport default createDOMPurify();\n"],"names":["entries","setPrototypeOf","isFrozen","getPrototypeOf","getOwnPropertyDescriptor","Object","freeze","seal","create","apply","construct","Reflect","x","fun","thisValue","args","Func","arrayForEach","unapply","Array","prototype","forEach","arrayPop","pop","arrayPush","push","stringToLowerCase","String","toLowerCase","stringToString","toString","stringMatch","match","stringReplace","replace","stringIndexOf","indexOf","stringTrim","trim","objectHasOwnProperty","hasOwnProperty","regExpTest","RegExp","test","typeErrorCreate","func","TypeError","_len2","arguments","length","_key2","thisArg","_len","_key","addToSet","set","array","transformCaseFunc","undefined","l","element","lcElement","cleanArray","index","clone","object","newObject","property","value","isArray","constructor","lookupGetter","prop","desc","get","html","svg","svgFilters","svgDisallowed","mathMl","mathMlDisallowed","text","xml","MUSTACHE_EXPR","ERB_EXPR","TMPLIT_EXPR","DATA_ATTR","ARIA_ATTR","IS_ALLOWED_URI","IS_SCRIPT_OR_DATA","ATTR_WHITESPACE","DOCTYPE_NAME","CUSTOM_ELEMENT","NODE_TYPE","getGlobal","window","_createTrustedTypesPolicy","trustedTypes","purifyHostElement","createPolicy","suffix","ATTR_NAME","hasAttribute","getAttribute","policyName","createHTML","createScriptURL","scriptUrl","_","console","warn","purify","createDOMPurify","DOMPurify","root","version","VERSION","removed","document","nodeType","isSupported","originalDocument","currentScript","DocumentFragment","HTMLTemplateElement","Node","Element","NodeFilter","NamedNodeMap","MozNamedAttrMap","HTMLFormElement","DOMParser","ElementPrototype","cloneNode","getNextSibling","getChildNodes","getParentNode","template","createElement","content","ownerDocument","trustedTypesPolicy","emptyHTML","implementation","createNodeIterator","createDocumentFragment","getElementsByTagName","importNode","hooks","createHTMLDocument","EXPRESSIONS","ALLOWED_TAGS","DEFAULT_ALLOWED_TAGS","TAGS","ALLOWED_ATTR","DEFAULT_ALLOWED_ATTR","ATTRS","CUSTOM_ELEMENT_HANDLING","tagNameCheck","writable","configurable","enumerable","attributeNameCheck","allowCustomizedBuiltInElements","FORBID_TAGS","FORBID_ATTR","ALLOW_ARIA_ATTR","ALLOW_DATA_ATTR","ALLOW_UNKNOWN_PROTOCOLS","ALLOW_SELF_CLOSE_IN_ATTR","SAFE_FOR_TEMPLATES","SAFE_FOR_XML","WHOLE_DOCUMENT","SET_CONFIG","FORCE_BODY","RETURN_DOM","RETURN_DOM_FRAGMENT","RETURN_TRUSTED_TYPE","SANITIZE_DOM","SANITIZE_NAMED_PROPS","SANITIZE_NAMED_PROPS_PREFIX","KEEP_CONTENT","IN_PLACE","USE_PROFILES","FORBID_CONTENTS","DEFAULT_FORBID_CONTENTS","DATA_URI_TAGS","DEFAULT_DATA_URI_TAGS","URI_SAFE_ATTRIBUTES","DEFAULT_URI_SAFE_ATTRIBUTES","MATHML_NAMESPACE","SVG_NAMESPACE","HTML_NAMESPACE","NAMESPACE","IS_EMPTY_INPUT","ALLOWED_NAMESPACES","DEFAULT_ALLOWED_NAMESPACES","PARSER_MEDIA_TYPE","SUPPORTED_PARSER_MEDIA_TYPES","DEFAULT_PARSER_MEDIA_TYPE","CONFIG","formElement","isRegexOrFunction","testValue","Function","_parseConfig","cfg","ADD_URI_SAFE_ATTR","ADD_DATA_URI_TAGS","ALLOWED_URI_REGEXP","ADD_TAGS","ADD_ATTR","table","tbody","TRUSTED_TYPES_POLICY","MATHML_TEXT_INTEGRATION_POINTS","HTML_INTEGRATION_POINTS","COMMON_SVG_AND_HTML_ELEMENTS","ALL_SVG_TAGS","ALL_MATHML_TAGS","_checkValidNamespace","parent","tagName","namespaceURI","parentTagName","Boolean","_forceRemove","node","parentNode","removeChild","remove","_removeAttribute","name","attribute","getAttributeNode","from","removeAttribute","setAttribute","_initDocument","dirty","doc","leadingWhitespace","matches","dirtyPayload","parseFromString","documentElement","createDocument","innerHTML","body","insertBefore","createTextNode","childNodes","call","_createNodeIterator","SHOW_ELEMENT","SHOW_COMMENT","SHOW_TEXT","SHOW_PROCESSING_INSTRUCTION","SHOW_CDATA_SECTION","_isClobbered","elm","nodeName","textContent","attributes","hasChildNodes","_isNode","_executeHook","entryPoint","currentNode","data","hook","_sanitizeElements","allowedTags","firstElementChild","_isBasicCustomElement","i","childClone","__removalCount","expr","_isValidAttribute","lcTag","lcName","_sanitizeAttributes","hookEvent","attrName","attrValue","keepAttr","allowedAttributes","attr","forceKeepAttr","getAttributeType","setAttributeNS","_sanitizeShadowDOM","fragment","shadowNode","shadowIterator","nextNode","sanitize","importedNode","returnNode","appendChild","firstChild","nodeIterator","shadowroot","shadowrootmode","serializedHTML","outerHTML","doctype","setConfig","clearConfig","isValidAttribute","tag","addHook","hookFunction","removeHook","removeHooks","removeAllHooks"],"mappings":";0OAAA,MAAMA,QACJA,EAAOC,eACPA,EAAcC,SACdA,EAAQC,eACRA,EAAcC,yBACdA,GACEC,OAEJ,IAAIC,OAAEA,EAAMC,KAAEA,EAAIC,OAAEA,GAAWH,QAC3BI,MAAEA,EAAKC,UAAEA,GAAiC,oBAAZC,SAA2BA,QAExDL,IACHA,EAAS,SAAUM,GACjB,OAAOA,IAINL,IACHA,EAAO,SAAUK,GACf,OAAOA,IAINH,IACHA,EAAQ,SAAUI,EAAKC,EAAWC,GAChC,OAAOF,EAAIJ,MAAMK,EAAWC,KAI3BL,IACHA,EAAY,SAAUM,EAAMD,GAC1B,OAAO,IAAIC,KAAQD,KAIvB,MAAME,EAAeC,EAAQC,MAAMC,UAAUC,SAEvCC,EAAWJ,EAAQC,MAAMC,UAAUG,KACnCC,EAAYN,EAAQC,MAAMC,UAAUK,MAGpCC,EAAoBR,EAAQS,OAAOP,UAAUQ,aAC7CC,EAAiBX,EAAQS,OAAOP,UAAUU,UAC1CC,EAAcb,EAAQS,OAAOP,UAAUY,OACvCC,EAAgBf,EAAQS,OAAOP,UAAUc,SACzCC,EAAgBjB,EAAQS,OAAOP,UAAUgB,SACzCC,EAAanB,EAAQS,OAAOP,UAAUkB,MAEtCC,EAAuBrB,EAAQb,OAAOe,UAAUoB,gBAEhDC,EAAavB,EAAQwB,OAAOtB,UAAUuB,MAEtCC,GAkBeC,EAlBeC,UAmB3B,WAAA,IAAA,IAAAC,EAAAC,UAAAC,OAAIlC,EAAII,IAAAA,MAAA4B,GAAAG,EAAA,EAAAA,EAAAH,EAAAG,IAAJnC,EAAImC,GAAAF,UAAAE,GAAA,OAAKxC,EAAUmC,EAAM9B,EAAK,GAD3C,IAAqB8B,EAVrB,SAAS3B,EAAQ2B,GACf,OAAO,SAACM,GAAO,IAAAC,IAAAA,EAAAJ,UAAAC,OAAKlC,MAAII,MAAAiC,EAAAA,EAAAA,OAAAC,EAAA,EAAAA,EAAAD,EAAAC,IAAJtC,EAAIsC,EAAAL,GAAAA,UAAAK,GAAA,OAAK5C,EAAMoC,EAAMM,EAASpC,EAAK,CACzD,CAoBA,SAASuC,EAASC,EAAKC,GAA8C,IAAvCC,EAAiBT,UAAAC,OAAA,QAAAS,IAAAV,UAAA,GAAAA,UAAA,GAAGtB,EAC5CzB,GAIFA,EAAesD,EAAK,MAGtB,IAAII,EAAIH,EAAMP,OACd,KAAOU,KAAK,CACV,IAAIC,EAAUJ,EAAMG,GACpB,GAAuB,iBAAZC,EAAsB,CAC/B,MAAMC,EAAYJ,EAAkBG,GAChCC,IAAcD,IAEX1D,EAASsD,KACZA,EAAMG,GAAKE,GAGbD,EAAUC,EAEd,CAEAN,EAAIK,IAAW,CACjB,CAEA,OAAOL,CACT,CAQA,SAASO,EAAWN,GAClB,IAAK,IAAIO,EAAQ,EAAGA,EAAQP,EAAMP,OAAQc,IAAS,CACzBxB,EAAqBiB,EAAOO,KAGlDP,EAAMO,GAAS,KAEnB,CAEA,OAAOP,CACT,CAQA,SAASQ,EAAMC,GACb,MAAMC,EAAY1D,EAAO,MAEzB,IAAK,MAAO2D,EAAUC,KAAUpE,EAAQiE,GAAS,CACvB1B,EAAqB0B,EAAQE,KAG/ChD,MAAMkD,QAAQD,GAChBF,EAAUC,GAAYL,EAAWM,GAEjCA,GACiB,iBAAVA,GACPA,EAAME,cAAgBjE,OAEtB6D,EAAUC,GAAYH,EAAMI,GAE5BF,EAAUC,GAAYC,EAG5B,CAEA,OAAOF,CACT,CASA,SAASK,EAAaN,EAAQO,GAC5B,KAAkB,OAAXP,GAAiB,CACtB,MAAMQ,EAAOrE,EAAyB6D,EAAQO,GAE9C,GAAIC,EAAM,CACR,GAAIA,EAAKC,IACP,OAAOxD,EAAQuD,EAAKC,KAGtB,GAA0B,mBAAfD,EAAKL,MACd,OAAOlD,EAAQuD,EAAKL,MAExB,CAEAH,EAAS9D,EAAe8D,EAC1B,CAMA,OAJA,WACE,OAAO,IACT,CAGF,CC1LO,MAAMU,EAAOrE,EAAO,CACzB,IACA,OACA,UACA,UACA,OACA,UACA,QACA,QACA,IACA,MACA,MACA,MACA,QACA,aACA,OACA,KACA,SACA,SACA,UACA,SACA,OACA,OACA,MACA,WACA,UACA,OACA,WACA,KACA,YACA,MACA,UACA,MACA,SACA,MACA,MACA,KACA,KACA,UACA,KACA,WACA,aACA,SACA,OACA,SACA,OACA,KACA,KACA,KACA,KACA,KACA,KACA,OACA,SACA,SACA,KACA,OACA,IACA,MACA,QACA,MACA,MACA,QACA,SACA,KACA,OACA,MACA,OACA,UACA,OACA,WACA,QACA,MACA,OACA,KACA,WACA,SACA,SACA,IACA,UACA,MACA,WACA,IACA,KACA,KACA,OACA,IACA,OACA,UACA,SACA,SACA,QACA,SACA,SACA,OACA,SACA,SACA,QACA,MACA,UACA,MACA,QACA,QACA,KACA,WACA,WACA,QACA,KACA,QACA,OACA,KACA,QACA,KACA,IACA,KACA,MACA,QACA,QAIWsE,EAAMtE,EAAO,CACxB,MACA,IACA,WACA,cACA,eACA,eACA,gBACA,mBACA,SACA,WACA,OACA,OACA,UACA,SACA,OACA,IACA,QACA,WACA,QACA,QACA,OACA,iBACA,SACA,OACA,WACA,QACA,OACA,UACA,UACA,WACA,iBACA,OACA,OACA,QACA,SACA,SACA,OACA,WACA,QACA,OACA,QACA,OACA,UAGWuE,EAAavE,EAAO,CAC/B,UACA,gBACA,sBACA,cACA,mBACA,oBACA,oBACA,iBACA,eACA,UACA,UACA,UACA,UACA,UACA,iBACA,UACA,UACA,cACA,eACA,WACA,eACA,qBACA,cACA,SACA,iBAOWwE,EAAgBxE,EAAO,CAClC,UACA,gBACA,SACA,UACA,YACA,mBACA,iBACA,gBACA,gBACA,gBACA,QACA,YACA,OACA,eACA,YACA,UACA,gBACA,SACA,MACA,aACA,UACA,QAGWyE,EAASzE,EAAO,CAC3B,OACA,WACA,SACA,UACA,QACA,SACA,KACA,aACA,gBACA,KACA,KACA,QACA,UACA,WACA,QACA,OACA,KACA,SACA,QACA,SACA,OACA,OACA,UACA,SACA,MACA,QACA,MACA,SACA,aACA,gBAKW0E,EAAmB1E,EAAO,CACrC,UACA,cACA,aACA,WACA,YACA,UACA,UACA,SACA,SACA,QACA,YACA,aACA,iBACA,cACA,SAGW2E,EAAO3E,EAAO,CAAC,UCrRfqE,EAAOrE,EAAO,CACzB,SACA,SACA,QACA,MACA,iBACA,eACA,uBACA,WACA,aACA,UACA,SACA,UACA,cACA,cACA,UACA,OACA,QACA,QACA,QACA,OACA,UACA,WACA,eACA,SACA,cACA,WACA,WACA,UACA,MACA,WACA,0BACA,wBACA,WACA,YACA,UACA,eACA,OACA,MACA,UACA,SACA,SACA,OACA,OACA,WACA,KACA,YACA,YACA,QACA,OACA,QACA,OACA,OACA,UACA,OACA,MACA,MACA,YACA,QACA,SACA,MACA,YACA,WACA,QACA,OACA,QACA,UACA,aACA,SACA,OACA,UACA,UACA,cACA,cACA,UACA,gBACA,sBACA,SACA,UACA,UACA,aACA,WACA,MACA,WACA,MACA,WACA,OACA,OACA,UACA,aACA,QACA,WACA,QACA,OACA,QACA,OACA,UACA,QACA,MACA,SACA,OACA,QACA,UACA,WACA,QACA,YACA,OACA,SACA,SACA,QACA,QACA,OACA,QACA,SAGWsE,EAAMtE,EAAO,CACxB,gBACA,aACA,WACA,qBACA,SACA,gBACA,gBACA,UACA,gBACA,iBACA,QACA,OACA,KACA,QACA,OACA,gBACA,YACA,YACA,QACA,sBACA,8BACA,gBACA,kBACA,KACA,KACA,IACA,KACA,KACA,kBACA,YACA,UACA,UACA,MACA,WACA,YACA,MACA,OACA,eACA,YACA,SACA,cACA,cACA,gBACA,cACA,YACA,mBACA,eACA,aACA,eACA,cACA,KACA,KACA,KACA,KACA,aACA,WACA,gBACA,oBACA,SACA,OACA,KACA,kBACA,KACA,MACA,IACA,KACA,KACA,KACA,KACA,UACA,YACA,aACA,WACA,OACA,eACA,iBACA,eACA,mBACA,iBACA,QACA,aACA,aACA,eACA,eACA,cACA,cACA,mBACA,YACA,MACA,OACA,QACA,SACA,OACA,MACA,OACA,aACA,SACA,WACA,UACA,QACA,SACA,cACA,SACA,WACA,cACA,OACA,aACA,sBACA,mBACA,eACA,SACA,gBACA,sBACA,iBACA,IACA,KACA,KACA,SACA,OACA,OACA,cACA,YACA,UACA,SACA,SACA,QACA,OACA,kBACA,mBACA,mBACA,eACA,cACA,eACA,cACA,aACA,eACA,mBACA,oBACA,iBACA,kBACA,oBACA,iBACA,SACA,eACA,QACA,eACA,iBACA,WACA,UACA,UACA,YACA,mBACA,cACA,kBACA,iBACA,aACA,OACA,KACA,KACA,UACA,SACA,UACA,aACA,UACA,aACA,gBACA,gBACA,QACA,eACA,OACA,eACA,mBACA,mBACA,IACA,KACA,KACA,QACA,IACA,KACA,KACA,IACA,eAGWyE,EAASzE,EAAO,CAC3B,SACA,cACA,QACA,WACA,QACA,eACA,cACA,aACA,aACA,QACA,MACA,UACA,eACA,WACA,QACA,QACA,SACA,OACA,KACA,UACA,SACA,gBACA,SACA,SACA,iBACA,YACA,WACA,cACA,UACA,UACA,gBACA,WACA,WACA,OACA,WACA,WACA,aACA,UACA,SACA,SACA,cACA,gBACA,uBACA,YACA,YACA,aACA,WACA,iBACA,iBACA,YACA,UACA,QACA,UAGW4E,EAAM5E,EAAO,CACxB,aACA,SACA,cACA,YACA,gBCzWW6E,EAAgB5E,EAAK,6BACrB6E,EAAW7E,EAAK,yBAChB8E,EAAc9E,EAAK,iBACnB+E,EAAY/E,EAAK,8BACjBgF,EAAYhF,EAAK,kBACjBiF,EAAiBjF,EAC5B,6FAEWkF,EAAoBlF,EAAK,yBACzBmF,EAAkBnF,EAC7B,+DAEWoF,EAAepF,EAAK,WACpBqF,EAAiBrF,EAAK,0NCSnC,MAAMsF,EACK,EADLA,EAGE,EAHFA,EAOoB,EAPpBA,EAQK,EARLA,GASM,EAMNC,GAAY,WAChB,MAAyB,oBAAXC,OAAyB,KAAOA,MAChD,EAUMC,GAA4B,SAAUC,EAAcC,GACxD,GAC0B,iBAAjBD,GAC8B,mBAA9BA,EAAaE,aAEpB,OAAO,KAMT,IAAIC,EAAS,KACb,MAAMC,EAAY,wBACdH,GAAqBA,EAAkBI,aAAaD,KACtDD,EAASF,EAAkBK,aAAaF,IAG1C,MAAMG,EAAa,aAAeJ,EAAS,IAAMA,EAAS,IAE1D,IACE,OAAOH,EAAaE,aAAaK,EAAY,CAC3CC,WAAW9B,GACFA,EAET+B,gBAAgBC,GACPA,GAWb,CARE,MAAOC,GAOP,OAHAC,QAAQC,KACN,uBAAyBN,EAAa,0BAEjC,IACT,CACF,EAmkDA,IAAAO,GAjkDA,SAASC,IAAsC,IAAtBjB,EAAM/C,UAAAC,OAAAD,QAAAU,IAAAV,UAAAU,GAAAV,UAAG8C,GAAAA,KAChC,MAAMmB,EAAaC,GAASF,EAAgBE,GAc5C,GARAD,EAAUE,QAAUC,QAMpBH,EAAUI,QAAU,IAGjBtB,IACAA,EAAOuB,UACRvB,EAAOuB,SAASC,WAAa1B,GAM7B,OAFAoB,EAAUO,aAAc,EAEjBP,EAGT,IAAIK,SAAEA,GAAavB,EAEnB,MAAM0B,EAAmBH,EACnBI,EAAgBD,EAAiBC,eACjCC,iBACJA,EAAgBC,oBAChBA,EAAmBC,KACnBA,EAAIC,QACJA,EAAOC,WACPA,EAAUC,aACVA,EAAejC,EAAOiC,cAAgBjC,EAAOkC,gBAAeC,gBAC5DA,EAAeC,UACfA,EAASlC,aACTA,GACEF,EAEEqC,EAAmBN,EAAQ1G,UAE3BiH,EAAY9D,EAAa6D,EAAkB,aAC3CE,EAAiB/D,EAAa6D,EAAkB,eAChDG,GAAgBhE,EAAa6D,EAAkB,cAC/CI,GAAgBjE,EAAa6D,EAAkB,cAQrD,GAAmC,mBAAxBR,EAAoC,CAC7C,MAAMa,EAAWnB,EAASoB,cAAc,YACpCD,EAASE,SAAWF,EAASE,QAAQC,gBACvCtB,EAAWmB,EAASE,QAAQC,cAEhC,CAEA,IAAIC,GACAC,GAAY,GAEhB,MAAMC,eACJA,GAAcC,mBACdA,GAAkBC,uBAClBA,GAAsBC,qBACtBA,IACE5B,GACE6B,WAAEA,IAAe1B,EAEvB,IAAI2B,GAAQ,CAAA,EAKZnC,EAAUO,YACW,mBAAZxH,GACkB,mBAAlBwI,IACPO,SACsCrF,IAAtCqF,GAAeM,mBAEjB,MAAMlE,cACJA,GAAaC,SACbA,GAAQC,YACRA,GAAWC,UACXA,GAASC,UACTA,GAASE,kBACTA,GAAiBC,gBACjBA,GAAeE,eACfA,IACE0D,EAEJ,IAAM9D,eAAAA,IAAmB8D,EAQrBC,GAAe,KACnB,MAAMC,GAAuBlG,EAAS,GAAI,IACrCmG,KACAA,KACAA,KACAA,KACAA,IAIL,IAAIC,GAAe,KACnB,MAAMC,GAAuBrG,EAAS,CAAE,EAAE,IACrCsG,KACAA,KACAA,KACAA,IASL,IAAIC,GAA0BxJ,OAAOE,KACnCC,EAAO,KAAM,CACXsJ,aAAc,CACZC,UAAU,EACVC,cAAc,EACdC,YAAY,EACZ7F,MAAO,MAET8F,mBAAoB,CAClBH,UAAU,EACVC,cAAc,EACdC,YAAY,EACZ7F,MAAO,MAET+F,+BAAgC,CAC9BJ,UAAU,EACVC,cAAc,EACdC,YAAY,EACZ7F,OAAO,MAMTgG,GAAc,KAGdC,GAAc,KAGdC,IAAkB,EAGlBC,IAAkB,EAGlBC,IAA0B,EAI1BC,IAA2B,EAK3BC,IAAqB,EAKrBC,IAAe,EAGfC,IAAiB,EAGjBC,IAAa,EAIbC,IAAa,EAMbC,IAAa,EAIbC,IAAsB,EAItBC,IAAsB,EAKtBC,IAAe,EAefC,IAAuB,EAC3B,MAAMC,GAA8B,gBAGpC,IAAIC,IAAe,EAIfC,IAAW,EAGXC,GAAe,CAAA,EAGfC,GAAkB,KACtB,MAAMC,GAA0BnI,EAAS,CAAE,EAAE,CAC3C,iBACA,QACA,WACA,OACA,gBACA,OACA,SACA,OACA,KACA,KACA,KACA,KACA,QACA,UACA,WACA,WACA,YACA,SACA,QACA,MACA,WACA,QACA,QACA,QACA,QAIF,IAAIoI,GAAgB,KACpB,MAAMC,GAAwBrI,EAAS,CAAE,EAAE,CACzC,QACA,QACA,MACA,SACA,QACA,UAIF,IAAIsI,GAAsB,KAC1B,MAAMC,GAA8BvI,EAAS,GAAI,CAC/C,MACA,QACA,MACA,KACA,QACA,OACA,UACA,cACA,OACA,UACA,QACA,QACA,QACA,UAGIwI,GAAmB,qCACnBC,GAAgB,6BAChBC,GAAiB,+BAEvB,IAAIC,GAAYD,GACZE,IAAiB,EAGjBC,GAAqB,KACzB,MAAMC,GAA6B9I,EACjC,GACA,CAACwI,GAAkBC,GAAeC,IAClCnK,GAIF,IAAIwK,GAAoB,KACxB,MAAMC,GAA+B,CAAC,wBAAyB,aACzDC,GAA4B,YAClC,IAAI9I,GAAoB,KAGpB+I,GAAS,KAKb,MAAMC,GAAcnF,EAASoB,cAAc,QAErCgE,GAAoB,SAAUC,GAClC,OAAOA,aAAqBjK,QAAUiK,aAAqBC,UASvDC,GAAe,WAAoB,IAAVC,EAAG9J,UAAAC,OAAA,QAAAS,IAAAV,UAAA,GAAAA,UAAA,GAAG,CAAA,EACnC,IAAIwJ,IAAUA,KAAWM,EAAzB,CAwLA,GAnLKA,GAAsB,iBAARA,IACjBA,EAAM,CAAA,GAIRA,EAAM9I,EAAM8I,GAEZT,IAEmE,IAAjEC,GAA6BlK,QAAQ0K,EAAIT,mBACrCE,GACAO,EAAIT,kBAGV5I,GACwB,0BAAtB4I,GACIxK,EACAH,EAGN6H,GAAehH,EAAqBuK,EAAK,gBACrCxJ,EAAS,CAAE,EAAEwJ,EAAIvD,aAAc9F,IAC/B+F,GACJE,GAAenH,EAAqBuK,EAAK,gBACrCxJ,EAAS,CAAE,EAAEwJ,EAAIpD,aAAcjG,IAC/BkG,GACJwC,GAAqB5J,EAAqBuK,EAAK,sBAC3CxJ,EAAS,CAAE,EAAEwJ,EAAIX,mBAAoBtK,GACrCuK,GACJR,GAAsBrJ,EAAqBuK,EAAK,qBAC5CxJ,EACEU,EAAM6H,IACNiB,EAAIC,kBACJtJ,IAEFoI,GACJH,GAAgBnJ,EAAqBuK,EAAK,qBACtCxJ,EACEU,EAAM2H,IACNmB,EAAIE,kBACJvJ,IAEFkI,GACJH,GAAkBjJ,EAAqBuK,EAAK,mBACxCxJ,EAAS,CAAE,EAAEwJ,EAAItB,gBAAiB/H,IAClCgI,GACJrB,GAAc7H,EAAqBuK,EAAK,eACpCxJ,EAAS,CAAE,EAAEwJ,EAAI1C,YAAa3G,IAC9B,CAAA,EACJ4G,GAAc9H,EAAqBuK,EAAK,eACpCxJ,EAAS,CAAE,EAAEwJ,EAAIzC,YAAa5G,IAC9B,CAAA,EACJ8H,KAAehJ,EAAqBuK,EAAK,iBACrCA,EAAIvB,aAERjB,IAA0C,IAAxBwC,EAAIxC,gBACtBC,IAA0C,IAAxBuC,EAAIvC,gBACtBC,GAA0BsC,EAAItC,0BAA2B,EACzDC,IAA4D,IAAjCqC,EAAIrC,yBAC/BC,GAAqBoC,EAAIpC,qBAAsB,EAC/CC,IAAoC,IAArBmC,EAAInC,aACnBC,GAAiBkC,EAAIlC,iBAAkB,EACvCG,GAAa+B,EAAI/B,aAAc,EAC/BC,GAAsB8B,EAAI9B,sBAAuB,EACjDC,GAAsB6B,EAAI7B,sBAAuB,EACjDH,GAAagC,EAAIhC,aAAc,EAC/BI,IAAoC,IAArB4B,EAAI5B,aACnBC,GAAuB2B,EAAI3B,uBAAwB,EACnDE,IAAoC,IAArByB,EAAIzB,aACnBC,GAAWwB,EAAIxB,WAAY,EAC3B9F,GAAiBsH,EAAIG,oBAAsB3D,EAC3C2C,GAAYa,EAAIb,WAAaD,GAC7BnC,GAA0BiD,EAAIjD,yBAA2B,GAEvDiD,EAAIjD,yBACJ6C,GAAkBI,EAAIjD,wBAAwBC,gBAE9CD,GAAwBC,aACtBgD,EAAIjD,wBAAwBC,cAI9BgD,EAAIjD,yBACJ6C,GAAkBI,EAAIjD,wBAAwBK,sBAE9CL,GAAwBK,mBACtB4C,EAAIjD,wBAAwBK,oBAI9B4C,EAAIjD,yBAEF,kBADKiD,EAAIjD,wBAAwBM,iCAGnCN,GAAwBM,+BACtB2C,EAAIjD,wBAAwBM,gCAG5BO,KACFH,IAAkB,GAGhBS,KACFD,IAAa,GAIXQ,KACFhC,GAAejG,EAAS,GAAImG,GAC5BC,GAAe,IACW,IAAtB6B,GAAa5G,OACfrB,EAASiG,GAAcE,GACvBnG,EAASoG,GAAcE,KAGA,IAArB2B,GAAa3G,MACftB,EAASiG,GAAcE,GACvBnG,EAASoG,GAAcE,GACvBtG,EAASoG,GAAcE,KAGO,IAA5B2B,GAAa1G,aACfvB,EAASiG,GAAcE,GACvBnG,EAASoG,GAAcE,GACvBtG,EAASoG,GAAcE,KAGG,IAAxB2B,GAAaxG,SACfzB,EAASiG,GAAcE,GACvBnG,EAASoG,GAAcE,GACvBtG,EAASoG,GAAcE,KAKvBkD,EAAII,WACF3D,KAAiBC,KACnBD,GAAevF,EAAMuF,KAGvBjG,EAASiG,GAAcuD,EAAII,SAAUzJ,KAGnCqJ,EAAIK,WACFzD,KAAiBC,KACnBD,GAAe1F,EAAM0F,KAGvBpG,EAASoG,GAAcoD,EAAIK,SAAU1J,KAGnCqJ,EAAIC,mBACNzJ,EAASsI,GAAqBkB,EAAIC,kBAAmBtJ,IAGnDqJ,EAAItB,kBACFA,KAAoBC,KACtBD,GAAkBxH,EAAMwH,KAG1BlI,EAASkI,GAAiBsB,EAAItB,gBAAiB/H,KAI7C4H,KACF9B,GAAa,UAAW,GAItBqB,IACFtH,EAASiG,GAAc,CAAC,OAAQ,OAAQ,SAItCA,GAAa6D,QACf9J,EAASiG,GAAc,CAAC,iBACjBa,GAAYiD,OAGjBP,EAAIQ,qBAAsB,CAC5B,GAAmD,mBAAxCR,EAAIQ,qBAAqB7G,WAClC,MAAM7D,EACJ,+EAIJ,GAAwD,mBAA7CkK,EAAIQ,qBAAqB5G,gBAClC,MAAM9D,EACJ,oFAKJiG,GAAqBiE,EAAIQ,qBAGzBxE,GAAYD,GAAmBpC,WAAW,GAC5C,WAE6B/C,IAAvBmF,KACFA,GAAqB7C,GACnBC,EACAyB,IAKuB,OAAvBmB,IAAoD,iBAAdC,KACxCA,GAAYD,GAAmBpC,WAAW,KAM1CnG,GACFA,EAAOwM,GAGTN,GAASM,CA7NT,GAgOIS,GAAiCjK,EAAS,CAAA,EAAI,CAClD,KACA,KACA,KACA,KACA,UAGIkK,GAA0BlK,EAAS,CAAA,EAAI,CAC3C,gBACA,mBAOImK,GAA+BnK,EAAS,CAAA,EAAI,CAChD,QACA,QACA,OACA,IACA,WAMIoK,GAAepK,EAAS,CAAA,EAAI,IAC7BmG,KACAA,KACAA,IAECkE,GAAkBrK,EAAS,CAAE,EAAE,IAChCmG,KACAA,IASCmE,GAAuB,SAAUhK,GACrC,IAAIiK,EAASrF,GAAc5E,GAItBiK,GAAWA,EAAOC,UACrBD,EAAS,CACPE,aAAc9B,GACd6B,QAAS,aAIb,MAAMA,EAAUpM,EAAkBkC,EAAQkK,SACpCE,EAAgBtM,EAAkBmM,EAAOC,SAE/C,QAAK3B,GAAmBvI,EAAQmK,gBAI5BnK,EAAQmK,eAAiBhC,GAIvB8B,EAAOE,eAAiB/B,GACP,QAAZ8B,EAMLD,EAAOE,eAAiBjC,GAEZ,QAAZgC,IACmB,mBAAlBE,GACCT,GAA+BS,IAM9BC,QAAQP,GAAaI,IAG1BlK,EAAQmK,eAAiBjC,GAIvB+B,EAAOE,eAAiB/B,GACP,SAAZ8B,EAKLD,EAAOE,eAAiBhC,GACP,SAAZ+B,GAAsBN,GAAwBQ,GAKhDC,QAAQN,GAAgBG,IAG7BlK,EAAQmK,eAAiB/B,KAKzB6B,EAAOE,eAAiBhC,KACvByB,GAAwBQ,QAMzBH,EAAOE,eAAiBjC,KACvByB,GAA+BS,OAQ/BL,GAAgBG,KAChBL,GAA6BK,KAAaJ,GAAaI,QAMpC,0BAAtBzB,KACAF,GAAmBvI,EAAQmK,iBAiBzBG,GAAe,SAAUC,GAC7B3M,EAAUyF,EAAUI,QAAS,CAAEzD,QAASuK,IAExC,IAEEA,EAAKC,WAAWC,YAAYF,EAG9B,CAFE,MAAOvH,GACPuH,EAAKG,QACP,GASIC,GAAmB,SAAUC,EAAML,GACvC,IACE3M,EAAUyF,EAAUI,QAAS,CAC3BoH,UAAWN,EAAKO,iBAAiBF,GACjCG,KAAMR,GAOV,CALE,MAAOvH,GACPpF,EAAUyF,EAAUI,QAAS,CAC3BoH,UAAW,KACXE,KAAMR,GAEV,CAKA,GAHAA,EAAKS,gBAAgBJ,GAGR,OAATA,IAAkB9E,GAAa8E,GACjC,GAAIzD,IAAcC,GAChB,IACEkD,GAAaC,EACF,CAAX,MAAOvH,GAAI,MAEb,IACEuH,EAAKU,aAAaL,EAAM,GACb,CAAX,MAAO5H,GAAI,GAWbkI,GAAgB,SAAUC,GAE9B,IAAIC,EAAM,KACNC,EAAoB,KAExB,GAAInE,GACFiE,EAAQ,oBAAsBA,MACzB,CAEL,MAAMG,EAAUnN,EAAYgN,EAAO,eACnCE,EAAoBC,GAAWA,EAAQ,EACzC,CAGwB,0BAAtB7C,IACAJ,KAAcD,KAGd+C,EACE,iEACAA,EACA,kBAGJ,MAAMI,EAAetG,GACjBA,GAAmBpC,WAAWsI,GAC9BA,EAKJ,GAAI9C,KAAcD,GAChB,IACEgD,GAAM,IAAI7G,GAAYiH,gBAAgBD,EAAc9C,GACzC,CAAX,MAAOzF,GAAI,CAIf,IAAKoI,IAAQA,EAAIK,gBAAiB,CAChCL,EAAMjG,GAAeuG,eAAerD,GAAW,WAAY,MAC3D,IACE+C,EAAIK,gBAAgBE,UAAYrD,GAC5BpD,GACAqG,CAEJ,CADA,MAAOvI,GACP,CAEJ,CAEA,MAAM4I,EAAOR,EAAIQ,MAAQR,EAAIK,gBAU7B,OARIN,GAASE,GACXO,EAAKC,aACHnI,EAASoI,eAAeT,GACxBO,EAAKG,WAAW,IAAM,MAKtB1D,KAAcD,GACT9C,GAAqB0G,KAC1BZ,EACApE,GAAiB,OAAS,QAC1B,GAGGA,GAAiBoE,EAAIK,gBAAkBG,GAS1CK,GAAsB,SAAU3I,GACpC,OAAO8B,GAAmB4G,KACxB1I,EAAK0B,eAAiB1B,EACtBA,EAEAa,EAAW+H,aACT/H,EAAWgI,aACXhI,EAAWiI,UACXjI,EAAWkI,4BACXlI,EAAWmI,mBACb,OAUEC,GAAe,SAAUC,GAC7B,OACEA,aAAelI,IACU,iBAAjBkI,EAAIC,UACiB,iBAApBD,EAAIE,aACgB,mBAApBF,EAAI/B,eACT+B,EAAIG,sBAAsBvI,IACG,mBAAxBoI,EAAIxB,iBACiB,mBAArBwB,EAAIvB,cACiB,iBAArBuB,EAAIrC,cACiB,mBAArBqC,EAAIX,cACkB,mBAAtBW,EAAII,gBAUXC,GAAU,SAAUxM,GACxB,MAAuB,mBAAT4D,GAAuB5D,aAAkB4D,GAWnD6I,GAAe,SAAUC,EAAYC,EAAaC,GACjDzH,GAAMuH,IAIX1P,EAAamI,GAAMuH,IAAcG,IAC/BA,EAAKlB,KAAK3I,EAAW2J,EAAaC,EAAMrE,GAAO,KAc7CuE,GAAoB,SAAUH,GAClC,IAAIjI,EAAU,KAMd,GAHA+H,GAAa,yBAA0BE,EAAa,MAGhDT,GAAaS,GAEf,OADA1C,GAAa0C,IACN,EAIT,MAAM9C,EAAUrK,GAAkBmN,EAAYP,UAS9C,GANAK,GAAa,sBAAuBE,EAAa,CAC/C9C,UACAkD,YAAazH,KAKbqH,EAAYJ,kBACXC,GAAQG,EAAYK,oBACrBxO,EAAW,UAAWmO,EAAYrB,YAClC9M,EAAW,UAAWmO,EAAYN,aAGlC,OADApC,GAAa0C,IACN,EAIT,GAAIA,EAAYrJ,WAAa1B,EAE3B,OADAqI,GAAa0C,IACN,EAIT,GACEjG,IACAiG,EAAYrJ,WAAa1B,GACzBpD,EAAW,UAAWmO,EAAYC,MAGlC,OADA3C,GAAa0C,IACN,EAIT,IAAKrH,GAAauE,IAAY1D,GAAY0D,GAAU,CAElD,IAAK1D,GAAY0D,IAAYoD,GAAsBpD,GAAU,CAC3D,GACEjE,GAAwBC,wBAAwBpH,QAChDD,EAAWoH,GAAwBC,aAAcgE,GAEjD,OAAO,EAGT,GACEjE,GAAwBC,wBAAwB8C,UAChD/C,GAAwBC,aAAagE,GAErC,OAAO,CAEX,CAGA,GAAIzC,KAAiBG,GAAgBsC,GAAU,CAC7C,MAAMM,EAAa5F,GAAcoI,IAAgBA,EAAYxC,WACvDuB,EAAapH,GAAcqI,IAAgBA,EAAYjB,WAE7D,GAAIA,GAAcvB,EAAY,CAG5B,IAAK,IAAI+C,EAFUxB,EAAW1M,OAEJ,EAAGkO,GAAK,IAAKA,EAAG,CACxC,MAAMC,EAAa/I,EAAUsH,EAAWwB,IAAI,GAC5CC,EAAWC,gBAAkBT,EAAYS,gBAAkB,GAAK,EAChEjD,EAAWqB,aAAa2B,EAAY9I,EAAesI,GACrD,CACF,CACF,CAGA,OADA1C,GAAa0C,IACN,CACT,CAGA,OAAIA,aAAuB9I,IAAY8F,GAAqBgD,IAC1D1C,GAAa0C,IACN,GAKM,aAAZ9C,GACa,YAAZA,GACY,aAAZA,IACFrL,EAAW,8BAA+BmO,EAAYrB,YAOpD7E,IAAsBkG,EAAYrJ,WAAa1B,IAEjD8C,EAAUiI,EAAYN,YAEtBrP,EAAa,CAACkE,GAAeC,GAAUC,KAAeiM,IACpD3I,EAAU1G,EAAc0G,EAAS2I,EAAM,IAAI,IAGzCV,EAAYN,cAAgB3H,IAC9BnH,EAAUyF,EAAUI,QAAS,CAAEzD,QAASgN,EAAYvI,cACpDuI,EAAYN,YAAc3H,IAK9B+H,GAAa,wBAAyBE,EAAa,OAE5C,IAtBL1C,GAAa0C,IACN,IAiCLW,GAAoB,SAAUC,EAAOC,EAAQrN,GAEjD,GACE8G,KACY,OAAXuG,GAA8B,SAAXA,KACnBrN,KAASkD,GAAYlD,KAASqI,IAE/B,OAAO,EAOT,GACElC,KACCF,GAAYoH,IACbhP,EAAW6C,GAAWmM,SAGjB,GAAInH,IAAmB7H,EAAW8C,GAAWkM,SAG7C,IAAK/H,GAAa+H,IAAWpH,GAAYoH,IAC9C,KAIGP,GAAsBM,KACnB3H,GAAwBC,wBAAwBpH,QAChDD,EAAWoH,GAAwBC,aAAc0H,IAChD3H,GAAwBC,wBAAwB8C,UAC/C/C,GAAwBC,aAAa0H,MACvC3H,GAAwBK,8BAA8BxH,QACtDD,EAAWoH,GAAwBK,mBAAoBuH,IACtD5H,GAAwBK,8BAA8B0C,UACrD/C,GAAwBK,mBAAmBuH,KAGrC,OAAXA,GACC5H,GAAwBM,iCACtBN,GAAwBC,wBAAwBpH,QAChDD,EAAWoH,GAAwBC,aAAc1F,IAChDyF,GAAwBC,wBAAwB8C,UAC/C/C,GAAwBC,aAAa1F,KAK3C,OAAO,OAGJ,GAAIwH,GAAoB6F,SAIxB,GACLhP,EAAW+C,GAAgBvD,EAAcmC,EAAOsB,GAAiB,WAK5D,GACO,QAAX+L,GAA+B,eAAXA,GAAsC,SAAXA,GACtC,WAAVD,GACkC,IAAlCrP,EAAciC,EAAO,WACrBsH,GAAc8F,IAMT,GACLhH,KACC/H,EAAWgD,GAAmBxD,EAAcmC,EAAOsB,GAAiB,WAIhE,GAAItB,EACT,OAAO,OAMT,OAAO,GAWH8M,GAAwB,SAAUpD,GACtC,MAAmB,mBAAZA,GAAgC/L,EAAY+L,EAASlI,KAaxD8L,GAAsB,SAAUd,GAEpCF,GAAa,2BAA4BE,EAAa,MAEtD,MAAML,WAAEA,GAAeK,EAGvB,IAAKL,EACH,OAGF,MAAMoB,EAAY,CAChBC,SAAU,GACVC,UAAW,GACXC,UAAU,EACVC,kBAAmBrI,IAErB,IAAI/F,EAAI4M,EAAWtN,OAGnB,KAAOU,KAAK,CACV,MAAMqO,EAAOzB,EAAW5M,IAClB6K,KAAEA,EAAIT,aAAEA,EAAc3J,MAAOyN,GAAcG,EAC3CP,EAAShO,GAAkB+K,GAEjC,IAAIpK,EAAiB,UAAToK,EAAmBqD,EAAYxP,EAAWwP,GAUtD,GAPAF,EAAUC,SAAWH,EACrBE,EAAUE,UAAYzN,EACtBuN,EAAUG,UAAW,EACrBH,EAAUM,mBAAgBvO,EAC1BgN,GAAa,wBAAyBE,EAAae,GACnDvN,EAAQuN,EAAUE,UAEdF,EAAUM,cACZ,SAOF,GAHA1D,GAAiBC,EAAMoC,IAGlBe,EAAUG,SACb,SAIF,IAAKrH,IAA4BhI,EAAW,OAAQ2B,GAAQ,CAC1DmK,GAAiBC,EAAMoC,GACvB,QACF,CAGA,GAAIjG,IAAgBlI,EAAW,gCAAiC2B,GAAQ,CACtEmK,GAAiBC,EAAMoC,GACvB,QACF,CAGIlG,IACFzJ,EAAa,CAACkE,GAAeC,GAAUC,KAAeiM,IACpDlN,EAAQnC,EAAcmC,EAAOkN,EAAM,IAAI,IAK3C,MAAME,EAAQ/N,GAAkBmN,EAAYP,UAC5C,GAAKkB,GAAkBC,EAAOC,EAAQrN,GAAtC,CAgBA,IATI+G,IAAoC,OAAXsG,GAA8B,SAAXA,IAE9ClD,GAAiBC,EAAMoC,GAGvBxM,EAAQgH,GAA8BhH,GAKtCyE,IACwB,iBAAjB5C,GACkC,mBAAlCA,EAAaiM,iBAEpB,GAAInE,QAGF,OAAQ9H,EAAaiM,iBAAiBV,EAAOC,IAC3C,IAAK,cACHrN,EAAQyE,GAAmBpC,WAAWrC,GACtC,MAGF,IAAK,mBACHA,EAAQyE,GAAmBnC,gBAAgBtC,GAYnD,IACM2J,EACF6C,EAAYuB,eAAepE,EAAcS,EAAMpK,GAG/CwM,EAAY/B,aAAaL,EAAMpK,GAG7B+L,GAAaS,GACf1C,GAAa0C,GAEbtP,EAAS2F,EAAUI,QAEV,CAAX,MAAOT,GAAI,CAtDb,CAuDF,CAGA8J,GAAa,0BAA2BE,EAAa,OAQjDwB,GAAqB,SAArBA,EAA+BC,GACnC,IAAIC,EAAa,KACjB,MAAMC,EAAiB1C,GAAoBwC,GAK3C,IAFA3B,GAAa,0BAA2B2B,EAAU,MAE1CC,EAAaC,EAAeC,YAElC9B,GAAa,yBAA0B4B,EAAY,MAG/CvB,GAAkBuB,KAKlBA,EAAW3J,mBAAmBhB,GAChCyK,EAAmBE,EAAW3J,SAIhC+I,GAAoBY,IAItB5B,GAAa,yBAA0B2B,EAAU,OAuRnD,OA5QApL,EAAUwL,SAAW,SAAU1D,GAAiB,IAAVjC,EAAG9J,UAAAC,OAAA,QAAAS,IAAAV,UAAA,GAAAA,UAAA,GAAG,CAAA,EACtCwM,EAAO,KACPkD,EAAe,KACf9B,EAAc,KACd+B,EAAa,KAUjB,GANAzG,IAAkB6C,EACd7C,KACF6C,EAAQ,eAIW,iBAAVA,IAAuB0B,GAAQ1B,GAAQ,CAChD,GAA8B,mBAAnBA,EAAMjN,SAMf,MAAMc,EAAgB,8BAJtB,GAAqB,iBADrBmM,EAAQA,EAAMjN,YAEZ,MAAMc,EAAgB,kCAK5B,CAGA,IAAKqE,EAAUO,YACb,OAAOuH,EAgBT,GAZKlE,IACHgC,GAAaC,GAIf7F,EAAUI,QAAU,GAGC,iBAAV0H,IACTzD,IAAW,GAGTA,IAEF,GAAIyD,EAAMsB,SAAU,CAClB,MAAMvC,EAAUrK,GAAkBsL,EAAMsB,UACxC,IAAK9G,GAAauE,IAAY1D,GAAY0D,GACxC,MAAMlL,EACJ,0DAGN,OACK,GAAImM,aAAiBlH,EAG1B2H,EAAOV,GAAc,iBACrB4D,EAAelD,EAAK5G,cAAcO,WAAW4F,GAAO,GAElD2D,EAAanL,WAAa1B,GACA,SAA1B6M,EAAarC,UAIsB,SAA1BqC,EAAarC,SADtBb,EAAOkD,EAKPlD,EAAKoD,YAAYF,OAEd,CAEL,IACG3H,KACAL,KACAE,KAEuB,IAAxBmE,EAAM3M,QAAQ,KAEd,OAAOyG,IAAsBoC,GACzBpC,GAAmBpC,WAAWsI,GAC9BA,EAON,GAHAS,EAAOV,GAAcC,IAGhBS,EACH,OAAOzE,GAAa,KAAOE,GAAsBnC,GAAY,EAEjE,CAGI0G,GAAQ1E,IACVoD,GAAasB,EAAKqD,YAIpB,MAAMC,EAAejD,GAAoBvE,GAAWyD,EAAQS,GAG5D,KAAQoB,EAAckC,EAAaN,YAE7BzB,GAAkBH,KAKlBA,EAAYjI,mBAAmBhB,GACjCyK,GAAmBxB,EAAYjI,SAIjC+I,GAAoBd,IAItB,GAAItF,GACF,OAAOyD,EAIT,GAAIhE,GAAY,CACd,GAAIC,GAGF,IAFA2H,EAAa1J,GAAuB2G,KAAKJ,EAAK5G,eAEvC4G,EAAKqD,YAEVF,EAAWC,YAAYpD,EAAKqD,iBAG9BF,EAAanD,EAcf,OAXI9F,GAAaqJ,YAAcrJ,GAAasJ,kBAQ1CL,EAAaxJ,GAAWyG,KAAKnI,EAAkBkL,GAAY,IAGtDA,CACT,CAEA,IAAIM,EAAiBrI,GAAiB4E,EAAK0D,UAAY1D,EAAKD,UAsB5D,OAlBE3E,IACArB,GAAa,aACbiG,EAAK5G,eACL4G,EAAK5G,cAAcuK,SACnB3D,EAAK5G,cAAcuK,QAAQ3E,MAC3B/L,EAAW6G,EAA0BkG,EAAK5G,cAAcuK,QAAQ3E,QAEhEyE,EACE,aAAezD,EAAK5G,cAAcuK,QAAQ3E,KAAO,MAAQyE,GAIzDvI,IACFzJ,EAAa,CAACkE,GAAeC,GAAUC,KAAeiM,IACpD2B,EAAiBhR,EAAcgR,EAAgB3B,EAAM,IAAI,IAItDzI,IAAsBoC,GACzBpC,GAAmBpC,WAAWwM,GAC9BA,GASNhM,EAAUmM,UAAY,WAAoB,IAAVtG,EAAG9J,UAAAC,OAAA,QAAAS,IAAAV,UAAA,GAAAA,UAAA,GAAG,CAAA,EACpC6J,GAAaC,GACbjC,IAAa,GAQf5D,EAAUoM,YAAc,WACtB7G,GAAS,KACT3B,IAAa,GAaf5D,EAAUqM,iBAAmB,SAAUC,EAAKvB,EAAM5N,GAE3CoI,IACHK,GAAa,CAAE,GAGjB,MAAM2E,EAAQ/N,GAAkB8P,GAC1B9B,EAAShO,GAAkBuO,GACjC,OAAOT,GAAkBC,EAAOC,EAAQrN,IAU1C6C,EAAUuM,QAAU,SAAU7C,EAAY8C,GACZ,mBAAjBA,IAIXrK,GAAMuH,GAAcvH,GAAMuH,IAAe,GACzCnP,EAAU4H,GAAMuH,GAAa8C,KAW/BxM,EAAUyM,WAAa,SAAU/C,GAC/B,GAAIvH,GAAMuH,GACR,OAAOrP,EAAS8H,GAAMuH,KAU1B1J,EAAU0M,YAAc,SAAUhD,GAC5BvH,GAAMuH,KACRvH,GAAMuH,GAAc,KAQxB1J,EAAU2M,eAAiB,WACzBxK,GAAQ,CAAA,GAGHnC,CACT,CAEeD"}
+210
lib/rich_text_lite.ts
··· 1 + /* 2 + Extracted from https://github.com/bluesky-social/atproto/ 3 + 4 + Copyright (c) 2022-2025 Bluesky Social PBC, and Contributors 5 + 6 + MIT License 7 + 8 + Permission is hereby granted, free of charge, to any person obtaining a copy 9 + of this software and associated documentation files (the "Software"), to deal 10 + in the Software without restriction, including without limitation the rights 11 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 + copies of the Software, and to permit persons to whom the Software is 13 + furnished to do so, subject to the following conditions: 14 + 15 + The above copyright notice and this permission notice shall be included in all 16 + copies or substantial portions of the Software. 17 + 18 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 + SOFTWARE. 25 + */ 26 + 27 + // packages/api/src/rich-text/rich-text.ts 28 + 29 + export interface ByteSlice { 30 + $type?: 'app.bsky.richtext.facet#byteSlice' 31 + byteStart: number 32 + byteEnd: number 33 + } 34 + 35 + export interface Facet { 36 + $type?: 'app.bsky.richtext.facet' 37 + index: ByteSlice 38 + features: (FacetMention | FacetLink | FacetTag | { $type: string })[] 39 + } 40 + 41 + export interface FacetTag { 42 + $type?: 'app.bsky.richtext.facet#tag' 43 + tag: string 44 + } 45 + 46 + export interface FacetLink { 47 + $type?: 'app.bsky.richtext.facet#link' 48 + uri: string 49 + } 50 + 51 + export interface FacetMention { 52 + $type?: 'app.bsky.richtext.facet#mention' 53 + did: string 54 + } 55 + 56 + export interface RichTextProps { 57 + text: string 58 + facets?: Facet[] | undefined 59 + } 60 + 61 + export class RichTextSegment { 62 + constructor(public text: string, public facet?: Facet) {} 63 + 64 + get link(): FacetLink | undefined { 65 + return this.facet?.features.find(v => v.$type === 'app.bsky.richtext.facet#link') as FacetLink 66 + } 67 + 68 + isLink() { 69 + return !!this.link 70 + } 71 + 72 + get mention(): FacetMention | undefined { 73 + return this.facet?.features.find(v => v.$type === 'app.bsky.richtext.facet#mention') as FacetMention 74 + } 75 + 76 + isMention() { 77 + return !!this.mention 78 + } 79 + 80 + get tag(): FacetTag | undefined { 81 + return this.facet?.features.find(v => v.$type === 'app.bsky.richtext.facet#tag') as FacetTag 82 + } 83 + 84 + isTag() { 85 + return !!this.tag 86 + } 87 + } 88 + 89 + export class RichText { 90 + unicodeText: UnicodeString 91 + facets?: Facet[] | undefined 92 + 93 + constructor(props: RichTextProps) { 94 + this.unicodeText = new UnicodeString(props.text); 95 + this.facets = props.facets; 96 + 97 + if (this.facets) { 98 + this.facets = this.facets.filter(facetFilter).sort(facetSort) 99 + } 100 + } 101 + 102 + get text() { 103 + return this.unicodeText.toString(); 104 + } 105 + 106 + get length() { 107 + return this.unicodeText.length; 108 + } 109 + 110 + get graphemeLength() { 111 + return this.unicodeText.graphemeLength; 112 + } 113 + 114 + *segments(): Generator<RichTextSegment, void, void> { 115 + const facets = this.facets || []; 116 + 117 + if (!facets.length) { 118 + yield new RichTextSegment(this.unicodeText.utf16); 119 + return; 120 + } 121 + 122 + let textCursor = 0; 123 + let facetCursor = 0; 124 + 125 + do { 126 + const currFacet = facets[facetCursor]; 127 + 128 + if (textCursor < currFacet.index.byteStart) { 129 + yield new RichTextSegment(this.unicodeText.slice(textCursor, currFacet.index.byteStart)); 130 + } else if (textCursor > currFacet.index.byteStart) { 131 + facetCursor++; 132 + continue; 133 + } 134 + 135 + if (currFacet.index.byteStart < currFacet.index.byteEnd) { 136 + const subtext = this.unicodeText.slice(currFacet.index.byteStart, currFacet.index.byteEnd); 137 + 138 + if (!subtext.trim()) { 139 + // dont empty string entities 140 + yield new RichTextSegment(subtext); 141 + } else { 142 + yield new RichTextSegment(subtext, currFacet); 143 + } 144 + } 145 + 146 + textCursor = currFacet.index.byteEnd; 147 + facetCursor++; 148 + } while (facetCursor < facets.length); 149 + 150 + if (textCursor < this.unicodeText.length) { 151 + yield new RichTextSegment(this.unicodeText.slice(textCursor, this.unicodeText.length)); 152 + } 153 + } 154 + } 155 + 156 + const facetSort = (a: Facet, b: Facet) => a.index.byteStart - b.index.byteStart 157 + 158 + const facetFilter = (facet: Facet) => 159 + // discard negative-length facets. zero-length facets are valid 160 + facet.index.byteStart <= facet.index.byteEnd 161 + 162 + 163 + // packages/api/src/rich-text/unicode.ts 164 + 165 + /** 166 + * Javascript uses utf16-encoded strings while most environments and specs 167 + * have standardized around utf8 (including JSON). 168 + * 169 + * After some lengthy debated we decided that richtext facets need to use 170 + * utf8 indices. This means we need tools to convert indices between utf8 171 + * and utf16, and that's precisely what this library handles. 172 + */ 173 + 174 + const encoder = new TextEncoder() 175 + const decoder = new TextDecoder() 176 + const segmenter = new Intl.Segmenter(); 177 + 178 + export const graphemeLen = (str: string): number => { 179 + return Array.from(segmenter.segment(str)).length; 180 + } 181 + 182 + export class UnicodeString { 183 + utf16: string 184 + utf8: Uint8Array 185 + private _graphemeLen?: number | undefined 186 + 187 + constructor(utf16: string) { 188 + this.utf16 = utf16; 189 + this.utf8 = encoder.encode(utf16); 190 + } 191 + 192 + get length() { 193 + return this.utf8.byteLength; 194 + } 195 + 196 + get graphemeLength() { 197 + if (!this._graphemeLen) { 198 + this._graphemeLen = graphemeLen(this.utf16) 199 + } 200 + return this._graphemeLen; 201 + } 202 + 203 + slice(start?: number, end?: number): string { 204 + return decoder.decode(this.utf8.slice(start, end)); 205 + } 206 + 207 + toString() { 208 + return this.utf16; 209 + } 210 + }
-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 - }
-383
minisky.js
··· 1 - /** 2 - * Thrown when status code of an API response is not "success". 3 - */ 4 - 5 - class APIError extends Error { 6 - 7 - /** @param {number} code, @param {json} json */ 8 - constructor(code, json) { 9 - super("APIError status " + code + "\n\n" + JSON.stringify(json)); 10 - this.code = code; 11 - this.json = json; 12 - } 13 - } 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 - 27 - class AuthError extends Error {} 28 - 29 - 30 - /** 31 - * Thrown when DID or DID document is invalid. 32 - */ 33 - 34 - class DIDError extends Error {} 35 - 36 - 37 - /** 38 - * Base API client for connecting to an ATProto XRPC API. 39 - */ 40 - 41 - class Minisky { 42 - 43 - /** @param {string} did, @returns {Promise<string>} */ 44 - 45 - static async pdsEndpointForDid(did) { 46 - let url; 47 - 48 - if (did.startsWith('did:plc:')) { 49 - url = new URL(`https://plc.directory/${did}`); 50 - } else if (did.startsWith('did:web:')) { 51 - let host = did.replace(/^did:web:/, ''); 52 - url = new URL(`https://${host}/.well-known/did.json`); 53 - } else { 54 - throw new DIDError("Unknown DID type: " + did); 55 - } 56 - 57 - let response = await fetch(url); 58 - let text = await response.text(); 59 - let json = text.trim().length > 0 ? JSON.parse(text) : undefined; 60 - 61 - if (response.status == 200) { 62 - let service = (json.service || []).find(s => s.id == '#atproto_pds'); 63 - if (service) { 64 - return service.serviceEndpoint.replace('https://', ''); 65 - } else { 66 - throw new DIDError("Missing #atproto_pds service definition"); 67 - } 68 - } else { 69 - throw new APIError(response.status, json); 70 - } 71 - } 72 - 73 - /** 74 - * @typedef {object} MiniskyOptions 75 - * @prop {boolean} [sendAuthHeaders] 76 - * @prop {boolean} [autoManageTokens] 77 - * 78 - * @typedef {object} MiniskyConfig 79 - * @prop {json | null | undefined} user 80 - * @prop {() => void} save 81 - * 82 - * @param {string | undefined} host 83 - * @param {MiniskyConfig | null | undefined} [config] 84 - * @param {MiniskyOptions} [options] 85 - */ 86 - 87 - constructor(host, config, options) { 88 - this.host = host; 89 - this.config = config; 90 - this.user = /** @type {json} */ (config?.user); 91 - 92 - this.sendAuthHeaders = !!this.user; 93 - this.autoManageTokens = !!this.user; 94 - 95 - if (options) { 96 - Object.assign(this, options); 97 - } 98 - } 99 - 100 - /** @returns {string} */ 101 - 102 - get baseURL() { 103 - if (this.host) { 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 - 111 - /** @returns {boolean} */ 112 - 113 - get isLoggedIn() { 114 - return !!(this.user && this.user.accessToken && this.user.refreshToken && this.user.did && this.user.pdsEndpoint); 115 - } 116 - 117 - /** 118 - * @typedef {object} MiniskyRequestOptions 119 - * @prop {string | boolean} [auth] 120 - * @prop {Record<string, string>} [headers] 121 - * 122 - * @param {string} method, @param {json | null} [params], @param {MiniskyRequestOptions} [options] 123 - * @returns {Promise<json>} 124 - */ 125 - 126 - async getRequest(method, params, options) { 127 - let url = new URL(`${this.baseURL}/${method}`); 128 - let auth = options && ('auth' in options) ? options.auth : this.sendAuthHeaders; 129 - 130 - if (this.autoManageTokens && auth === true) { 131 - await this.checkAccess(); 132 - } 133 - 134 - if (params) { 135 - for (let p in params) { 136 - if (params[p] instanceof Array) { 137 - params[p].forEach(x => url.searchParams.append(p, x)); 138 - } else { 139 - url.searchParams.append(p, params[p]); 140 - } 141 - } 142 - } 143 - 144 - let headers = this.authHeaders(auth); 145 - 146 - if (options && options.headers) { 147 - Object.assign(headers, options.headers); 148 - } 149 - 150 - let response = await fetch(url, { headers: headers }); 151 - return await this.parseResponse(response); 152 - } 153 - 154 - /** 155 - * @param {string} method, @param {json | null} [data], @param {MiniskyRequestOptions} [options] 156 - * @returns Promise<json> 157 - */ 158 - 159 - async postRequest(method, data, options) { 160 - let url = `${this.baseURL}/${method}`; 161 - let auth = options && ('auth' in options) ? options.auth : this.sendAuthHeaders; 162 - 163 - if (this.autoManageTokens && auth === true) { 164 - await this.checkAccess(); 165 - } 166 - 167 - let request = { method: 'POST', headers: this.authHeaders(auth) }; 168 - 169 - if (data) { 170 - request.body = JSON.stringify(data); 171 - request.headers['Content-Type'] = 'application/json'; 172 - } 173 - 174 - if (options && options.headers) { 175 - Object.assign(request.headers, options.headers); 176 - } 177 - 178 - let response = await fetch(url, request); 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) { 247 - if (typeof auth == 'string') { 248 - return { 'Authorization': `Bearer ${auth}` }; 249 - } else if (auth) { 250 - if (this.user?.accessToken) { 251 - return { 'Authorization': `Bearer ${this.user.accessToken}` }; 252 - } else { 253 - throw new AuthError("Can't send auth headers, access token is missing"); 254 - } 255 - } else { 256 - return {}; 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) { 277 - let parts = token.split('.'); 278 - if (parts.length != 3) { 279 - throw new AuthError("Invalid access token format"); 280 - } 281 - 282 - let payload = JSON.parse(atob(parts[1])); 283 - let exp = payload.exp; 284 - 285 - if (!(exp && typeof exp == 'number' && exp > 0)) { 286 - throw new AuthError("Invalid token expiry data"); 287 - } 288 - 289 - return exp * 1000; 290 - } 291 - 292 - /** @param {Response} response, @param {json} json, @returns {boolean} */ 293 - 294 - isInvalidToken(response, json) { 295 - return (response.status == 400) && !!json && ['InvalidToken', 'ExpiredToken'].includes(json.error); 296 - } 297 - 298 - /** @param {Response} response, @returns {Promise<json>} */ 299 - 300 - async parseResponse(response) { 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); 308 - } 309 - } 310 - 311 - /** @returns {Promise<void>} */ 312 - 313 - async checkAccess() { 314 - if (!this.isLoggedIn) { 315 - throw new AuthError("Not logged in"); 316 - } 317 - 318 - let expirationTimestamp = this.tokenExpirationTimestamp(this.user.accessToken); 319 - 320 - if (expirationTimestamp < new Date().getTime() + 60 * 1000) { 321 - await this.performTokenRefresh(); 322 - } 323 - } 324 - 325 - /** @param {string} handle, @param {string} password, @returns {Promise<json>} */ 326 - 327 - async logIn(handle, password) { 328 - if (!this.config || !this.config.user) { 329 - throw new AuthError("Missing user configuration object"); 330 - } 331 - 332 - let params = { identifier: handle, password: password }; 333 - let json = await this.postRequest('com.atproto.server.createSession', params, { auth: false }); 334 - 335 - this.saveTokens(json); 336 - return json; 337 - } 338 - 339 - /** @returns {Promise<json>} */ 340 - 341 - async performTokenRefresh() { 342 - if (!this.isLoggedIn) { 343 - throw new AuthError("Not logged in"); 344 - } 345 - 346 - console.log('Refreshing access tokenโ€ฆ'); 347 - let json = await this.postRequest('com.atproto.server.refreshSession', null, { auth: this.user.refreshToken }); 348 - this.saveTokens(json); 349 - return json; 350 - } 351 - 352 - /** @param {json} json */ 353 - 354 - saveTokens(json) { 355 - if (!this.config || !this.config.user) { 356 - throw new AuthError("Missing user configuration object"); 357 - } 358 - 359 - this.user.accessToken = json['accessJwt']; 360 - this.user.refreshToken = json['refreshJwt']; 361 - this.user.did = json['did']; 362 - 363 - if (json.didDoc?.service) { 364 - let service = json.didDoc.service.find(s => s.id == '#atproto_pds'); 365 - this.host = service.serviceEndpoint.replace('https://', ''); 366 - } 367 - 368 - this.user.pdsEndpoint = this.host; 369 - this.config.save(); 370 - } 371 - 372 - resetTokens() { 373 - if (!this.config || !this.config.user) { 374 - throw new AuthError("Missing user configuration object"); 375 - } 376 - 377 - delete this.user.accessToken; 378 - delete this.user.refreshToken; 379 - delete this.user.did; 380 - delete this.user.pdsEndpoint; 381 - this.config.save(); 382 - } 383 - }
-772
models.js
··· 1 - /** 2 - * Thrown when parsing post JSON fails. 3 - */ 4 - 5 - class PostDataError extends Error { 6 - 7 - /** @param {string} message */ 8 - constructor(message) { 9 - super(message); 10 - } 11 - } 12 - 13 - 14 - /** 15 - * Generic record type, base class for all records or record view objects. 16 - */ 17 - 18 - class ATProtoRecord { 19 - 20 - /** @param {json} data, @param {json} [extra] */ 21 - constructor(data, extra) { 22 - this.data = data; 23 - Object.assign(this, extra ?? {}); 24 - } 25 - 26 - /** @returns {string} */ 27 - get uri() { 28 - return this.data.uri; 29 - } 30 - 31 - /** @returns {string} */ 32 - get cid() { 33 - return this.data.cid; 34 - } 35 - 36 - /** @returns {string} */ 37 - get rkey() { 38 - return atURI(this.uri).rkey; 39 - } 40 - 41 - /** @returns {string} */ 42 - get type() { 43 - return this.data.$type; 44 - } 45 - } 46 - 47 - 48 - /** 49 - * Standard Bluesky post record. 50 - * 51 - * @typedef {Post | BlockedPost | MissingPost | DetachedQuotePost} AnyPost 52 - */ 53 - 54 - class Post extends ATProtoRecord { 55 - /** 56 - * Post object which is the direct parent of this post. 57 - * @type {ATProtoRecord | undefined} 58 - */ 59 - parent; 60 - 61 - /** 62 - * Post object which is the root of the whole thread (as specified in the post record). 63 - * @type {ATProtoRecord | undefined} 64 - */ 65 - threadRoot; 66 - 67 - /** 68 - * Post which is at the top of the (sub)thread currently loaded on the page (might not be the same as threadRoot). 69 - * @type {Post | undefined} 70 - */ 71 - pageRoot; 72 - 73 - /** 74 - * Info about the author of the "grandparent" post. Included only in feedPost views, for the purposes 75 - * of feed filtering algorithm. 76 - * @type {json | undefined} 77 - */ 78 - grandparentAuthor; 79 - 80 - /** 81 - * Depth of the post in the getPostThread response it was loaded from, starting from 0. May be negative. 82 - * @type {number | undefined} 83 - */ 84 - level; 85 - 86 - /** 87 - * Depth of the post in the whole tree visible on the page (pageRoot's absoluteLevel is 0). May be negative. 88 - * @type {number | undefined} 89 - */ 90 - absoluteLevel; 91 - 92 - /** 93 - * For posts in feeds and timelines - specifies e.g. that a post was reposted by someone. 94 - * @type {object | undefined} 95 - */ 96 - reason; 97 - 98 - /** 99 - * True if the post was extracted from inner embed of a quote, not from a #postView. 100 - * @type {boolean | undefined} 101 - */ 102 - isEmbed; 103 - 104 - /** 105 - * View of a post as part of a thread, as returned from getPostThread. 106 - * Expected to be #threadViewPost, but may be blocked or missing. 107 - * 108 - * @param {json} json 109 - * @param {Post?} [pageRoot] 110 - * @param {number} [level] 111 - * @param {number} [absoluteLevel] 112 - * @returns {AnyPost} 113 - */ 114 - 115 - static parseThreadPost(json, pageRoot = null, level = 0, absoluteLevel = 0) { 116 - switch (json.$type) { 117 - case 'app.bsky.feed.defs#threadViewPost': 118 - let post = new Post(json.post, { level: level, absoluteLevel: absoluteLevel }); 119 - 120 - post.pageRoot = pageRoot ?? post; 121 - 122 - if (json.replies) { 123 - let replies = json.replies.map(x => Post.parseThreadPost(x, post.pageRoot, level + 1, absoluteLevel + 1)); 124 - post.setReplies(replies); 125 - } 126 - 127 - if (absoluteLevel <= 0 && json.parent) { 128 - post.parent = Post.parseThreadPost(json.parent, post.pageRoot, level - 1, absoluteLevel - 1); 129 - } 130 - 131 - return post; 132 - 133 - case 'app.bsky.feed.defs#notFoundPost': 134 - return new MissingPost(json); 135 - 136 - case 'app.bsky.feed.defs#blockedPost': 137 - return new BlockedPost(json); 138 - 139 - default: 140 - throw new PostDataError(`Unexpected record type: ${json.$type}`); 141 - } 142 - } 143 - 144 - /** 145 - * View of a post embedded as a quote. 146 - * Expected to be app.bsky.embed.record#viewRecord, but may be blocked, missing or a different type of record 147 - * (e.g. a list or a feed generator). For unknown record embeds, we fall back to generic ATProtoRecord. 148 - * 149 - * @param {json} json, @returns {ATProtoRecord} 150 - */ 151 - 152 - static parseViewRecord(json) { 153 - switch (json.$type) { 154 - case 'app.bsky.embed.record#viewRecord': 155 - return new Post(json, { isEmbed: true }); 156 - 157 - case 'app.bsky.embed.record#viewNotFound': 158 - return new MissingPost(json); 159 - 160 - case 'app.bsky.embed.record#viewBlocked': 161 - return new BlockedPost(json); 162 - 163 - case 'app.bsky.embed.record#viewDetached': 164 - return new DetachedQuotePost(json); 165 - 166 - case 'app.bsky.feed.defs#generatorView': 167 - return new FeedGeneratorRecord(json); 168 - 169 - case 'app.bsky.graph.defs#listView': 170 - return new UserListRecord(json); 171 - 172 - case 'app.bsky.graph.defs#starterPackViewBasic': 173 - return new StarterPackRecord(json); 174 - 175 - default: 176 - console.warn('Unknown record type:', json.$type); 177 - return new ATProtoRecord(json); 178 - } 179 - } 180 - 181 - /** 182 - * View of a post as part of a feed (e.g. a profile feed, home timeline or a custom feed). It should be an 183 - * app.bsky.feed.defs#feedViewPost - blocked or missing posts don't appear here, they just aren't included. 184 - * 185 - * @param {json} json, @returns {Post} 186 - */ 187 - 188 - static parseFeedPost(json) { 189 - let post = new Post(json.post); 190 - 191 - if (json.reply) { 192 - post.parent = Post.parsePostView(json.reply.parent); 193 - post.threadRoot = Post.parsePostView(json.reply.root); 194 - 195 - if (json.reply.grandparentAuthor) { 196 - post.grandparentAuthor = json.reply.grandparentAuthor; 197 - } 198 - } 199 - 200 - if (json.reason) { 201 - post.reason = json.reason; 202 - } 203 - 204 - return post; 205 - } 206 - 207 - /** 208 - * Parses a #postView - the inner post object that includes the actual post - but still checks if it's not 209 - * a blocked or missing post. The #postView must include a $type. 210 - * (This is used for e.g. parent/root of a #feedViewPost.) 211 - * 212 - * @param {json} json, @returns {AnyPost} 213 - */ 214 - 215 - static parsePostView(json) { 216 - switch (json.$type) { 217 - case 'app.bsky.feed.defs#postView': 218 - return new Post(json); 219 - 220 - case 'app.bsky.feed.defs#notFoundPost': 221 - return new MissingPost(json); 222 - 223 - case 'app.bsky.feed.defs#blockedPost': 224 - return new BlockedPost(json); 225 - 226 - default: 227 - throw new PostDataError(`Unexpected record type: ${json.$type}`); 228 - } 229 - } 230 - 231 - /** @param {json} data, @param {json} [extra] */ 232 - 233 - constructor(data, extra) { 234 - super(data); 235 - Object.assign(this, extra ?? {}); 236 - 237 - if (this.absoluteLevel === 0) { 238 - this.pageRoot = this; 239 - } 240 - 241 - this.record = this.isPostView ? data.record : data.value; 242 - 243 - if (this.isPostView && data.embed) { 244 - this.embed = Embed.parseInlineEmbed(data.embed); 245 - } else if (this.isEmbed && data.embeds && data.embeds[0]) { 246 - this.embed = Embed.parseInlineEmbed(data.embeds[0]); 247 - } else if (this.record.embed) { 248 - this.embed = Embed.parseRawEmbed(this.record.embed); 249 - } 250 - 251 - this.author = this.author ?? data.author; 252 - this.replies = []; 253 - 254 - this.viewerData = data.viewer; 255 - this.viewerLike = data.viewer?.like; 256 - 257 - if (this.author) { 258 - api.cacheProfile(this.author); 259 - } 260 - } 261 - 262 - /** @param {Post} post */ 263 - 264 - updateDataFromPost(post) { 265 - this.record = post.record; 266 - this.embed = post.embed; 267 - this.author = post.author; 268 - this.replies = post.replies; 269 - this.viewerData = post.viewerData; 270 - this.viewerLike = post.viewerLike; 271 - this.level = post.level; 272 - this.absoluteLevel = post.absoluteLevel; 273 - } 274 - 275 - /** @param {AnyPost[]} replies */ 276 - 277 - setReplies(replies) { 278 - this.replies = replies; 279 - this.replies.sort(this.sortReplies.bind(this)); 280 - } 281 - 282 - /** @param {AnyPost} a, @param {AnyPost} b, @returns {-1 | 0 | 1} */ 283 - 284 - sortReplies(a, b) { 285 - if (a instanceof Post && b instanceof Post) { 286 - if (a.author.did == this.author.did && b.author.did != this.author.did) { 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()) { 297 - return 1; 298 - } else { 299 - return 0; 300 - } 301 - } else if (a instanceof Post) { 302 - return -1; 303 - } else if (b instanceof Post) { 304 - return 1; 305 - } else { 306 - return 0; 307 - } 308 - } 309 - 310 - /** @returns {boolean} */ 311 - get isPostView() { 312 - return !this.isEmbed; 313 - } 314 - 315 - /** @returns {boolean} */ 316 - get isFediPost() { 317 - return this.author?.handle.endsWith('.ap.brid.gy'); 318 - } 319 - 320 - /** @returns {string | undefined} */ 321 - get originalFediContent() { 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 333 - return (this.pageRoot === this); 334 - } 335 - 336 - /** @returns {string} */ 337 - get authorFediHandle() { 338 - if (this.isFediPost) { 339 - return this.author.handle.replace(/\.ap\.brid\.gy$/, '').replace('.', '@'); 340 - } else { 341 - throw "Not a Fedi post"; 342 - } 343 - } 344 - 345 - /** @returns {string} */ 346 - get text() { 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; 362 - } 363 - 364 - /** @returns {string[] | undefined} */ 365 - get tags() { 366 - return this.record.tags; 367 - } 368 - 369 - /** @returns {Date} */ 370 - get createdAt() { 371 - return new Date(this.record.createdAt); 372 - } 373 - 374 - /** @returns {number} */ 375 - get likeCount() { 376 - return castToInt(this.data.likeCount); 377 - } 378 - 379 - /** @returns {number} */ 380 - get replyCount() { 381 - return castToInt(this.data.replyCount); 382 - } 383 - 384 - /** @returns {number} */ 385 - get quoteCount() { 386 - return castToInt(this.data.quoteCount); 387 - } 388 - 389 - /** @returns {boolean} */ 390 - get hasMoreReplies() { 391 - let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length); 392 - 393 - return shouldHaveMoreReplies && (this.replies.length === 0) && (this.level !== undefined && this.level > 4); 394 - } 395 - 396 - /** @returns {boolean} */ 397 - get hasHiddenReplies() { 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} */ 409 - get repostCount() { 410 - return castToInt(this.data.repostCount); 411 - } 412 - 413 - /** @returns {boolean} */ 414 - get liked() { 415 - return (this.viewerLike !== undefined); 416 - } 417 - 418 - /** @returns {boolean | undefined} */ 419 - get muted() { 420 - return this.author.viewer?.muted; 421 - } 422 - 423 - /** @returns {string | undefined} */ 424 - get muteList() { 425 - return this.author.viewer?.mutedByList?.name; 426 - } 427 - 428 - /** @returns {boolean} */ 429 - get hasViewerInfo() { 430 - return (this.viewerData !== undefined); 431 - } 432 - 433 - /** @returns {ATProtoRecord | undefined} */ 434 - get parentReference() { 435 - return this.record.reply?.parent && new ATProtoRecord(this.record.reply?.parent); 436 - } 437 - 438 - /** @returns {ATProtoRecord | undefined} */ 439 - get rootReference() { 440 - return this.record.reply?.root && new ATProtoRecord(this.record.reply?.root); 441 - } 442 - } 443 - 444 - 445 - /** 446 - * Post which is blocked for some reason (the author is blocked, the author has blocked you, or there is a block 447 - * between the post author and the parent author). It only includes a reference, but no post content. 448 - */ 449 - 450 - class BlockedPost extends ATProtoRecord { 451 - 452 - /** @param {json} data */ 453 - constructor(data) { 454 - super(data); 455 - this.author = data.author; 456 - } 457 - 458 - /** @returns {boolean} */ 459 - get blocksUser() { 460 - return !!this.author.viewer?.blocking; 461 - } 462 - 463 - /** @returns {boolean} */ 464 - get blockedByUser() { 465 - return this.author.viewer?.blockedBy; 466 - } 467 - } 468 - 469 - 470 - /** 471 - * Stub of a post which was deleted or hidden. 472 - */ 473 - 474 - class MissingPost extends ATProtoRecord {} 475 - 476 - 477 - /** 478 - * Stub of a quoted post which was un-quoted by the original author. 479 - */ 480 - 481 - class DetachedQuotePost extends ATProtoRecord {} 482 - 483 - 484 - /** 485 - * Record representing a feed generator. 486 - */ 487 - 488 - class FeedGeneratorRecord extends ATProtoRecord { 489 - 490 - /** @param {json} data */ 491 - constructor(data) { 492 - super(data); 493 - this.author = data.creator; 494 - } 495 - 496 - /** @returns {string | undefined} */ 497 - get title() { 498 - return this.data.displayName; 499 - } 500 - 501 - /** @returns {string | undefined} */ 502 - get description() { 503 - return this.data.description; 504 - } 505 - 506 - /** @returns {number} */ 507 - get likeCount() { 508 - return castToInt(this.data.likeCount); 509 - } 510 - 511 - /** @returns {string | undefined} */ 512 - get avatar() { 513 - return this.data.avatar; 514 - } 515 - } 516 - 517 - 518 - /** 519 - * Record representing a user list or moderation list. 520 - */ 521 - 522 - class UserListRecord extends ATProtoRecord { 523 - 524 - /** @param {json} data */ 525 - constructor(data) { 526 - super(data); 527 - this.author = data.creator; 528 - } 529 - 530 - /** @returns {string | undefined} */ 531 - get title() { 532 - return this.data.name; 533 - } 534 - 535 - /** @returns {string | undefined} */ 536 - get purpose() { 537 - return this.data.purpose; 538 - } 539 - 540 - /** @returns {string | undefined} */ 541 - get description() { 542 - return this.data.description; 543 - } 544 - 545 - /** @returns {string | undefined} */ 546 - get avatar() { 547 - return this.data.avatar; 548 - } 549 - } 550 - 551 - 552 - /** 553 - * Record representing a starter pack. 554 - */ 555 - 556 - class StarterPackRecord extends ATProtoRecord { 557 - 558 - /** @param {json} data */ 559 - constructor(data) { 560 - super(data); 561 - this.author = data.creator; 562 - } 563 - 564 - /** @returns {string | undefined} */ 565 - get title() { 566 - return this.data.record.name; 567 - } 568 - 569 - /** @returns {string | undefined} */ 570 - get description() { 571 - return this.data.record.description; 572 - } 573 - } 574 - 575 - 576 - /** 577 - * Base class for embed objects. 578 - */ 579 - 580 - class Embed { 581 - 582 - /** 583 - * More hydrated view of an embed, taken from a full post view (#postView). 584 - * 585 - * @param {json} json, @returns {Embed} 586 - */ 587 - 588 - static parseInlineEmbed(json) { 589 - switch (json.$type) { 590 - case 'app.bsky.embed.record#view': 591 - return new InlineRecordEmbed(json); 592 - 593 - case 'app.bsky.embed.recordWithMedia#view': 594 - return new InlineRecordWithMediaEmbed(json); 595 - 596 - case 'app.bsky.embed.images#view': 597 - return new InlineImageEmbed(json); 598 - 599 - case 'app.bsky.embed.external#view': 600 - return new InlineLinkEmbed(json); 601 - 602 - case 'app.bsky.embed.video#view': 603 - return new InlineVideoEmbed(json); 604 - 605 - default: 606 - if (location.protocol == 'file:') { 607 - throw new PostDataError(`Unexpected embed type: ${json.$type}`); 608 - } else { 609 - console.warn('Unexpected embed type:', json.$type); 610 - return new Embed(json); 611 - } 612 - } 613 - } 614 - 615 - /** 616 - * Raw embed extracted from raw record data of a post. Does not include quoted post contents. 617 - * 618 - * @param {json} json, @returns {Embed} 619 - */ 620 - 621 - static parseRawEmbed(json) { 622 - switch (json.$type) { 623 - case 'app.bsky.embed.record': 624 - return new RawRecordEmbed(json); 625 - 626 - case 'app.bsky.embed.recordWithMedia': 627 - return new RawRecordWithMediaEmbed(json); 628 - 629 - case 'app.bsky.embed.images': 630 - return new RawImageEmbed(json); 631 - 632 - case 'app.bsky.embed.external': 633 - return new RawLinkEmbed(json); 634 - 635 - case 'app.bsky.embed.video': 636 - return new RawVideoEmbed(json); 637 - 638 - default: 639 - if (location.protocol == 'file:') { 640 - throw new PostDataError(`Unexpected embed type: ${json.$type}`); 641 - } else { 642 - console.warn('Unexpected embed type:', json.$type); 643 - return new Embed(json); 644 - } 645 - } 646 - } 647 - 648 - /** @param {json} json */ 649 - constructor(json) { 650 - this.json = json; 651 - } 652 - 653 - /** @returns {string} */ 654 - get type() { 655 - return this.json.$type; 656 - } 657 - } 658 - 659 - class RawImageEmbed extends Embed { 660 - 661 - /** @param {json} json */ 662 - constructor(json) { 663 - super(json); 664 - this.images = json.images; 665 - } 666 - } 667 - 668 - class RawLinkEmbed extends Embed { 669 - 670 - /** @param {json} json */ 671 - constructor(json) { 672 - super(json); 673 - 674 - this.url = json.external.uri; 675 - this.title = json.external.title; 676 - this.thumb = json.external.thumb; 677 - } 678 - } 679 - 680 - class RawVideoEmbed extends Embed { 681 - 682 - /** @param {json} json */ 683 - constructor(json) { 684 - super(json); 685 - this.video = json.video; 686 - } 687 - } 688 - 689 - class RawRecordEmbed extends Embed { 690 - 691 - /** @param {json} json */ 692 - constructor(json) { 693 - super(json); 694 - this.record = new ATProtoRecord(json.record); 695 - } 696 - } 697 - 698 - class RawRecordWithMediaEmbed extends Embed { 699 - 700 - /** @param {json} json */ 701 - constructor(json) { 702 - super(json); 703 - this.record = new ATProtoRecord(json.record.record); 704 - this.media = Embed.parseRawEmbed(json.media); 705 - } 706 - } 707 - 708 - class InlineRecordEmbed extends Embed { 709 - 710 - /** 711 - * app.bsky.embed.record#view 712 - * @param {json} json 713 - */ 714 - constructor(json) { 715 - super(json); 716 - this.post = Post.parseViewRecord(json.record); 717 - } 718 - } 719 - 720 - class InlineRecordWithMediaEmbed extends Embed { 721 - 722 - /** 723 - * app.bsky.embed.recordWithMedia#view 724 - * @param {json} json 725 - */ 726 - constructor(json) { 727 - super(json); 728 - this.post = Post.parseViewRecord(json.record.record); 729 - this.media = Embed.parseInlineEmbed(json.media); 730 - } 731 - } 732 - 733 - class InlineLinkEmbed extends Embed { 734 - 735 - /** 736 - * app.bsky.embed.external#view 737 - * @param {json} json 738 - */ 739 - constructor(json) { 740 - super(json); 741 - 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 - 749 - class InlineImageEmbed extends Embed { 750 - 751 - /** 752 - * app.bsky.embed.images#view 753 - * @param {json} json 754 - */ 755 - constructor(json) { 756 - super(json); 757 - this.images = json.images; 758 - } 759 - } 760 - 761 - class InlineVideoEmbed extends Embed { 762 - 763 - /** 764 - * app.bsky.embed.video#view 765 - * @param {json} json 766 - */ 767 - constructor(json) { 768 - super(json); 769 - this.playlistURL = json.playlist; 770 - this.alt = json.alt; 771 - } 772 - }
-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 - }
+13
package.json
··· 1 + { 2 + "dependencies": { 3 + "dompurify": "^3.3.0", 4 + "svelte": "^5.45.2" 5 + }, 6 + "devDependencies": { 7 + "bun-plugin-svelte": "^0.0.6", 8 + "esbuild": "^0.27.0", 9 + "esbuild-svelte": "^0.9.3", 10 + "svelte-check": "^4.3.4", 11 + "typescript": "^5.9.3" 12 + } 13 + }
-832
post_component.js
··· 1 - /** 2 - * Renders a post/thread view and its subviews. 3 - */ 4 - 5 - class PostComponent { 6 - /** 7 - * Post component's root HTML element, if built. 8 - * @type {HTMLElement | undefined} 9 - */ 10 - _rootElement; 11 - 12 - /** 13 - Contexts: 14 - - thread - a post in the thread tree 15 - - parent - parent reference above the thread root 16 - - quote - a quote embed 17 - - quotes - a post on the quotes page 18 - - feed - a post on the hashtag feed page 19 - 20 - @typedef {'thread' | 'parent' | 'quote' | 'quotes' | 'feed'} PostContext 21 - @param {AnyPost} post, @param {PostContext} context 22 - */ 23 - constructor(post, context) { 24 - this.post = /** @type {Post}, TODO */ (post); 25 - this.context = context; 26 - } 27 - 28 - /** 29 - * @returns {HTMLElement} 30 - */ 31 - get rootElement() { 32 - if (!this._rootElement) { 33 - throw new Error("rootElement not initialized"); 34 - } 35 - 36 - return this._rootElement; 37 - } 38 - 39 - /** @returns {boolean} */ 40 - get isRoot() { 41 - return this.post.isRoot; 42 - } 43 - 44 - /** @returns {string} */ 45 - get linkToAuthor() { 46 - if (this.post.author.handle != 'handle.invalid') { 47 - return 'https://bsky.app/profile/' + this.post.author.handle; 48 - } else { 49 - return 'https://bsky.app/profile/' + this.post.author.did; 50 - } 51 - } 52 - 53 - /** @returns {string} */ 54 - get linkToPost() { 55 - return this.linkToAuthor + '/post/' + this.post.rkey; 56 - } 57 - 58 - /** @returns {string} */ 59 - get didLinkToAuthor() { 60 - let { repo } = atURI(this.post.uri); 61 - return `https://bsky.app/profile/${repo}`; 62 - } 63 - 64 - /** @returns {string} */ 65 - get didLinkToPost() { 66 - let { repo, rkey } = atURI(this.post.uri); 67 - return `https://bsky.app/profile/${repo}/post/${rkey}`; 68 - } 69 - 70 - /** @returns {string} */ 71 - get authorName() { 72 - if (this.post.author.displayName) { 73 - return this.post.author.displayName; 74 - } else if (this.post.author.handle.endsWith('.bsky.social')) { 75 - return this.post.author.handle.replace(/\.bsky\.social$/, ''); 76 - } else { 77 - return this.post.author.handle; 78 - } 79 - } 80 - 81 - /** @returns {json} */ 82 - get timeFormatForTimestamp() { 83 - if (this.context == 'quotes' || this.context == 'feed') { 84 - return { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' }; 85 - } else if (this.isRoot || this.context != 'thread') { 86 - return { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' }; 87 - } else if (this.post.pageRoot && !sameDay(this.post.createdAt, this.post.pageRoot.createdAt)) { 88 - return { day: 'numeric', month: 'short', hour: 'numeric', minute: 'numeric' }; 89 - } else { 90 - return { hour: 'numeric', minute: 'numeric' }; 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) { 115 - div.classList.add('muted'); 116 - } 117 - 118 - if (this.post instanceof BlockedPost) { 119 - this.buildBlockedPostElement(div); 120 - return div; 121 - } else if (this.post instanceof DetachedQuotePost) { 122 - this.buildDetachedQuoteElement(div); 123 - return div; 124 - } else if (this.post instanceof MissingPost) { 125 - this.buildMissingPostElement(div); 126 - return div; 127 - } 128 - 129 - let header = this.buildPostHeader(); 130 - div.appendChild(header); 131 - 132 - let content = $tag('div.content'); 133 - 134 - if (this.context == 'thread' && !this.isRoot) { 135 - let edgeMargin = this.buildEdgeMargin(); 136 - div.appendChild(edgeMargin); 137 - } 138 - 139 - let wrapper; 140 - 141 - if (this.post.muted) { 142 - let details = $tag('details'); 143 - 144 - let summary = $tag('summary'); 145 - summary.innerText = this.post.muteList ? `Muted (${this.post.muteList})` : 'Muted - click to show'; 146 - details.appendChild(summary); 147 - 148 - content.appendChild(details); 149 - wrapper = details; 150 - } else { 151 - wrapper = content; 152 - } 153 - 154 - let p = this.buildPostBody(); 155 - wrapper.appendChild(p); 156 - 157 - if (this.post.tags) { 158 - let tagsRow = this.buildTagsRow(this.post.tags); 159 - wrapper.appendChild(tagsRow); 160 - } 161 - 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) { 181 - let stats = this.buildStatsFooter(); 182 - wrapper.appendChild(stats); 183 - } 184 - 185 - if (this.post.replyCount == 1 && this.post.replies[0]?.author?.did == this.post.author.did) { 186 - let component = new PostComponent(this.post.replies[0], 'thread'); 187 - let element = component.buildElement(); 188 - element.classList.add('flat'); 189 - content.appendChild(element); 190 - } else { 191 - for (let reply of this.post.replies) { 192 - if (reply instanceof MissingPost) { continue } 193 - if (reply instanceof BlockedPost && window.biohazardEnabled === false) { continue } 194 - 195 - let component = new PostComponent(reply, 'thread'); 196 - content.appendChild(component.buildElement()); 197 - } 198 - } 199 - 200 - if (this.context == 'thread') { 201 - if (this.post.hasMoreReplies) { 202 - let loadMore = this.buildLoadMoreLink(); 203 - content.appendChild(loadMore); 204 - } else if (this.post.hasHiddenReplies && window.biohazardEnabled !== false) { 205 - let loadMore = this.buildHiddenRepliesLink(); 206 - content.appendChild(loadMore); 207 - } 208 - } 209 - 210 - div.appendChild(content); 211 - 212 - return div; 213 - } 214 - 215 - /** @returns {HTMLElement} */ 216 - 217 - buildPostHeader() { 218 - let timeFormat = this.timeFormatForTimestamp; 219 - let formattedTime = this.post.createdAt.toLocaleString(window.dateLocale, timeFormat); 220 - let isoTime = this.post.createdAt.toISOString(); 221 - 222 - let h = $tag('h2'); 223 - 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> ` + 236 - `<a class="time" href="${this.linkToPost}" target="_blank" title="${isoTime}">${formattedTime}</a> `; 237 - 238 - if (this.post.replyCount > 0 && !this.isRoot || ['quote', 'quotes', 'feed'].includes(this.context)) { 239 - h.innerHTML += 240 - `<span class="separator">&bull;</span> ` + 241 - `<a href="${linkToPostThread(this.post)}" class="action" title="Load this subtree">` + 242 - `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i></a> `; 243 - } 244 - 245 - if (this.post.muted) { 246 - h.prepend($tag('i', 'missing fa-regular fa-circle-user fa-2x')); 247 - } else if (this.post.author.avatar) { 248 - h.prepend(this.buildUserAvatar(this.post.author.avatar)); 249 - } else { 250 - h.prepend($tag('i', 'missing fa-regular fa-face-smile fa-2x')); 251 - } 252 - 253 - return h; 254 - } 255 - 256 - buildEdgeMargin() { 257 - let div = $tag('div.margin'); 258 - 259 - let edge = $tag('div.edge'); 260 - let line = $tag('div.line'); 261 - edge.appendChild(line); 262 - div.appendChild(edge); 263 - 264 - let plus = $tag('img.plus', { src: 'icons/subtract-square.png' }); 265 - div.appendChild(plus); 266 - 267 - [edge, plus].forEach(x => { 268 - x.addEventListener('click', (e) => { 269 - e.preventDefault(); 270 - this.toggleSectionFold(); 271 - }); 272 - }); 273 - 274 - return div; 275 - } 276 - 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) { 290 - return $tag('div.body', { html: sanitizeHTML(this.post.originalFediContent) }); 291 - } 292 - 293 - let p = $tag('p.body'); 294 - let richText = new RichText({ text: this.post.text, facets: this.post.facets }); 295 - 296 - for (let seg of richText.segments()) { 297 - if (seg.mention) { 298 - p.append($tag('a', { href: `https://bsky.app/profile/${seg.mention.did}`, text: seg.text })); 299 - } else if (seg.link) { 300 - p.append($tag('a', { href: seg.link.uri, text: seg.text })); 301 - } else if (seg.tag) { 302 - let url = new URL(getLocation()); 303 - url.searchParams.set('hash', seg.tag.tag); 304 - p.append($tag('a', { href: url.toString(), text: seg.text })); 305 - } else if (seg.text.includes("\n")) { 306 - let span = $tag('span', { text: seg.text }); 307 - span.innerHTML = span.innerHTML.replaceAll("\n", "<br>"); 308 - p.append(span); 309 - } else { 310 - p.append(seg.text); 311 - } 312 - } 313 - 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'); 365 - 366 - for (let tag of tags) { 367 - let url = new URL(getLocation()); 368 - url.searchParams.set('hash', tag); 369 - 370 - let tagLink = $tag('a', { href: url.toString(), text: '# ' + tag }); 371 - p.append(tagLink); 372 - } 373 - 374 - return p; 375 - } 376 - 377 - /** @returns {HTMLElement} */ 378 - 379 - buildStatsFooter() { 380 - let stats = $tag('p.stats'); 381 - 382 - let span = $tag('span'); 383 - let heart = $tag('i', 'fa-solid fa-heart ' + (this.post.liked ? 'liked' : '')); 384 - heart.addEventListener('click', (e) => this.onHeartClick(heart)); 385 - 386 - span.append(heart, ' ', $tag('output', { text: this.post.likeCount })); 387 - stats.append(span); 388 - 389 - if (this.post.repostCount > 0) { 390 - let span = $tag('span', { html: `<i class="fa-solid fa-retweet"></i> ${this.post.repostCount}` }); 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} ` }); 427 - let link = $tag('a', { text: (count > 1) ? `${count} quotes` : '1 quote', href: url }); 428 - span.append(link); 429 - return span; 430 - } else { 431 - return $tag('a', { html: `${icon} ${count}`, href: url }); 432 - } 433 - } 434 - 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'); 447 - 448 - let link = $tag('a', { 449 - href: linkToPostThread(this.post), 450 - text: "Load more repliesโ€ฆ" 451 - }); 452 - 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'); 467 - 468 - let link = $tag('a', { 469 - href: linkToPostThread(this.post), 470 - text: "Load hidden repliesโ€ฆ" 471 - }); 472 - 473 - link.addEventListener('click', (e) => { 474 - e.preventDefault(); 475 - 476 - if (window.biohazardEnabled === true) { 477 - this.loadHiddenReplies(loadMore); 478 - } else { 479 - window.loadInfohazard = () => this.loadHiddenReplies(loadMore); 480 - showDialog($id('biohazard_dialog')); 481 - } 482 - }); 483 - 484 - loadMore.append("โ˜ฃ๏ธ ", link); 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 */ 512 - 513 - loadReferencedPostAuthor(authorLink) { 514 - let did = atURI(this.post.uri).repo; 515 - 516 - api.fetchHandleForDid(did).then(handle => { 517 - if (this.post.author) { 518 - this.post.author.handle = handle; 519 - } else { 520 - this.post.author = { did, handle }; 521 - } 522 - 523 - authorLink.href = this.linkToAuthor; 524 - authorLink.innerText = `@${handle}`; 525 - }); 526 - } 527 - 528 - /** @param {HTMLElement} div, @returns {HTMLElement} */ 529 - 530 - buildBlockedPostElement(div) { 531 - let p = $tag('p.blocked-header'); 532 - p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Blocked post</span>`; 533 - 534 - if (window.biohazardEnabled === false) { 535 - div.appendChild(p); 536 - div.classList.add('blocked'); 537 - return p; 538 - } 539 - 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 - 547 - this.loadReferencedPostAuthor(authorLink); 548 - 549 - let loadPost = $tag('p.load-post'); 550 - let a = $tag('a', { href: '#', text: "Load postโ€ฆ" }); 551 - 552 - a.addEventListener('click', (e) => { 553 - e.preventDefault(); 554 - loadPost.innerHTML = '&nbsp;'; 555 - this.loadBlockedPost(this.post.uri, div); 556 - }); 557 - 558 - loadPost.appendChild(a); 559 - div.appendChild(loadPost); 560 - div.classList.add('blocked'); 561 - return div; 562 - } 563 - 564 - /** @param {HTMLElement} div, @returns {HTMLElement} */ 565 - 566 - buildDetachedQuoteElement(div) { 567 - let p = $tag('p.blocked-header'); 568 - p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Hidden quote</span>`; 569 - 570 - if (window.biohazardEnabled === false) { 571 - div.appendChild(p); 572 - div.classList.add('blocked'); 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 - 580 - this.loadReferencedPostAuthor(authorLink); 581 - 582 - let loadPost = $tag('p.load-post'); 583 - let a = $tag('a', { href: '#', text: "Load postโ€ฆ" }); 584 - 585 - a.addEventListener('click', (e) => { 586 - e.preventDefault(); 587 - loadPost.innerHTML = '&nbsp;'; 588 - this.loadBlockedPost(this.post.uri, div); 589 - }); 590 - 591 - loadPost.appendChild(a); 592 - div.appendChild(loadPost); 593 - div.classList.add('blocked'); 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); 607 - 608 - div.appendChild(p); 609 - div.classList.add('blocked'); 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); 617 - 618 - if (!record) { 619 - let post = new MissingPost({ uri: this.post.uri }); 620 - let postView = new PostComponent(post, 'quote').buildElement(); 621 - div.replaceWith(postView); 622 - return; 623 - } 624 - 625 - this.post = new Post(record); 626 - 627 - let userView = await api.getRequest('app.bsky.actor.getProfile', { actor: this.post.author.did }); 628 - 629 - if (!userView.viewer || !(userView.viewer.blockedBy || userView.viewer.blocking)) { 630 - let { repo, rkey } = atURI(this.post.uri); 631 - 632 - let a = $tag('a', { 633 - href: linkToPostById(repo, rkey), 634 - className: 'action', 635 - title: "Load thread", 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); 649 - let url = linkToPostById(repo, rkey); 650 - 651 - let handle = api.findHandleByDid(repo); 652 - let link = handle ? `See parent post (@${handle})` : "See parent post"; 653 - 654 - let p = $tag('p.back', { html: `<i class="fa-solid fa-reply"></i><a href="${url}">${link}</a>` }); 655 - div.appendChild(p); 656 - } 657 - 658 - let p = this.buildPostBody(); 659 - div.appendChild(p); 660 - 661 - if (this.post.embed) { 662 - let embed = new EmbedComponent(this.post, this.post.embed).buildElement(); 663 - div.appendChild(embed); 664 - 665 - // TODO 666 - Array.from(div.querySelectorAll('a.link-card')).forEach(x => x.remove()); 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'); 758 - plus.src = 'icons/subtract-square.png' 759 - } else { 760 - this.rootElement.classList.add('collapsed'); 761 - plus.src = 'icons/add-square.png' 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 - }
-161
rich_text_lite.js
··· 1 - /* 2 - Extracted from https://github.com/bluesky-social/atproto/ 3 - 4 - Copyright (c) 2022-2024 Bluesky PBC, and Contributors 5 - MIT License 6 - 7 - Permission is hereby granted, free of charge, to any person obtaining a copy 8 - of this software and associated documentation files (the "Software"), to deal 9 - in the Software without restriction, including without limitation the rights 10 - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 - copies of the Software, and to permit persons to whom the Software is 12 - furnished to do so, subject to the following conditions: 13 - 14 - The above copyright notice and this permission notice shall be included in all 15 - copies or substantial portions of the Software. 16 - 17 - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 - SOFTWARE. 24 - */ 25 - 26 - // packages/api/src/rich-text/rich-text.ts 27 - 28 - class RichTextSegment { 29 - /** @param {string} text, @param {json} [facet] */ 30 - constructor(text, facet) { 31 - this.text = text; 32 - this.facet = facet; 33 - } 34 - 35 - /** @returns {object | undefined} */ 36 - get link() { 37 - return this.facet?.features?.find(v => v.$type === 'app.bsky.richtext.facet#link'); 38 - } 39 - 40 - /** @returns {object | undefined} */ 41 - get mention() { 42 - return this.facet?.features?.find(v => v.$type === 'app.bsky.richtext.facet#mention'); 43 - } 44 - 45 - /** @returns {object | undefined} */ 46 - get tag() { 47 - return this.facet?.features?.find(v => v.$type === 'app.bsky.richtext.facet#tag'); 48 - } 49 - } 50 - 51 - class RichText { 52 - /** @params {json} props */ 53 - constructor(props) { 54 - this.unicodeText = new UnicodeString(props.text); 55 - this.facets = props.facets; 56 - 57 - if (this.facets) { 58 - this.facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 59 - } 60 - } 61 - 62 - /** @returns {string} */ 63 - get text() { 64 - return this.unicodeText.toString(); 65 - } 66 - 67 - /** @returns {number} */ 68 - get length() { 69 - return this.unicodeText.length; 70 - } 71 - 72 - /** @returns {number} */ 73 - get graphemeLength() { 74 - return this.unicodeText.graphemeLength; 75 - } 76 - 77 - *segments() { 78 - const facets = this.facets || []; 79 - 80 - if (facets.length == 0) { 81 - yield new RichTextSegment(this.unicodeText.utf16); 82 - return; 83 - } 84 - 85 - let textCursor = 0; 86 - let facetCursor = 0; 87 - 88 - do { 89 - const currFacet = facets[facetCursor]; 90 - 91 - if (textCursor < currFacet.index.byteStart) { 92 - yield new RichTextSegment(this.unicodeText.slice(textCursor, currFacet.index.byteStart)); 93 - } else if (textCursor > currFacet.index.byteStart) { 94 - facetCursor++; 95 - continue; 96 - } 97 - 98 - if (currFacet.index.byteStart < currFacet.index.byteEnd) { 99 - const subtext = this.unicodeText.slice(currFacet.index.byteStart, currFacet.index.byteEnd); 100 - 101 - if (subtext.trim().length == 0) { 102 - yield new RichTextSegment(subtext); 103 - } else { 104 - yield new RichTextSegment(subtext, currFacet); 105 - } 106 - } 107 - 108 - textCursor = currFacet.index.byteEnd; 109 - facetCursor++; 110 - 111 - } while (facetCursor < facets.length); 112 - 113 - if (textCursor < this.unicodeText.length) { 114 - yield new RichTextSegment(this.unicodeText.slice(textCursor, this.unicodeText.length)); 115 - } 116 - } 117 - } 118 - 119 - 120 - // packages/api/src/rich-text/unicode.ts 121 - 122 - /** 123 - * Javascript uses utf16-encoded strings while most environments and specs 124 - * have standardized around utf8 (including JSON). 125 - * 126 - * After some lengthy debated we decided that richtext facets need to use 127 - * utf8 indices. This means we need tools to convert indices between utf8 128 - * and utf16, and that's precisely what this library handles. 129 - */ 130 - 131 - class UnicodeString { 132 - static encoder = new TextEncoder(); 133 - static decoder = new TextDecoder(); 134 - static segmenter = window.Intl && Intl.Segmenter && new Intl.Segmenter(); 135 - 136 - /** @param {string} utf16 */ 137 - constructor(utf16) { 138 - this.utf16 = utf16; 139 - this.utf8 = UnicodeString.encoder.encode(utf16); 140 - } 141 - 142 - /** @returns number */ 143 - get length() { 144 - return this.utf8.byteLength; 145 - } 146 - 147 - /** @returns number */ 148 - get graphemeLength() { 149 - return Array.from(UnicodeString.segmenter.segment(this.utf16)).length; 150 - } 151 - 152 - /** @param {number} start, @param {number} end, @returns string */ 153 - slice(start, end) { 154 - return UnicodeString.decoder.decode(this.utf8.slice(start, end)); 155 - } 156 - 157 - /** @returns string */ 158 - toString() { 159 - return this.utf16; 160 - } 161 - }
+57
serve.js
··· 1 + import { parseArgs } from 'util'; 2 + import { runBuild } from './build.js'; 3 + import { runTypecheck } from './typecheck.js'; 4 + 5 + let { values: options } = parseArgs({ 6 + args: Bun.argv, 7 + options: { 8 + ts: { 9 + type: 'boolean' 10 + } 11 + }, 12 + strict: true, 13 + allowPositionals: true 14 + }); 15 + 16 + async function rebuild() { 17 + let start = Bun.nanoseconds(); 18 + let result = await runBuild(true); 19 + let timeSpent = Math.floor((Bun.nanoseconds() - start) / 100_000) / 10; 20 + 21 + let bundlePath = result.outputs.find(x => x.kind == 'entry-point').path; 22 + console.log(`[${new Date().toUTCString()}] Built ${bundlePath} in ${timeSpent} ms`); 23 + 24 + if (options.ts) { 25 + setTimeout(() => { 26 + let start2 = Bun.nanoseconds(); 27 + let result = runTypecheck(); 28 + let timeSpent = Math.floor((Bun.nanoseconds() - start) / 100_000) / 10; 29 + 30 + console.log(`[${new Date().toUTCString()}] Type-checked TypeScript in ${timeSpent} ms`); 31 + 32 + if (!result.ok) { 33 + console.error("\n" + result.text); 34 + } 35 + }, 75); 36 + } 37 + } 38 + 39 + rebuild().catch(err => { 40 + console.log(err); 41 + }); 42 + 43 + Bun.serve({ 44 + routes: { 45 + '/': { 46 + async GET(req) { 47 + await rebuild(); 48 + return new Response(Bun.file("./index.html")); 49 + } 50 + } 51 + }, 52 + async fetch(req) { 53 + let url = new URL(req.url); 54 + let file = Bun.file(`.${url.pathname}`); 55 + return new Response(file); 56 + } 57 + });
-442
skythread.js
··· 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; 52 - localStorage.setItem('biohazard', 'true'); 53 - 54 - if (window.loadInfohazard) { 55 - window.loadInfohazard(); 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(); 113 - loadQuotesPage(decodeURIComponent(quotes)); 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 { 126 - showSearch(); 127 - } 128 - } 129 - 130 - /** @returns {IntersectionObserver} */ 131 - 132 - function buildAvatarPreloader() { 133 - return new IntersectionObserver((entries, observer) => { 134 - for (const entry of entries) { 135 - if (entry.isIntersecting) { 136 - const img = entry.target; 137 - img.removeAttribute('lazy'); 138 - observer.unobserve(img); 139 - } 140 - } 141 - }, { 142 - rootMargin: '1000px 0px' 143 - }); 144 - } 145 - 146 - function showLoader() { 147 - $id('loader').style.display = 'block'; 148 - } 149 - 150 - function hideLoader() { 151 - $id('loader').style.display = 'none'; 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() { 163 - $id('search').style.visibility = 'hidden'; 164 - } 165 - 166 - function showDialog(dialog) { 167 - dialog.style.visibility = 'visible'; 168 - $id('thread').classList.add('overlay'); 169 - 170 - dialog.querySelector('input[type=text]')?.focus(); 171 - } 172 - 173 - function hideDialog(dialog) { 174 - dialog.style.visibility = 'hidden'; 175 - dialog.classList.remove('expanded'); 176 - $id('thread').classList.remove('overlay'); 177 - 178 - for (let field of dialog.querySelectorAll('input[type=text]')) { 179 - field.value = ''; 180 - } 181 - } 182 - 183 - function toggleDialog(dialog) { 184 - if (dialog.style.visibility == 'visible') { 185 - hideDialog(dialog); 186 - } else { 187 - showDialog(dialog); 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'); 230 - if (page) { 231 - openPage(page); 232 - } 233 - }) 234 - .catch((error) => { 235 - submit.style.display = 'inline'; 236 - cloudy.style.display = 'none'; 237 - console.log(error); 238 - 239 - if (error.code == 401 && error.json.error == 'AuthFactorTokenRequired') { 240 - alert("Please log in using an \"app password\" if you have 2FA enabled."); 241 - } else { 242 - window.setTimeout(() => alert(error), 10); 243 - } 244 - }); 245 - } 246 - 247 - /** @param {string} identifier, @param {string} password, @returns {Promise<BlueskyAPI>} */ 248 - 249 - async function logIn(identifier, password) { 250 - let pdsEndpoint; 251 - 252 - if (identifier.match(/^did:/)) { 253 - pdsEndpoint = await Minisky.pdsEndpointForDid(identifier); 254 - } else if (identifier.match(/^[^@]+@[^@]+$/)) { 255 - pdsEndpoint = 'bsky.social'; 256 - } else if (identifier.match(/^@?[\w\-]+(\.[\w\-]+)+$/)) { 257 - identifier = identifier.replace(/^@/, ''); 258 - let did = await appView.resolveHandle(identifier); 259 - pdsEndpoint = await Minisky.pdsEndpointForDid(did); 260 - } else { 261 - throw 'Please enter your handle or DID.'; 262 - } 263 - 264 - let pds = new BlueskyAPI(pdsEndpoint, true); 265 - await pds.logIn(identifier, password); 266 - return pds; 267 - } 268 - 269 - function logOut() { 270 - accountAPI.resetTokens(); 271 - localStorage.removeItem('incognito'); 272 - location.reload(); 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 - 282 - if (url.startsWith('at://')) { 283 - let target = new URL(getLocation()); 284 - target.searchParams.set('q', url); 285 - location.assign(target.toString()); 286 - return; 287 - } 288 - 289 - if (url.match(/^#?((\p{Letter}|\p{Number})+)$/u)) { 290 - let target = new URL(getLocation()); 291 - target.searchParams.set('hash', encodeURIComponent(url.replace(/^#/, ''))); 292 - location.assign(target.toString()); 293 - return; 294 - } 295 - 296 - try { 297 - let [handle, postId] = BlueskyAPI.parsePostURL(url); 298 - 299 - let newURL = linkToPostById(handle, postId); 300 - location.assign(newURL); 301 - } catch (error) { 302 - console.log(error); 303 - alert(error.message || "This is not a valid URL or hashtag"); 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) { 330 - document.title = `${post.author.displayName}: "${post.text}" - Skythread`; 331 - } 332 - 333 - /** @param {string} hashtag */ 334 - 335 - function loadHashtagPage(hashtag) { 336 - hashtag = hashtag.replace(/^\#/, ''); 337 - document.title = `#${hashtag} - Skythread`; 338 - 339 - let isLoading = false; 340 - let firstPageLoaded = false; 341 - let finished = false; 342 - let cursor; 343 - 344 - Paginator.loadInPages(() => { 345 - if (isLoading || finished) { return; } 346 - isLoading = true; 347 - 348 - api.getHashtagFeed(hashtag, cursor).then(data => { 349 - let posts = data.posts.map(j => new Post(j)); 350 - 351 - if (!firstPageLoaded) { 352 - hideLoader(); 353 - 354 - let header = $tag('header'); 355 - let h2 = $tag('h2', { 356 - text: (posts.length > 0) ? `Posts tagged: #${hashtag}` : `No posts tagged #${hashtag}.` 357 - }); 358 - header.append(h2); 359 - 360 - $id('thread').appendChild(header); 361 - $id('thread').classList.add('hashtag'); 362 - } 363 - 364 - for (let post of posts) { 365 - let postView = new PostComponent(post, 'feed').buildElement(); 366 - $id('thread').appendChild(postView); 367 - } 368 - 369 - isLoading = false; 370 - firstPageLoaded = true; 371 - cursor = data.cursor; 372 - 373 - if (!cursor || posts.length == 0) { 374 - finished = true; 375 - } 376 - }).catch(error => { 377 - hideLoader(); 378 - console.log(error); 379 - isLoading = false; 380 - }); 381 - }); 382 - } 383 - 384 - /** @param {string} url */ 385 - 386 - function loadQuotesPage(url) { 387 - let isLoading = false; 388 - let firstPageLoaded = false; 389 - let cursor; 390 - let finished = false; 391 - 392 - Paginator.loadInPages(() => { 393 - if (isLoading || finished) { return; } 394 - isLoading = true; 395 - 396 - blueAPI.getQuotes(url, cursor).then(data => { 397 - api.loadPosts(data.posts).then(jsons => { 398 - let posts = jsons.map(j => new Post(j)); 399 - 400 - if (!firstPageLoaded) { 401 - hideLoader(); 402 - 403 - let header = $tag('header'); 404 - let h2; 405 - 406 - if (data.quoteCount > 1) { 407 - h2 = $tag('h2', { text: `${data.quoteCount} quotes:` }); 408 - } else if (data.quoteCount == 1) { 409 - h2 = $tag('h2', { text: '1 quote:' }); 410 - } else { 411 - h2 = $tag('h2', { text: 'No quotes found.' }); 412 - } 413 - 414 - header.append(h2); 415 - $id('thread').appendChild(header); 416 - $id('thread').classList.add('quotes'); 417 - } 418 - 419 - for (let post of posts) { 420 - let postView = new PostComponent(post, 'quotes').buildElement(); 421 - $id('thread').appendChild(postView); 422 - } 423 - 424 - isLoading = false; 425 - firstPageLoaded = true; 426 - cursor = data.cursor; 427 - 428 - if (!cursor || posts.length == 0) { 429 - finished = true; 430 - } 431 - }).catch(error => { 432 - hideLoader(); 433 - console.log(error); 434 - isLoading = false; 435 - }) 436 - }).catch(error => { 437 - hideLoader(); 438 - console.log(error); 439 - isLoading = false; 440 - }); 441 - }); 442 - }
+60
src/App.svelte
··· 1 + <script lang="ts"> 2 + import { account } from './models/account.svelte.js'; 3 + 4 + import AccountMenu from './components/AccountMenu.svelte'; 5 + import Dialogs, { showLoginDialog } from './components/Dialogs.svelte'; 6 + import HashtagPage from './pages/HashtagPage.svelte'; 7 + import HomeSearch from './components/HomeSearch.svelte'; 8 + import LikeStatsPage from './pages/LikeStatsPage.svelte'; 9 + import LycanSearchPage from './pages/LycanSearchPage.svelte'; 10 + import NotificationsPage from './pages/NotificationsPage.svelte'; 11 + import PostingStatsPage from './pages/PostingStatsPage.svelte'; 12 + import QuotesPage from './pages/QuotesPage.svelte'; 13 + import TangledLink from './components/TangledLink.svelte'; 14 + import ThreadPage from './pages/ThreadPage.svelte'; 15 + import TimelineSearchPage from './pages/TimelineSearchPage.svelte'; 16 + 17 + let { params }: { params: Record<string, string> } = $props(); 18 + 19 + if (params.page && !account.loggedIn) { 20 + showLoginDialog({ showClose: false }); 21 + } 22 + </script> 23 + 24 + <AccountMenu /> 25 + <Dialogs /> 26 + <TangledLink /> 27 + 28 + {#if params.q} 29 + <ThreadPage url={params.q} /> 30 + {:else if params.author && params.post} 31 + <ThreadPage author={params.author} rkey={params.post} /> 32 + {:else if params.quotes} 33 + <QuotesPage postURL={params.quotes} /> 34 + {:else if params.hash} 35 + <HashtagPage hashtag={params.hash} /> 36 + {:else if params.page} 37 + {#if account.loggedIn} 38 + {@render page()} 39 + {/if} 40 + {:else} 41 + <HomeSearch /> 42 + {/if} 43 + 44 + {#snippet page()} 45 + {#if params.page == 'notif'} 46 + <NotificationsPage /> 47 + {:else if params.page == 'posting_stats'} 48 + <PostingStatsPage /> 49 + {:else if params.page == 'like_stats'} 50 + <LikeStatsPage /> 51 + {:else if params.page == 'search'} 52 + {#if params.mode == 'likes'} 53 + <LycanSearchPage lycan={params.lycan} /> 54 + {:else} 55 + <TimelineSearchPage /> 56 + {/if} 57 + {:else} 58 + <HomeSearch /> 59 + {/if} 60 + {/snippet}
+135
src/api/authenticated_api.ts
··· 1 + import { BlueskyAPI, type TimelineFetchOptions } from "./bluesky_api"; 2 + import { AuthError } from './minisky.js'; 3 + import { Post } from '../models/posts.js'; 4 + import { atURI, feedPostTime } from '../utils.js'; 5 + 6 + /** 7 + * Stores user's access tokens and data in local storage after they log in. 8 + */ 9 + 10 + class LocalStorageConfig { 11 + user: json; 12 + 13 + constructor() { 14 + let data = localStorage.getItem('userData'); 15 + this.user = data ? JSON.parse(data) : {}; 16 + } 17 + 18 + save() { 19 + if (this.user) { 20 + localStorage.setItem('userData', JSON.stringify(this.user)); 21 + } else { 22 + localStorage.removeItem('userData'); 23 + } 24 + } 25 + } 26 + 27 + export class AuthenticatedAPI extends BlueskyAPI { 28 + override user: json; 29 + 30 + constructor() { 31 + let config = new LocalStorageConfig(); 32 + let pds: string | null = config.user.pdsEndpoint || null; 33 + super(pds, config); 34 + this.user = config.user; 35 + } 36 + 37 + async getCurrentUserAvatar(): Promise<json | undefined> { 38 + let json = await this.getRequest('com.atproto.repo.getRecord', { 39 + repo: this.user.did, 40 + collection: 'app.bsky.actor.profile', 41 + rkey: 'self' 42 + }); 43 + 44 + return json.value.avatar; 45 + } 46 + 47 + async loadCurrentUserAvatar(): Promise<string | null> { 48 + if (!this.config || !this.config.user) { 49 + throw new AuthError("User isn't logged in"); 50 + } 51 + 52 + let avatar = await this.getCurrentUserAvatar(); 53 + 54 + if (avatar) { 55 + let url = `https://cdn.bsky.app/img/avatar/plain/${this.user.did}/${avatar.ref.$link}@jpeg`; 56 + this.config.user.avatar = url; 57 + this.config.save(); 58 + return url; 59 + } else { 60 + return null; 61 + } 62 + } 63 + 64 + async loadNotifications(params?: json): Promise<json> { 65 + return await this.getRequest('app.bsky.notification.listNotifications', params || {}); 66 + } 67 + 68 + async loadMentions(cursor?: string): Promise<{ cursor: string | undefined, posts: json[] }> { 69 + let response = await this.loadNotifications({ cursor: cursor ?? '', limit: 100, reasons: ['reply', 'mention'] }); 70 + let uris = response.notifications.map((x: any) => x.uri); 71 + let batches: Promise<json[]>[] = []; 72 + 73 + for (let i = 0; i < uris.length; i += 25) { 74 + let batch = this.loadPosts(uris.slice(i, i + 25)); 75 + batches.push(batch); 76 + } 77 + 78 + let postGroups = await Promise.all(batches); 79 + 80 + return { cursor: response.cursor, posts: postGroups.flat() }; 81 + } 82 + 83 + async loadHomeTimeline(days: number, options: TimelineFetchOptions = {}): Promise<json[]> { 84 + let now = new Date(); 85 + let timeLimit = now.getTime() - days * 86400 * 1000; 86 + 87 + return await this.fetchAll('app.bsky.feed.getTimeline', { 88 + params: { limit: 100 }, 89 + field: 'feed', 90 + breakWhen: (x: json) => feedPostTime(x) < timeLimit, 91 + ...options 92 + }); 93 + } 94 + 95 + async loadUserLists(): Promise<json[]> { 96 + let lists = await this.fetchAll('app.bsky.graph.getLists', { 97 + params: { 98 + actor: this.user.did, 99 + limit: 100 100 + }, 101 + field: 'lists' 102 + }); 103 + 104 + return lists.filter((x: json) => x.purpose == 'app.bsky.graph.defs#curatelist'); 105 + } 106 + 107 + async likePost(post: Post): Promise<json> { 108 + return await this.postRequest('com.atproto.repo.createRecord', { 109 + repo: this.user.did, 110 + collection: 'app.bsky.feed.like', 111 + record: { 112 + subject: { 113 + uri: post.uri, 114 + cid: post.cid 115 + }, 116 + createdAt: new Date().toISOString() 117 + } 118 + }); 119 + } 120 + 121 + async removeLike(uri: string) { 122 + let { rkey } = atURI(uri); 123 + 124 + await this.postRequest('com.atproto.repo.deleteRecord', { 125 + repo: this.user.did, 126 + collection: 'app.bsky.feed.like', 127 + rkey: rkey 128 + }); 129 + } 130 + 131 + override resetTokens() { 132 + delete this.user.avatar; 133 + super.resetTokens(); 134 + } 135 + }
+264
src/api/bluesky_api.ts
··· 1 + import { HandleCache } from './handle_cache.js'; 2 + import { appView, constellationAPI } from '../api.js'; 3 + import { APIError, Minisky, type FetchAllOnPageLoad, type MiniskyConfig, type MiniskyOptions } from './minisky.js'; 4 + import { atURI, feedPostTime } from '../utils.js'; 5 + import { Post } from '../models/posts.js'; 6 + import { parseBlueskyPostURL } from '../router.js'; 7 + 8 + export { APIError }; 9 + 10 + /** 11 + * Thrown when the response is technically a "success" one, but the returned data is not what it should be. 12 + */ 13 + 14 + export class ResponseDataError extends Error {} 15 + 16 + /** 17 + * Thrown when the passed URL is not a supported post URL on bsky.app. 18 + */ 19 + 20 + export class URLError extends Error { 21 + constructor(message: string) { 22 + super(message); 23 + } 24 + } 25 + 26 + type AuthorFeedFilter = 27 + | 'posts_with_replies' // posts, replies and reposts (default) 28 + | 'posts_no_replies' // posts and reposts (no replies) 29 + | 'posts_and_author_threads' // posts, reposts, and replies in your own threads 30 + | 'posts_with_media' // posts and replies, but only with images (no reposts) 31 + | 'posts_with_video'; // posts and replies, but only with videos (no reposts) 32 + 33 + export type TimelineFetchOptions = { 34 + onPageLoad?: FetchAllOnPageLoad; 35 + keepLastPage?: boolean; 36 + abortSignal?: AbortSignal; 37 + } 38 + 39 + /** 40 + * API client for connecting to the Bluesky XRPC API (authenticated or not). 41 + */ 42 + 43 + export class BlueskyAPI extends Minisky { 44 + handleCache: HandleCache; 45 + profiles: Record<string, json>; 46 + 47 + constructor(host: string | null, config?: MiniskyConfig | null, options?: MiniskyOptions | null) { 48 + super(host, config, options); 49 + 50 + this.handleCache = new HandleCache(); 51 + this.profiles = {}; 52 + } 53 + 54 + cacheProfile(author: json) { 55 + this.profiles[author.did] = author; 56 + this.profiles[author.handle] = author; 57 + this.handleCache.setHandleDid(author.handle, author.did); 58 + } 59 + 60 + async fetchHandleForDid(did: string): Promise<string> { 61 + let cachedHandle = this.handleCache.findHandleByDid(did); 62 + 63 + if (cachedHandle) { 64 + return cachedHandle; 65 + } else { 66 + let author = await this.loadUserProfile(did); 67 + return author.handle; 68 + } 69 + } 70 + 71 + async resolveHandle(handle: string): Promise<string> { 72 + let cachedDid = this.handleCache.getHandleDid(handle); 73 + 74 + if (cachedDid) { 75 + return cachedDid; 76 + } else { 77 + let json = await this.getRequest('com.atproto.identity.resolveHandle', { handle }, { auth: false }); 78 + let did = json['did']; 79 + 80 + if (did) { 81 + this.handleCache.setHandleDid(handle, did); 82 + return did; 83 + } else { 84 + throw new ResponseDataError('Missing DID in response: ' + JSON.stringify(json)); 85 + } 86 + } 87 + } 88 + 89 + async loadThreadByURL(url: string): Promise<json> { 90 + let { user, post } = parseBlueskyPostURL(url); 91 + return await this.loadThreadById(user, post); 92 + } 93 + 94 + async loadThreadById(author: string, postId: string): Promise<json> { 95 + let did = author.startsWith('did:') ? author : await this.resolveHandle(author); 96 + let postURI = `at://${did}/app.bsky.feed.post/${postId}`; 97 + return await this.loadThreadByAtURI(postURI); 98 + } 99 + 100 + async loadThreadByAtURI(uri: string): Promise<json> { 101 + return await this.getRequest('app.bsky.feed.getPostThread', { uri: uri, depth: 10 }); 102 + } 103 + 104 + async loadUserProfile(handle: string): Promise<json> { 105 + if (this.profiles[handle]) { 106 + return this.profiles[handle]; 107 + } else { 108 + let profile = await this.getRequest('app.bsky.actor.getProfile', { actor: handle }); 109 + this.cacheProfile(profile); 110 + return profile; 111 + } 112 + } 113 + 114 + async autocompleteUsers(query: string): Promise<json[]> { 115 + let json = await this.getRequest('app.bsky.actor.searchActorsTypeahead', { q: query }); 116 + return json.actors; 117 + } 118 + 119 + async getReplies(uri: string): Promise<string[]> { 120 + let results = await this.fetchAll('blue.microcosm.links.getBacklinks', { 121 + field: 'records', 122 + params: { 123 + subject: uri, 124 + source: 'app.bsky.feed.post:reply.parent.uri', 125 + limit: 100 126 + } 127 + }); 128 + 129 + return results.map((x: json) => `at://${x.did}/${x.collection}/${x.rkey}`); 130 + } 131 + 132 + async getQuoteCount(uri: string): Promise<number> { 133 + let json = await this.getRequest('blue.feeds.post.getQuoteCount', { uri }); 134 + return json.quoteCount; 135 + } 136 + 137 + async getQuotes(url: string, cursor?: string): Promise<json> { 138 + let postURI: string; 139 + 140 + if (url.startsWith('at://')) { 141 + postURI = url; 142 + } else { 143 + let { user, post } = parseBlueskyPostURL(url); 144 + let did = user.startsWith('did:') ? user : await appView.resolveHandle(user); 145 + postURI = `at://${did}/app.bsky.feed.post/${post}`; 146 + } 147 + 148 + let params: Record<string, string> = { uri: postURI }; 149 + 150 + if (cursor) { 151 + params['cursor'] = cursor; 152 + } 153 + 154 + return await this.getRequest('blue.feeds.post.getQuotes', params); 155 + } 156 + 157 + async getHashtagFeed(hashtag: string, cursor?: string): Promise<json> { 158 + let params: Record<string, any> = { q: '#' + hashtag, limit: 50, sort: 'latest' }; 159 + 160 + if (cursor) { 161 + params['cursor'] = cursor; 162 + } 163 + 164 + return await this.getRequest('app.bsky.feed.searchPosts', params); 165 + } 166 + 167 + async loadHiddenReplies(post: Post): Promise<(json | null)[]> { 168 + let expectedReplyURIs = await constellationAPI.getReplies(post.uri); 169 + let missingReplyURIs = expectedReplyURIs.filter(r => !post.replies.some(x => x.uri === r)); 170 + let promises = missingReplyURIs.map(uri => this.loadThreadByAtURI(uri)); 171 + let responses = await Promise.allSettled(promises); 172 + 173 + return responses.map(r => (r.status == 'fulfilled') ? r.value : null); 174 + } 175 + 176 + async loadUserTimeline( 177 + did: string, 178 + days: number, 179 + options: { filter: AuthorFeedFilter } & TimelineFetchOptions 180 + ): Promise<json[]> { 181 + let now = new Date(); 182 + let timeLimit = now.getTime() - days * 86400 * 1000; 183 + let { filter, ...fetchOptions } = options; 184 + 185 + return await this.fetchAll('app.bsky.feed.getAuthorFeed', { 186 + params: { 187 + actor: did, 188 + filter: filter, 189 + limit: 100 190 + }, 191 + field: 'feed', 192 + breakWhen: (x: json) => feedPostTime(x) < timeLimit, 193 + ...fetchOptions 194 + }); 195 + } 196 + 197 + async loadListTimeline(list: string, days: number, options: TimelineFetchOptions = {}): Promise<json[]> { 198 + let now = new Date(); 199 + let timeLimit = now.getTime() - days * 86400 * 1000; 200 + 201 + return await this.fetchAll('app.bsky.feed.getListFeed', { 202 + params: { 203 + list: list, 204 + limit: 100 205 + }, 206 + field: 'feed', 207 + breakWhen: (x: json) => feedPostTime(x) < timeLimit, 208 + ...options 209 + }); 210 + } 211 + 212 + async loadPost(postURI: string): Promise<json> { 213 + let posts = await this.loadPosts([postURI]); 214 + 215 + if (posts.length == 1) { 216 + return posts[0]; 217 + } else { 218 + throw new ResponseDataError('Post not found'); 219 + } 220 + } 221 + 222 + async loadPostIfExists(postURI: string): Promise<json | undefined> { 223 + let posts = await this.loadPosts([postURI]); 224 + return posts[0]; 225 + } 226 + 227 + async loadPosts(uris: string[]): Promise<json[]> { 228 + if (uris.length > 0) { 229 + let response = await this.getRequest('app.bsky.feed.getPosts', { uris }); 230 + return response.posts; 231 + } else { 232 + return []; 233 + } 234 + } 235 + 236 + async loadPostViewerInfo(post: Post): Promise<json | undefined> { 237 + let data = await this.loadPostIfExists(post.uri); 238 + 239 + if (data) { 240 + post.author = data.author; 241 + post.viewerData = data.viewer; 242 + post.viewerLike = data.viewer?.like; 243 + } 244 + 245 + return data; 246 + } 247 + 248 + async reloadBlockedPost(uri: string): Promise<Post | null> { 249 + let { repo } = atURI(uri); 250 + 251 + let loadPost = appView.loadPostIfExists(uri); 252 + let loadProfile = this.getRequest('app.bsky.actor.getProfile', { actor: repo }); 253 + 254 + let data = await loadPost; 255 + 256 + if (!data) { 257 + return null; 258 + } 259 + 260 + let profile = await loadProfile; 261 + 262 + return new Post(data, { author: profile }); 263 + } 264 + }
+37
src/api/handle_cache.ts
··· 1 + /** 2 + * Caches the mapping of handles to DIDs to avoid unnecessary API calls to resolveHandle or getProfile. 3 + */ 4 + 5 + type HandleMap = Record<string, string>; 6 + 7 + export class HandleCache { 8 + cache?: HandleMap 9 + 10 + prepareCache(): asserts this is { cache: HandleMap } { 11 + if (!this.cache) { 12 + let savedCache = localStorage.getItem('handleCache'); 13 + this.cache = (savedCache ? JSON.parse(savedCache) : {}) as HandleMap; 14 + } 15 + } 16 + 17 + saveCache() { 18 + localStorage.setItem('handleCache', JSON.stringify(this.cache)); 19 + } 20 + 21 + getHandleDid(handle: string): string | undefined { 22 + this.prepareCache(); 23 + return this.cache[handle]; 24 + } 25 + 26 + setHandleDid(handle: string, did: string) { 27 + this.prepareCache(); 28 + this.cache[handle] = did; 29 + this.saveCache(); 30 + } 31 + 32 + findHandleByDid(did: string) { 33 + this.prepareCache(); 34 + let found = Object.entries(this.cache).find((e) => e[1] == did); 35 + return found ? found[0] : undefined; 36 + } 37 + }
+58
src/api/identity.ts
··· 1 + import { appView } from '../api.js'; 2 + import { APIError } from './minisky.js'; 3 + 4 + /** 5 + * Thrown when DID or DID document is invalid. 6 + */ 7 + 8 + export class DIDError extends Error {} 9 + 10 + export class LoginError extends Error {} 11 + 12 + export async function pdsEndpointForDID(did: string): Promise<string> { 13 + let documentURL: URL; 14 + 15 + if (did.startsWith('did:plc:')) { 16 + documentURL = new URL(`https://plc.directory/${did}`); 17 + 18 + } else if (did.startsWith('did:web:')) { 19 + let host = did.replace(/^did:web:/, ''); 20 + documentURL = new URL(`https://${host}/.well-known/did.json`); 21 + 22 + } else { 23 + throw new DIDError(`Unknown DID type: ${did}`); 24 + } 25 + 26 + let response = await fetch(documentURL); 27 + let text = await response.text(); 28 + let json = text.trim().length > 0 ? JSON.parse(text) : undefined; 29 + 30 + if (response.status == 200) { 31 + let service = (json.service || []).find((s: json) => s.id == '#atproto_pds'); 32 + 33 + if (service) { 34 + return service.serviceEndpoint.replace('https://', ''); 35 + } else { 36 + throw new DIDError("Missing #atproto_pds service definition"); 37 + } 38 + } else { 39 + throw new APIError(response.status, json); 40 + } 41 + } 42 + 43 + export async function pdsEndpointForIdentifier(identifier: string): Promise<string> { 44 + if (identifier.match(/^did:/)) { 45 + return await pdsEndpointForDID(identifier); 46 + 47 + } else if (identifier.match(/^[^@]+@[^@]+$/)) { 48 + return 'bsky.social'; 49 + 50 + } else if (identifier.match(/^@?[\w\-]+(\.[\w\-]+)+$/)) { 51 + identifier = identifier.replace(/^@/, ''); 52 + let did = await appView.resolveHandle(identifier); 53 + return await pdsEndpointForDID(did); 54 + 55 + } else { 56 + throw new LoginError('Please enter your handle or DID.'); 57 + } 58 + }
+316
src/api/minisky.ts
··· 1 + /** 2 + * Thrown when status code of an API response is not "success". 3 + */ 4 + 5 + export class APIError extends Error { 6 + code: number; 7 + json: json; 8 + 9 + constructor(code: number, json: json) { 10 + super("APIError status " + code + "\n\n" + JSON.stringify(json)); 11 + this.code = code; 12 + this.json = json; 13 + } 14 + } 15 + 16 + 17 + /** 18 + * Thrown when passed arguments/options are invalid or missing. 19 + */ 20 + 21 + export class RequestError extends Error {} 22 + 23 + 24 + /** 25 + * Thrown when authentication is needed, but access token is invalid or missing. 26 + */ 27 + 28 + export class AuthError extends Error {} 29 + 30 + 31 + /** 32 + * Base API client for connecting to an ATProto XRPC API. 33 + */ 34 + 35 + export type MiniskyOptions = { 36 + sendAuthHeaders?: boolean; 37 + autoManageTokens?: boolean; 38 + }; 39 + 40 + export type MiniskyConfig = { 41 + user: json | null | undefined; 42 + save: () => void; 43 + }; 44 + 45 + export type MiniskyRequestOptions = { 46 + auth?: string | boolean; 47 + headers?: Record<string, string>; 48 + abortSignal?: AbortSignal; 49 + }; 50 + 51 + export type FetchAllOnPageLoad = (items: json[]) => void; 52 + 53 + export type FetchAllOptions = MiniskyOptions & MiniskyRequestOptions & { 54 + field: string; 55 + params?: json; 56 + breakWhen?: (obj: json) => boolean; 57 + keepLastPage?: boolean; 58 + onPageLoad?: FetchAllOnPageLoad; 59 + }; 60 + 61 + export class Minisky { 62 + host: string | null; 63 + config: MiniskyConfig | null; 64 + user: json | null; 65 + sendAuthHeaders: boolean; 66 + autoManageTokens: boolean; 67 + 68 + constructor(host: string | null, config?: MiniskyConfig | null, options?: MiniskyOptions | null) { 69 + this.host = host; 70 + this.config = config || null; 71 + this.user = config?.user || null; 72 + 73 + // defaults, can be overridden with options 74 + this.sendAuthHeaders = !!this.user; 75 + this.autoManageTokens = !!this.user; 76 + 77 + if (options) { 78 + Object.assign(this, options); 79 + } 80 + } 81 + 82 + get baseURL(): string { 83 + if (this.host) { 84 + let host = (this.host.includes('://')) ? this.host : `https://${this.host}`; 85 + return host + '/xrpc'; 86 + } else { 87 + throw new RequestError('Hostname not set'); 88 + } 89 + } 90 + 91 + get isLoggedIn(): boolean { 92 + return !!(this.user && this.user.accessToken && this.user.refreshToken && this.user.did && this.user.pdsEndpoint); 93 + } 94 + 95 + async getRequest(method: string, params?: json | null, options: MiniskyRequestOptions = {}): Promise<json> { 96 + let url = new URL(`${this.baseURL}/${method}`); 97 + let auth = options && ('auth' in options) ? options.auth : this.sendAuthHeaders; 98 + 99 + if (this.autoManageTokens && auth === true) { 100 + await this.checkAccess(); 101 + } 102 + 103 + if (params) { 104 + for (let p in params) { 105 + if (params[p] instanceof Array) { 106 + params[p].forEach(x => url.searchParams.append(p, x)); 107 + } else { 108 + url.searchParams.append(p, params[p]); 109 + } 110 + } 111 + } 112 + 113 + let headers: HeadersInit = this.authHeaders(auth); 114 + 115 + if (options.headers) { 116 + Object.assign(headers, options.headers); 117 + } 118 + 119 + let response = await fetch(url, { headers: headers, signal: options.abortSignal ?? null }); 120 + return await this.parseResponse(response); 121 + } 122 + 123 + async postRequest(method: string, data?: json | null, options: MiniskyRequestOptions = {}): Promise<json> { 124 + let url = `${this.baseURL}/${method}`; 125 + let auth = options && ('auth' in options) ? options.auth : this.sendAuthHeaders; 126 + 127 + if (this.autoManageTokens && auth === true) { 128 + await this.checkAccess(); 129 + } 130 + 131 + let headers: HeadersInit = this.authHeaders(auth); 132 + let request: RequestInit = { method: 'POST' }; 133 + 134 + if (data) { 135 + request.body = JSON.stringify(data); 136 + headers['Content-Type'] = 'application/json'; 137 + } 138 + 139 + if (options.headers) { 140 + Object.assign(headers, options.headers); 141 + } 142 + 143 + if (options.abortSignal) { 144 + request.signal = options.abortSignal; 145 + } 146 + 147 + request.headers = headers; 148 + let response = await fetch(url, request); 149 + return await this.parseResponse(response); 150 + } 151 + 152 + async fetchAll(method: string, options?: FetchAllOptions): Promise<json[]> { 153 + if (!options || !options.field) { 154 + throw new RequestError("'field' option is required"); 155 + } 156 + 157 + let data: json[] = []; 158 + let reqParams: json = options.params ?? {}; 159 + let reqOptions = this.sliceOptions(options, ['auth', 'headers', 'abortSignal']) as MiniskyRequestOptions; 160 + 161 + for (;;) { 162 + let response = await this.getRequest(method, reqParams, reqOptions); 163 + 164 + let items = response[options.field]; 165 + let cursor = response.cursor; 166 + 167 + if (options.breakWhen) { 168 + let test = options.breakWhen; 169 + 170 + if (items.some((x: json) => test(x))) { 171 + if (!options.keepLastPage) { 172 + items = items.filter((x: json) => !test(x)); 173 + } 174 + 175 + cursor = null; 176 + } 177 + } 178 + 179 + data = data.concat(items); 180 + reqParams.cursor = cursor; 181 + options.onPageLoad?.(items); 182 + 183 + if (!cursor) { 184 + break; 185 + } 186 + } 187 + 188 + return data; 189 + } 190 + 191 + authHeaders(auth: string | boolean) { 192 + if (typeof auth == 'string') { 193 + return { 'Authorization': `Bearer ${auth}` }; 194 + } else if (auth) { 195 + if (this.user?.accessToken) { 196 + return { 'Authorization': `Bearer ${this.user.accessToken}` }; 197 + } else { 198 + throw new AuthError("Can't send auth headers, access token is missing"); 199 + } 200 + } else { 201 + return {}; 202 + } 203 + } 204 + 205 + sliceOptions(options: json, list: string[]): json { 206 + let newOptions: any = {}; 207 + 208 + for (let i of list) { 209 + if (i in options) { 210 + newOptions[i] = options[i]; 211 + } 212 + } 213 + 214 + return newOptions; 215 + } 216 + 217 + tokenExpirationTimestamp(token: string): number { 218 + let parts = token.split('.'); 219 + if (parts.length != 3) { 220 + throw new AuthError("Invalid access token format"); 221 + } 222 + 223 + let payload = JSON.parse(atob(parts[1])); 224 + let exp = payload.exp; 225 + 226 + if (!(exp && typeof exp == 'number' && exp > 0)) { 227 + throw new AuthError("Invalid token expiry data"); 228 + } 229 + 230 + return exp * 1000; 231 + } 232 + 233 + isInvalidToken(response: Response, json: json): boolean { 234 + return (response.status == 400) && !!json && ['InvalidToken', 'ExpiredToken'].includes(json.error); 235 + } 236 + 237 + async parseResponse(response: Response): Promise<json> { 238 + let text = await response.text(); 239 + let json = text.trim().length > 0 ? JSON.parse(text) : undefined; 240 + 241 + if (response.status >= 200 && response.status < 300) { 242 + return json; 243 + } else { 244 + throw new APIError(response.status, json); 245 + } 246 + } 247 + 248 + requireUserConfig(): asserts this is { config: MiniskyConfig, user: json } { 249 + if (!this.config || !this.config.user) { 250 + throw new AuthError("Missing user configuration object"); 251 + } 252 + } 253 + 254 + requireLoggedInUser(): asserts this is { config: MiniskyConfig, user: json } { 255 + this.requireUserConfig(); 256 + 257 + if (!this.isLoggedIn) { 258 + throw new AuthError("Not logged in"); 259 + } 260 + } 261 + 262 + async checkAccess() { 263 + this.requireLoggedInUser(); 264 + 265 + let expirationTimestamp = this.tokenExpirationTimestamp(this.user.accessToken); 266 + 267 + if (expirationTimestamp < new Date().getTime() + 60 * 1000) { 268 + await this.performTokenRefresh(); 269 + } 270 + } 271 + 272 + async logIn(handle: string, password: string): Promise<json> { 273 + this.requireUserConfig(); 274 + 275 + let params = { identifier: handle, password: password }; 276 + let json = await this.postRequest('com.atproto.server.createSession', params, { auth: false }); 277 + 278 + this.saveTokens(json); 279 + return json; 280 + } 281 + 282 + async performTokenRefresh(): Promise<json> { 283 + this.requireLoggedInUser(); 284 + 285 + console.log('Refreshing access tokenโ€ฆ'); 286 + let json = await this.postRequest('com.atproto.server.refreshSession', null, { auth: this.user.refreshToken }); 287 + this.saveTokens(json); 288 + return json; 289 + } 290 + 291 + saveTokens(json: json) { 292 + this.requireUserConfig(); 293 + 294 + this.user.accessToken = json['accessJwt']; 295 + this.user.refreshToken = json['refreshJwt']; 296 + this.user.did = json['did']; 297 + 298 + if (json.didDoc?.service) { 299 + let service = json.didDoc.service.find((s: json) => s.id == '#atproto_pds'); 300 + this.host = service.serviceEndpoint.replace('https://', ''); 301 + } 302 + 303 + this.user.pdsEndpoint = this.host; 304 + this.config.save(); 305 + } 306 + 307 + resetTokens() { 308 + this.requireUserConfig(); 309 + 310 + delete this.user.accessToken; 311 + delete this.user.refreshToken; 312 + delete this.user.did; 313 + delete this.user.pdsEndpoint; 314 + this.config.save(); 315 + } 316 + }
+43
src/api.ts
··· 1 + import { AuthenticatedAPI } from "./api/authenticated_api"; 2 + import { BlueskyAPI, URLError } from "./api/bluesky_api"; 3 + import { APIError, Minisky } from "./api/minisky"; 4 + import { settings } from "./models/settings.svelte"; 5 + 6 + export { AuthenticatedAPI, BlueskyAPI, Minisky }; 7 + export { APIError, URLError }; 8 + 9 + declare global { 10 + interface Window { 11 + AuthenticatedAPI: typeof AuthenticatedAPI; 12 + BlueskyAPI: typeof BlueskyAPI; 13 + Minisky: typeof Minisky; 14 + 15 + api: BlueskyAPI; 16 + appView: BlueskyAPI; 17 + blueAPI: BlueskyAPI; 18 + constellationAPI: BlueskyAPI; 19 + accountAPI: AuthenticatedAPI; 20 + } 21 + } 22 + 23 + export let appView = new BlueskyAPI('api.bsky.app'); 24 + export let blueAPI = new BlueskyAPI('blue.mackuba.eu'); 25 + export let constellationAPI = new BlueskyAPI('constellation.microcosm.blue'); 26 + export let accountAPI = new AuthenticatedAPI(); 27 + export let api: BlueskyAPI; 28 + 29 + export function setAPI() { 30 + api = (accountAPI.isLoggedIn && !settings.incognitoMode) ? accountAPI : appView; 31 + window.api = api; 32 + } 33 + 34 + setAPI(); 35 + 36 + window.AuthenticatedAPI = AuthenticatedAPI; 37 + window.BlueskyAPI = BlueskyAPI; 38 + window.Minisky = Minisky; 39 + 40 + window.appView = appView; 41 + window.blueAPI = blueAPI; 42 + window.accountAPI = accountAPI; 43 + window.constellationAPI = constellationAPI;
+184
src/components/AccountMenu.svelte
··· 1 + <script lang="ts"> 2 + import { showLoginDialog } from './Dialogs.svelte'; 3 + import { account } from '../models/account.svelte.js'; 4 + import { settings } from '../models/settings.svelte.js'; 5 + import { getBaseLocation } from '../router.js'; 6 + import AccountMenuButton from './AccountMenuButton.svelte'; 7 + import LoadableImage from './LoadableImage.svelte'; 8 + 9 + let menuVisible = $state(false); 10 + 11 + $effect(() => { 12 + let html = document.body.parentNode! 13 + html.addEventListener('click', hideMenu); 14 + 15 + return () => { 16 + html.removeEventListener('click', hideMenu); 17 + }; 18 + }); 19 + 20 + function hideMenu() { 21 + menuVisible = false; 22 + } 23 + 24 + function toggleMenu(e: Event) { 25 + e.stopPropagation(); 26 + menuVisible = !menuVisible; 27 + } 28 + 29 + function toggleBiohazard(e: Event) { 30 + e.preventDefault(); 31 + 32 + if (settings.biohazardsEnabled === false) { 33 + settings.biohazardsEnabled = true; 34 + } else { 35 + settings.biohazardsEnabled = false; 36 + } 37 + } 38 + 39 + function toggleIncognito(e: Event) { 40 + e.preventDefault(); 41 + account.toggleIncognitoMode(); 42 + } 43 + 44 + function showLoginScreen(e: Event) { 45 + e.preventDefault(); 46 + 47 + showLoginDialog({ showClose: true }); 48 + menuVisible = false; 49 + } 50 + 51 + function logOut(e: Event) { 52 + e.preventDefault(); 53 + account.logOut(); 54 + } 55 + </script> 56 + 57 + <div id="account" onclick={toggleMenu} class={{ active: menuVisible }}> 58 + {#if account.isIncognito} 59 + <i class="fa-solid fa-user-secret fa-lg"></i> 60 + 61 + {:else if !account.loggedIn || account.avatarIsLoading} 62 + <i class="fa-regular fa-user-circle fa-xl"></i> 63 + 64 + {:else if account.loggedIn && account.avatarURL} 65 + <LoadableImage class="avatar" src={account.avatarURL}> 66 + {#snippet loading()} 67 + <i class="fa-regular fa-user-circle fa-xl"></i> 68 + {/snippet} 69 + {#snippet error()} 70 + <i class="fa-solid fa-user-circle fa-xl"></i> 71 + {/snippet} 72 + </LoadableImage> 73 + 74 + {:else} 75 + <i class="fa-solid fa-user-circle fa-xl"></i> 76 + {/if} 77 + </div> 78 + 79 + <div id="account_menu" style="visibility: {menuVisible ? 'visible' : 'hidden'}" onclick={(e) => e.stopPropagation()}> 80 + <ul> 81 + {#if account.loggedIn} 82 + <AccountMenuButton 83 + onclick={toggleIncognito} 84 + label="Incognito mode" 85 + title="Temporarily load threads as a logged-out user" 86 + showCheckmark={account.isIncognito} 87 + /> 88 + {/if} 89 + 90 + <AccountMenuButton 91 + onclick={toggleBiohazard} 92 + label="Show infohazards" 93 + title="Show links to blocked and hidden comments" 94 + showCheckmark={settings.biohazardsEnabled !== false} 95 + /> 96 + 97 + {#if !account.loggedIn} 98 + <AccountMenuButton onclick={showLoginScreen} label="Log in" /> 99 + {:else} 100 + <AccountMenuButton onclick={logOut} label="Log out" /> 101 + {/if} 102 + 103 + <li class="link"><a href="{getBaseLocation()}">Home</a></li> 104 + <li class="link"><a href="?page=posting_stats">Posting stats</a></li> 105 + <li class="link"><a href="?page=like_stats">Like stats</a></li> 106 + <li class="link"><a href="?page=search">Timeline search</a></li> 107 + <li class="link"><a href="?page=search&mode=likes">Archive search</a></li> 108 + </ul> 109 + </div> 110 + 111 + <style> 112 + #account { 113 + position: fixed; 114 + top: 10px; 115 + left: 10px; 116 + line-height: 24px; 117 + z-index: 20; 118 + user-select: none; 119 + -webkit-user-select: none; 120 + } 121 + 122 + #account i { 123 + opacity: 0.4; 124 + } 125 + 126 + #account i:hover { 127 + cursor: pointer; 128 + opacity: 0.6; 129 + } 130 + 131 + #account :global(img.avatar) { 132 + width: 24px; 133 + height: 24px; 134 + border-radius: 13px; 135 + box-shadow: 0px 0px 2px black; 136 + } 137 + 138 + #account_menu { 139 + position: fixed; 140 + visibility: hidden; 141 + top: 5px; 142 + left: 5px; 143 + padding-top: 30px; 144 + z-index: 15; 145 + background: hsl(210, 33.33%, 94.0%); 146 + border: 1px solid #ccc; 147 + border-radius: 5px; 148 + user-select: none; 149 + -webkit-user-select: none; 150 + } 151 + 152 + #account_menu ul { 153 + list-style-type: none; 154 + margin: 0px 0px 10px; 155 + padding: 6px 11px; 156 + } 157 + 158 + #account_menu :global(li:not(.link) + li.link) { 159 + margin-top: 16px; 160 + padding-top: 10px; 161 + border-top: 1px solid #ccc; 162 + } 163 + 164 + li.link { 165 + margin-top: 8px; 166 + margin-left: 2px; 167 + } 168 + 169 + li.link a { 170 + font-size: 11pt; 171 + color: #333; 172 + } 173 + 174 + @media (prefers-color-scheme: dark) { 175 + #account.active { 176 + color: #333; 177 + } 178 + 179 + #account_menu { 180 + background: hsl(210, 33.33%, 94.0%); 181 + border-color: #ccc; 182 + } 183 + } 184 + </style>
+44
src/components/AccountMenuButton.svelte
··· 1 + <script lang="ts"> 2 + type Props = { 3 + title?: string, 4 + label: string, 5 + showCheckmark?: boolean, 6 + onclick: (e: Event) => void 7 + } 8 + 9 + let { title = undefined, label, showCheckmark = false, onclick }: Props = $props(); 10 + </script> 11 + 12 + <li><a class="button" href="#" {onclick} {title}> 13 + {#if showCheckmark} <span class="check">โœ“</span> {/if} {label} 14 + </a></li> 15 + 16 + <style> 17 + li .button { 18 + display: inline-block; 19 + color: #333; 20 + font-size: 11pt; 21 + border: 1px solid #bbb; 22 + padding: 3px 5px; 23 + margin-top: 8px; 24 + border-radius: 5px; 25 + background-color: hsla(210, 100%, 4%, 0.12); 26 + } 27 + 28 + li .button:hover { 29 + background-color: hsla(210, 100%, 4%, 0.2); 30 + text-decoration: none; 31 + } 32 + 33 + @media (prefers-color-scheme: dark) { 34 + li .button { 35 + color: #333; 36 + border-color: #bbb; 37 + background-color: hsla(210, 100%, 4%, 0.12); 38 + } 39 + 40 + li .button:hover { 41 + background-color: hsla(210, 100%, 4%, 0.2); 42 + } 43 + } 44 + </style>
+61
src/components/BiohazardDialog.svelte
··· 1 + <script lang="ts"> 2 + import { settings } from '../models/settings.svelte.js'; 3 + import DialogPanel from './DialogPanel.svelte'; 4 + 5 + type Props = { 6 + onConfirm?: () => void; 7 + onReject?: () => void; 8 + onClose?: () => void; 9 + } 10 + 11 + let { onConfirm = undefined, onReject = undefined, onClose = undefined }: Props = $props(); 12 + 13 + function showBiohazard(e: Event) { 14 + e.preventDefault(); 15 + settings.biohazardsEnabled = true; 16 + 17 + onConfirm?.() 18 + onClose?.(); 19 + } 20 + 21 + function hideBiohazard(e: Event) { 22 + e.preventDefault(); 23 + settings.biohazardsEnabled = false; 24 + 25 + onReject?.(); 26 + onClose?.(); 27 + } 28 + </script> 29 + 30 + <DialogPanel onClose={() => onClose?.()}> 31 + <form method="get"> 32 + <i class="close fa-circle-xmark fa-regular" onclick={onClose}></i> 33 + <h2>โ˜ฃ๏ธ Infohazard Warning</h2> 34 + 35 + <p>&ldquo;<em>This thread is not a place of honor... no highly esteemed post is commemorated here... nothing valued is here.</em>&rdquo;</p> 36 + <p>This feature allows access to comments in a thread which were hidden because one of the commenters has blocked another. Bluesky currently hides such comments to avoid escalating conflicts.</p> 37 + <p>Are you sure you want to enter?<br>(You can toggle this in the menu in top-left corner.)</p> 38 + 39 + <p class="submit"> 40 + <input type="submit" value="Show me the drama ๐Ÿ˜ˆ" onclick={showBiohazard}> 41 + <input type="submit" value="Nope, I'd rather not ๐Ÿ™ˆ" onclick={hideBiohazard}> 42 + </p> 43 + </form> 44 + </DialogPanel> 45 + 46 + <style> 47 + form { 48 + width: 400px; 49 + } 50 + 51 + :global(.dialog) p.submit { 52 + margin-top: 40px; 53 + margin-bottom: 20px; 54 + } 55 + 56 + :global(.dialog) input[type="submit"] { 57 + width: 180px; 58 + margin-left: 5px; 59 + margin-right: 5px; 60 + } 61 + </style>
+113
src/components/DialogPanel.svelte
··· 1 + <script lang="ts"> 2 + type Props = { 3 + children: any; 4 + id?: string; 5 + class?: string; 6 + onClose?: () => void; 7 + } 8 + 9 + let { children, onClose = undefined, id = undefined, ...props }: Props = $props(); 10 + 11 + function onclick(e: Event) { 12 + // close the dialog (if it's closable) on click on the overlay, but not on anything inside 13 + if (e.target === e.currentTarget) { 14 + onClose?.(); 15 + } 16 + } 17 + </script> 18 + 19 + <div {id} class="dialog {props.class}" {onclick}> 20 + {@render children()} 21 + </div> 22 + 23 + <style> 24 + .dialog { 25 + position: fixed; 26 + top: 0; 27 + bottom: 0; 28 + left: 0; 29 + right: 0; 30 + display: flex; 31 + align-items: center; 32 + justify-content: center; 33 + padding-bottom: 5%; 34 + z-index: 10; 35 + background-color: rgba(240, 240, 240, 0.4); 36 + } 37 + 38 + .dialog:global(.expanded) { 39 + padding-bottom: 0; 40 + } 41 + 42 + .dialog ~ :global(main) { 43 + filter: blur(8px); 44 + } 45 + 46 + .dialog :global { 47 + form { 48 + position: relative; 49 + border: 2px solid hsl(210, 100%, 85%); 50 + background-color: hsl(210, 100%, 98%); 51 + border-radius: 10px; 52 + padding: 15px 25px; 53 + } 54 + 55 + .close { 56 + position: absolute; 57 + top: 5px; 58 + right: 5px; 59 + color: hsl(210, 100%, 75%); 60 + opacity: 0.6; 61 + } 62 + 63 + .close:hover { 64 + color: hsl(210, 100%, 65%); 65 + opacity: 1.0; 66 + } 67 + 68 + p { 69 + text-align: center; 70 + line-height: 125%; 71 + } 72 + 73 + h2 { 74 + font-size: 13pt; 75 + font-weight: 600; 76 + text-align: center; 77 + margin-bottom: 25px; 78 + padding-right: 10px; 79 + } 80 + 81 + input[type="text"], input[type="password"] { 82 + width: 200px; 83 + font-size: 11pt; 84 + border: 1px solid #d6d6d6; 85 + border-radius: 4px; 86 + padding: 5px 6px; 87 + margin: 0px 15px; 88 + } 89 + 90 + p.submit { 91 + margin-top: 25px; 92 + } 93 + 94 + input[type="submit"] { 95 + width: 150px; 96 + font-size: 11pt; 97 + border: 1px solid hsl(210, 90%, 85%); 98 + background-color: hsl(210, 100%, 92%); 99 + border-radius: 4px; 100 + padding: 5px 6px; 101 + } 102 + 103 + input[type="submit"]:hover { 104 + background-color: hsl(210, 100%, 90%); 105 + border: 1px solid hsl(210, 90%, 82%); 106 + } 107 + 108 + input[type="submit"]:active { 109 + background-color: hsl(210, 100%, 87%); 110 + border: 1px solid hsl(210, 90%, 80%); 111 + } 112 + } 113 + </style>
+30
src/components/Dialogs.svelte
··· 1 + <script module lang="ts"> 2 + import BiohazardDialog from './BiohazardDialog.svelte'; 3 + import LoginDialog from './LoginDialog.svelte'; 4 + 5 + let loginDisplayed = $state(false); 6 + let loginWithClose = $state(false); 7 + 8 + let biohazardDisplayed = $state(false); 9 + let biohazardOnConfirm: (() => void) | undefined = $state(undefined); 10 + 11 + export function showLoginDialog(opts: { showClose: boolean }) { 12 + if (!loginDisplayed) { 13 + loginDisplayed = true; 14 + loginWithClose = opts.showClose; 15 + } 16 + } 17 + 18 + export function showBiohazardDialog(onConfirm?: () => void) { 19 + if (!biohazardDisplayed) { 20 + biohazardDisplayed = true; 21 + biohazardOnConfirm = onConfirm; 22 + } 23 + } 24 + </script> 25 + 26 + {#if loginDisplayed} 27 + <LoginDialog onClose={() => loginDisplayed = false} showClose={loginWithClose} /> 28 + {:else if biohazardDisplayed} 29 + <BiohazardDialog onClose={() => biohazardDisplayed = false} onConfirm={() => biohazardOnConfirm?.()} /> 30 + {/if}
+87
src/components/HomeSearch.svelte
··· 1 + <script lang="ts"> 2 + import { getBaseLocation, linkToHashtagPage, linkToPostById, parseBlueskyPostURL } from '../router.js'; 3 + 4 + let query = $state(''); 5 + let searchField: HTMLInputElement; 6 + 7 + $effect(() => { 8 + searchField.focus(); 9 + }); 10 + 11 + function onsubmit(e: Event) { 12 + e.preventDefault(); 13 + 14 + let q = query.trim(); 15 + 16 + if (!q) { 17 + return; 18 + } 19 + 20 + if (q.startsWith('at://')) { 21 + let target = new URL(getBaseLocation()); 22 + target.searchParams.set('q', q); 23 + location.assign(target.toString()); 24 + 25 + } else if (q.match(/^#?((\p{Letter}|\p{Number})+)$/u)) { 26 + let hashtag = q.replace(/^#/, ''); 27 + location.assign(linkToHashtagPage(hashtag)); 28 + 29 + } else { 30 + try { 31 + let { user, post } = parseBlueskyPostURL(q); 32 + location.assign(linkToPostById(user, post)); 33 + } catch (error) { 34 + console.log(error); 35 + alert(error.message || "This is not a valid URL or hashtag"); 36 + } 37 + } 38 + } 39 + </script> 40 + 41 + <div id="search"> 42 + <form method="get" {onsubmit}> 43 + ๐ŸŒค <input type="text" placeholder="Paste a thread link or type a #hashtag" bind:value={query} bind:this={searchField}> 44 + </form> 45 + </div> 46 + 47 + <style> 48 + #search { 49 + position: fixed; 50 + top: 0; 51 + bottom: 0; 52 + left: 0; 53 + right: 0; 54 + display: flex; 55 + align-items: center; 56 + justify-content: center; 57 + padding-bottom: 5%; 58 + } 59 + 60 + form { 61 + border: 2px solid hsl(210, 100%, 80%); 62 + border-radius: 10px; 63 + padding: 15px 20px; 64 + margin-left: 50px; 65 + } 66 + 67 + input { 68 + font-size: 16pt; 69 + width: 600px; 70 + border: 0; 71 + margin-left: 8px; 72 + } 73 + 74 + input:focus { 75 + outline: none; 76 + } 77 + 78 + @media (prefers-color-scheme: dark) { 79 + form { 80 + border-color: hsl(210, 40%, 60%); 81 + } 82 + 83 + form input { 84 + background-color: transparent; 85 + } 86 + } 87 + </style>
+76
src/components/LikeStatsTable.svelte
··· 1 + <script lang="ts"> 2 + import type { LikeStat } from "../services/like_stats"; 3 + 4 + let { cssClass, header, users }: { cssClass: string, header: string, users: LikeStat[] } = $props(); 5 + </script> 6 + 7 + <table class="scan-result {cssClass}" style="display: table;"> 8 + <thead> 9 + <tr><th colspan="3">{header}</th></tr> 10 + </thead> 11 + <tbody> 12 + {#each users as user, i} 13 + <tr> 14 + <td class="no">{i + 1}</td> 15 + <td class="handle"><img class="avatar" alt="Avatar" src="{user.avatar}"> 16 + <a href="https://bsky.app/profile/{user.handle}" target="_blank">{user.handle}</a> 17 + </td> 18 + <td class="count">{user.count}</td> 19 + </tr> 20 + {/each} 21 + </tbody> 22 + </table> 23 + 24 + <style> 25 + .scan-result { 26 + border: 1px solid #333; 27 + border-collapse: collapse; 28 + display: none; 29 + float: left; 30 + margin-top: 20px; 31 + margin-bottom: 40px; 32 + } 33 + 34 + td, th { 35 + border: 1px solid #333; 36 + padding: 5px 10px; 37 + } 38 + 39 + th { 40 + text-align: center; 41 + background-color: hsl(207, 100%, 86%); 42 + padding: 12px 10px; 43 + } 44 + 45 + td.no { 46 + font-weight: bold; 47 + text-align: right; 48 + } 49 + 50 + td.handle { 51 + width: 280px; 52 + } 53 + 54 + td.count { 55 + padding: 5px 15px; 56 + } 57 + 58 + .avatar { 59 + width: 24px; 60 + height: 24px; 61 + border-radius: 14px; 62 + vertical-align: middle; 63 + margin-right: 2px; 64 + padding: 2px; 65 + } 66 + 67 + @media (prefers-color-scheme: dark) { 68 + .scan-result, td, th { 69 + border-color: #888; 70 + } 71 + 72 + th { 73 + background-color: hsl(207, 90%, 25%); 74 + } 75 + } 76 + </style>
+21
src/components/LoadableImage.svelte
··· 1 + <script lang="ts"> 2 + let { loading, error, ...props } = $props(); 3 + let imageState: string | undefined = $state(); 4 + 5 + function onload() { 6 + imageState = 'loaded'; 7 + } 8 + 9 + function onerror() { 10 + imageState = 'error'; 11 + } 12 + </script> 13 + 14 + {#if !imageState} 15 + {@render loading()} 16 + <img {...props} style="display: none" {onload} {onerror}> 17 + {:else if imageState == 'loaded'} 18 + <img {...props}> 19 + {:else} 20 + {@render error()} 21 + {/if}
+174
src/components/LoginDialog.svelte
··· 1 + <script lang="ts"> 2 + import { APIError } from '../api.js'; 3 + import { account } from '../models/account.svelte.js'; 4 + import DialogPanel from './DialogPanel.svelte'; 5 + 6 + type Props = { 7 + onLogin?: () => void; 8 + onClose?: () => void; 9 + showClose: boolean; 10 + } 11 + 12 + let { onClose = undefined, onLogin = undefined, showClose }: Props = $props(); 13 + 14 + let identifier: string = $state(''); 15 + let password: string = $state(''); 16 + let loginInfoVisible = $state(false); 17 + let submitting = $state(false); 18 + let loginField: HTMLInputElement; 19 + let passwordField: HTMLInputElement; 20 + 21 + function onOverlayClick() { 22 + if (showClose && onClose) { 23 + onClose(); 24 + } 25 + } 26 + 27 + function toggleLoginInfo(e: Event) { 28 + e.preventDefault(); 29 + loginInfoVisible = !loginInfoVisible; 30 + } 31 + 32 + async function onsubmit(e: Event) { 33 + e.preventDefault(); 34 + submitting = true; 35 + 36 + loginField.blur(); 37 + passwordField.blur(); 38 + 39 + try { 40 + await account.logIn(identifier.trim(), password.trim()); 41 + onLogin?.(); 42 + onClose?.(); 43 + } catch (error) { 44 + submitting = false; 45 + showError(error); 46 + } 47 + } 48 + 49 + function showError(error: Error) { 50 + console.log(error); 51 + 52 + if (error instanceof APIError && error.code == 401 && error.json.error == 'AuthFactorTokenRequired') { 53 + alert(`Please log in using an "app password" if you have 2FA enabled.`); 54 + } else { 55 + window.setTimeout(() => alert(error), 10); 56 + } 57 + } 58 + </script> 59 + 60 + <DialogPanel id="login" class={loginInfoVisible ? 'expanded' : ''} onClose={onOverlayClick}> 61 + <form method="get" {onsubmit}> 62 + {#if showClose} 63 + <i class="close fa-circle-xmark fa-regular" onclick={onClose}></i> 64 + {/if} 65 + 66 + <h2>๐ŸŒค Skythread</h2> 67 + 68 + <p><input type="text" id="login_handle" required autofocus placeholder="name.bsky.social" 69 + bind:value={identifier} bind:this={loginField}></p> 70 + 71 + <p><input type="password" id="login_password" required 72 + placeholder="&#x2731;&#x2731;&#x2731;&#x2731;&#x2731;&#x2731;&#x2731;&#x2731;" 73 + bind:value={password} bind:this={passwordField}></p> 74 + 75 + <p class="info"> 76 + <a href="#" onclick={toggleLoginInfo}><i class="fa-regular fa-circle-question"></i> Use an "app password" here</a> 77 + </p> 78 + 79 + {#if loginInfoVisible} 80 + <div class="info-box"> 81 + <p>Skythread doesn't support OAuth yet. For now, you need to use an "app password" here, which you can generate in the Bluesky app settings.</p> 82 + <p>The password you enter here is only passed to the Bluesky API (PDS) and isn't saved anywhere. The returned access token is only stored in your browser's local storage. You can see the complete source code of this app <a href="http://tangled.org/@mackuba.eu/skythread" target="_blank">on Tangled</a>.</p> 83 + </div> 84 + {/if} 85 + 86 + <p class="submit"> 87 + {#if !submitting} 88 + <input type="submit" value="Log in"> 89 + {:else} 90 + <i class="cloudy fa-solid fa-cloud fa-beat fa-xl"></i> 91 + {/if} 92 + </p> 93 + </form> 94 + </DialogPanel> 95 + 96 + <style> 97 + p.info { 98 + font-size: 9pt; 99 + } 100 + 101 + p.info a { 102 + color: #666; 103 + } 104 + 105 + .cloudy { 106 + color: hsl(210, 60%, 75%); 107 + margin: 14px 0px; 108 + } 109 + 110 + .info-box { 111 + border: 1px solid hsl(45, 100%, 60%); 112 + background-color: hsl(50, 100%, 96%); 113 + width: 360px; 114 + font-size: 11pt; 115 + border-radius: 6px; 116 + } 117 + 118 + .info-box p { 119 + margin: 15px 15px; 120 + text-align: left; 121 + } 122 + 123 + @media (prefers-color-scheme: dark) { 124 + :global(#login) { 125 + background-color: rgba(240, 240, 240, 0.15); 126 + } 127 + 128 + form { 129 + border-color: hsl(210, 20%, 40%); 130 + background-color: hsl(210, 12%, 25%); 131 + } 132 + 133 + .close { 134 + color: hsl(210, 20%, 50%); 135 + opacity: 0.6; 136 + } 137 + 138 + .close:hover { 139 + color: hsl(210, 20%, 50%); 140 + opacity: 1.0; 141 + } 142 + 143 + p.info a { 144 + color: #888; 145 + } 146 + 147 + input[type="text"], input[type="password"] { 148 + border-color: #666; 149 + } 150 + 151 + input[type="submit"] { 152 + border-color: hsl(210, 15%, 40%); 153 + background-color: hsl(210, 12%, 35%); 154 + } 155 + 156 + input[type="submit"]:active { 157 + border-color: hsl(210, 15%, 35%); 158 + background-color: hsl(210, 12%, 30%); 159 + } 160 + 161 + .cloudy { 162 + color: hsl(210, 60%, 75%); 163 + } 164 + 165 + .info-box { 166 + border-color: hsl(45, 100%, 45%); 167 + background-color: hsl(50, 40%, 30%); 168 + } 169 + 170 + .info-box a { 171 + color: hsl(45, 100%, 50%); 172 + } 173 + } 174 + </style>
+25
src/components/MainLoader.svelte
··· 1 + <div id="loader"> 2 + <img src="icons/sunny.png" alt="Loading..."> 3 + </div> 4 + 5 + <style> 6 + #loader { 7 + position: fixed; 8 + top: 0; 9 + bottom: 0; 10 + left: 0; 11 + right: 0; 12 + margin: auto; 13 + width: 36px; 14 + height: 36px; 15 + } 16 + 17 + #loader img { 18 + width: 36px; 19 + animation: rotation 3s infinite linear; 20 + } 21 + 22 + @media (prefers-color-scheme: dark) { 23 + #loader { filter: invert(); } 24 + } 25 + </style>
+156
src/components/PostingStatsTable.svelte
··· 1 + <script lang="ts"> 2 + import { type PostingStatsResult } from "../services/posting_stats"; 3 + 4 + export interface TableOptions { 5 + showReposts?: boolean, 6 + showPercentages?: boolean, 7 + showTotal?: boolean 8 + }; 9 + 10 + type Props = PostingStatsResult & TableOptions; 11 + 12 + let { users, sums, daysBack, showReposts = true, showPercentages = true, showTotal = true }: Props = $props(); 13 + 14 + function format(value: number): string { 15 + return (value > 0) ? value.toFixed(1) : 'โ€“'; 16 + } 17 + </script> 18 + 19 + <table class="scan-result"> 20 + <thead> 21 + <tr> 22 + <th>#</th> 23 + <th>Handle</th> 24 + 25 + {#if showReposts} 26 + <th>All posts /d</th> 27 + <th>Own posts /d</th> 28 + <th>Reposts /d</th> 29 + {:else} 30 + <th>Posts /d</th> 31 + {/if} 32 + 33 + {#if showPercentages} 34 + <th>% of timeline</th> 35 + {/if} 36 + </tr> 37 + </thead> 38 + <tbody> 39 + {#if showTotal} 40 + <tr class="total"> 41 + <td class="no"></td> 42 + <td class="handle">Total:</td> 43 + 44 + {#if showReposts} 45 + <td>{format(sums.all / daysBack)}</td> 46 + {/if} 47 + 48 + <td>{format(sums.own / daysBack)}</td> 49 + 50 + {#if showReposts} 51 + <td>{format(sums.reposts / daysBack)}</td> 52 + {/if} 53 + 54 + {#if showPercentages} 55 + <td class="percent"></td> 56 + {/if} 57 + </tr> 58 + {/if} 59 + 60 + {#each users as user, i} 61 + <tr> 62 + <td class="no">{i + 1}</td> 63 + <td class="handle"> 64 + <img class="avatar" alt="Avatar" src="{user.avatar}"> 65 + <a href="https://bsky.app/profile/{user.handle}" target="_blank">{user.handle}</a> 66 + </td> 67 + 68 + {#if showReposts} 69 + <td>{format(user.all / daysBack)}</td> 70 + {/if} 71 + 72 + <td>{format(user.own / daysBack)}</td> 73 + 74 + {#if showReposts} 75 + <td>{format(user.reposts / daysBack)}</td> 76 + {/if} 77 + 78 + {#if showPercentages} 79 + <td class="percent">{format(user.all * 100 / sums.all)}%</td> 80 + {/if} 81 + </tr> 82 + {/each} 83 + </tbody> 84 + </table> 85 + 86 + <style> 87 + .scan-result { 88 + border: 1px solid #333; 89 + border-collapse: collapse; 90 + } 91 + 92 + td, th { 93 + border: 1px solid #333; 94 + } 95 + 96 + td { 97 + text-align: right; 98 + padding: 5px 8px; 99 + } 100 + 101 + th { 102 + text-align: center; 103 + background-color: hsl(207, 100%, 86%); 104 + padding: 7px 10px; 105 + } 106 + 107 + td.handle { 108 + text-align: left; 109 + max-width: 450px; 110 + overflow: hidden; 111 + text-overflow: ellipsis; 112 + white-space: nowrap; 113 + } 114 + 115 + tr.total td { 116 + font-weight: bold; 117 + font-size: 11pt; 118 + background-color: hsla(207, 100%, 86%, 0.4); 119 + } 120 + 121 + tr.total td.handle { 122 + text-align: left; 123 + padding: 10px 12px; 124 + } 125 + 126 + .avatar { 127 + width: 24px; 128 + height: 24px; 129 + border-radius: 14px; 130 + vertical-align: middle; 131 + margin-right: 2px; 132 + padding: 2px; 133 + } 134 + 135 + td.no { 136 + font-weight: bold; 137 + } 138 + 139 + td.percent { 140 + min-width: 70px; 141 + } 142 + 143 + @media (prefers-color-scheme: dark) { 144 + .scan-result, td, th { 145 + border-color: #888; 146 + } 147 + 148 + th { 149 + background-color: hsl(207, 90%, 25%); 150 + } 151 + 152 + tr.total td { 153 + background-color: hsla(207, 90%, 25%, 0.4); 154 + } 155 + } 156 + </style>
+25
src/components/RichTextFromFacets.svelte
··· 1 + <script lang="ts"> 2 + import { RichText, type Facet } from '../../lib/rich_text_lite.js'; 3 + import { linkToHashtagPage } from '../router.js'; 4 + 5 + let { text, facets }: { text: string, facets: Facet[] } = $props(); 6 + 7 + let richText = $derived(new RichText({ text, facets })); 8 + let segments = $derived(richText.segments()); 9 + </script> 10 + 11 + {#each segments as segment} 12 + {#if segment.mention} 13 + <a href="https://bsky.app/profile/{segment.mention.did}">{segment.text}</a> 14 + {:else if segment.link} 15 + <a href="{segment.link.uri}">{segment.text}</a> 16 + {:else if segment.tag} 17 + <a href={linkToHashtagPage(segment.tag.tag)}>{segment.text}</a> 18 + {:else} 19 + {@const lines = segment.text.split("\n")} 20 + 21 + {#each lines as line, i} 22 + {#if i > 0}<br>{/if}{line} 23 + {/each} 24 + {/if} 25 + {/each}
+29
src/components/TangledLink.svelte
··· 1 + <div id="tangled"> 2 + <a href="https://tangled.org/mackuba.eu/skythread" target="_blank"> 3 + <img src="icons/tangled_dolly.svg" alt="Tangled"> 4 + </a> 5 + </div> 6 + 7 + <style> 8 + #tangled { 9 + position: fixed; 10 + bottom: 10px; 11 + right: 10px; 12 + z-index: 10; 13 + } 14 + 15 + img { 16 + width: 20px; 17 + opacity: 0.4; 18 + } 19 + 20 + a:hover img { 21 + opacity: 0.6; 22 + } 23 + 24 + @media (prefers-color-scheme: dark) { 25 + #tangled { 26 + filter: invert(); 27 + } 28 + } 29 + </style>
+280
src/components/UserAutocomplete.svelte
··· 1 + <script lang="ts"> 2 + import { api } from '../api.js'; 3 + 4 + export type AutocompleteUser = { 5 + did: string; 6 + handle: string; 7 + avatar?: string; 8 + displayName?: string; 9 + } 10 + 11 + let { selectedUsers = $bindable([]) }: { selectedUsers: AutocompleteUser[] } = $props(); 12 + 13 + let typedValue = $state(''); 14 + let autocompleteResults: AutocompleteUser[] = $state([]); 15 + let autocompleteIndex = $state(-1); 16 + 17 + let selectedUserDIDs: string[] = $derived(selectedUsers.map(u => u.did)); 18 + let autocompleteVisible = $derived(autocompleteResults.length > 0); 19 + let autocompleteVerticalOffset = $state(0); 20 + 21 + let autocompleteTimer: number | undefined; 22 + 23 + $effect(() => { 24 + let html = document.body.parentNode! 25 + html.addEventListener('click', hideAutocomplete); 26 + 27 + return () => { 28 + html.removeEventListener('click', hideAutocomplete); 29 + }; 30 + }); 31 + 32 + function onTextInput() { 33 + if (autocompleteTimer) { 34 + clearTimeout(autocompleteTimer); 35 + } 36 + 37 + let query = typedValue.trim(); 38 + 39 + if (query.length > 0) { 40 + autocompleteTimer = setTimeout(() => fetchAutocomplete(query), 100); 41 + } else { 42 + hideAutocomplete(); 43 + autocompleteTimer = undefined; 44 + } 45 + } 46 + 47 + function onKeyPress(e: KeyboardEvent) { 48 + if (e.key == 'Enter') { 49 + e.preventDefault(); 50 + 51 + if (autocompleteIndex >= 0) { 52 + selectUser(autocompleteIndex); 53 + } 54 + } else if (e.key == 'Escape') { 55 + hideAutocomplete(); 56 + } else if (e.key == 'ArrowDown' && autocompleteResults.length > 0) { 57 + e.preventDefault(); 58 + moveAutocomplete(+1); 59 + } else if (e.key == 'ArrowUp' && autocompleteResults.length > 0) { 60 + e.preventDefault(); 61 + moveAutocomplete(-1); 62 + } 63 + } 64 + 65 + async function fetchAutocomplete(query: string) { 66 + let users = await api.autocompleteUsers(query) as AutocompleteUser[]; 67 + 68 + let selectedDIDs = new Set(selectedUserDIDs); 69 + users = users.filter(u => !selectedDIDs.has(u.did)); 70 + 71 + if (users.length > 0) { 72 + autocompleteResults = users; 73 + autocompleteIndex = 0; 74 + } else { 75 + hideAutocomplete(); 76 + } 77 + } 78 + 79 + function hideAutocomplete() { 80 + autocompleteResults = []; 81 + autocompleteIndex = -1; 82 + } 83 + 84 + function moveAutocomplete(change: 1 | -1) { 85 + if (autocompleteResults.length == 0) { 86 + return; 87 + } 88 + 89 + let newIndex = autocompleteIndex + change; 90 + 91 + if (newIndex < 0) { 92 + newIndex = autocompleteResults.length - 1; 93 + } else if (newIndex >= autocompleteResults.length) { 94 + newIndex = 0; 95 + } 96 + 97 + autocompleteIndex = newIndex; 98 + } 99 + 100 + function selectAutocomplete(e: MouseEvent, index: number) { 101 + e.preventDefault(); 102 + selectUser(index); 103 + } 104 + 105 + function selectUser(index: number) { 106 + let user = autocompleteResults[index]; 107 + 108 + if (!user) { 109 + return; 110 + } 111 + 112 + selectedUsers.push(user); 113 + typedValue = ''; 114 + hideAutocomplete(); 115 + } 116 + 117 + function removeUser(e: MouseEvent, index: number) { 118 + e.preventDefault(); 119 + selectedUsers.splice(index, 1); 120 + } 121 + </script> 122 + 123 + <div class="user-choice"> 124 + <input type="text" placeholder="Add user" autocomplete="off" autofocus 125 + oninput={onTextInput} 126 + onkeydown={onKeyPress} 127 + bind:value={typedValue} 128 + bind:offsetHeight={autocompleteVerticalOffset}> 129 + 130 + {#if autocompleteVisible} 131 + <div class="autocomplete" 132 + style:display={autocompleteVisible ? 'block' : 'none'} 133 + style:top="{autocompleteVerticalOffset}px"> 134 + 135 + {#each autocompleteResults as user, i (user.did)} 136 + <div class="user-row" 137 + class:highlighted={autocompleteIndex == i} 138 + onmouseenter={() => { autocompleteIndex = i }} 139 + onmousedown={(e) => { selectAutocomplete(e, i) }}> 140 + {@render userRow(user)} 141 + </div> 142 + {/each} 143 + </div> 144 + {/if} 145 + 146 + <div class="selected-users"> 147 + {#each selectedUsers as user, i (user.did)} 148 + <div class="user-row"> 149 + {@render userRow(user)} 150 + <a class="remove" href="#" onclick={(e) => { removeUser(e, i) }}>โœ•</a> 151 + </div> 152 + {/each} 153 + </div> 154 + </div> 155 + 156 + {#snippet userRow(user: AutocompleteUser)} 157 + <img class="avatar" alt="Avatar" src={user.avatar}> 158 + <span class="name">{user.displayName || 'โ€“'}</span> 159 + <span class="handle">{user.handle}</span> 160 + {/snippet} 161 + 162 + <style> 163 + .user-choice { 164 + position: relative; 165 + } 166 + 167 + input { 168 + width: 260px; 169 + font-size: 11pt; 170 + } 171 + 172 + .autocomplete { 173 + position: absolute; 174 + left: 0; 175 + top: 0; 176 + margin-top: 4px; 177 + width: 350px; 178 + max-height: 250px; 179 + overflow-y: auto; 180 + background-color: white; 181 + border: 1px solid #ccc; 182 + z-index: 10; 183 + } 184 + 185 + .selected-users { 186 + width: 275px; 187 + height: 150px; 188 + overflow-y: auto; 189 + border: 1px solid #aaa; 190 + padding: 4px; 191 + margin-top: 20px; 192 + } 193 + 194 + .user-row { 195 + position: relative; 196 + padding: 2px 4px 2px 37px; 197 + cursor: pointer; 198 + } 199 + 200 + .user-row .avatar { 201 + position: absolute; 202 + left: 6px; 203 + top: 8px; 204 + width: 24px; 205 + border-radius: 12px; 206 + } 207 + 208 + .user-row span { 209 + display: block; 210 + overflow-x: hidden; 211 + text-overflow: ellipsis; 212 + } 213 + 214 + .user-row .name { 215 + font-size: 11pt; 216 + margin-top: 1px; 217 + margin-bottom: 1px; 218 + } 219 + 220 + .user-row .handle { 221 + font-size: 10pt; 222 + margin-bottom: 2px; 223 + color: #666; 224 + } 225 + 226 + .autocomplete .user-row { 227 + cursor: pointer; 228 + } 229 + 230 + .autocomplete .user-row.highlighted { 231 + background-color: hsl(207, 100%, 85%); 232 + } 233 + 234 + .selected-users .user-row span { 235 + padding-right: 14px; 236 + } 237 + 238 + .selected-users .user-row .remove { 239 + position: absolute; 240 + right: 4px; 241 + top: 11px; 242 + padding: 0px 4px; 243 + color: #333; 244 + line-height: 17px; 245 + } 246 + 247 + .selected-users .user-row .remove:hover { 248 + text-decoration: none; 249 + background-color: #ddd; 250 + border-radius: 8px; 251 + } 252 + 253 + @media (prefers-color-scheme: dark) { 254 + .autocomplete { 255 + background-color: hsl(210, 5%, 18%); 256 + border-color: #4b4b4b; 257 + } 258 + 259 + .selected-users { 260 + border-color: #666; 261 + } 262 + 263 + .user-row .handle { 264 + color: #888; 265 + } 266 + 267 + .autocomplete .user-row.highlighted { 268 + background-color: hsl(207, 90%, 25%); 269 + } 270 + 271 + .selected-users .user-row .remove { 272 + color: #aaa; 273 + } 274 + 275 + .selected-users .user-row .remove:hover { 276 + background-color: #555; 277 + color: #bbb; 278 + } 279 + } 280 + </style>
+164
src/components/embeds/EmbedComponent.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + Embed, RawRecordEmbed, RawRecordWithMediaEmbed, RawImageEmbed, RawLinkEmbed, RawVideoEmbed, 4 + InlineRecordEmbed, InlineRecordWithMediaEmbed, InlineImageEmbed, InlineLinkEmbed, InlineVideoEmbed 5 + } from '../../models/embeds.js'; 6 + 7 + import EmbedComponent from './EmbedComponent.svelte'; 8 + import ImagesComponent from './ImagesComponent.svelte'; 9 + import LinkComponent from './LinkComponent.svelte'; 10 + import QuoteComponent from './QuoteComponent.svelte'; 11 + import VideoComponent from './VideoComponent.svelte'; 12 + 13 + let { embed }: { embed: Embed } = $props(); 14 + </script> 15 + 16 + <div class="embed"> 17 + {#if embed instanceof RawRecordEmbed || embed instanceof InlineRecordEmbed} 18 + <QuoteComponent record={embed.record} /> 19 + 20 + {:else if embed instanceof RawRecordWithMediaEmbed || embed instanceof InlineRecordWithMediaEmbed} 21 + <div> 22 + <EmbedComponent embed={embed.media} /> 23 + <QuoteComponent record={embed.record} /> 24 + </div> 25 + 26 + {:else if embed instanceof RawImageEmbed || embed instanceof InlineImageEmbed} 27 + <ImagesComponent {embed} /> 28 + 29 + {:else if embed instanceof RawLinkEmbed || embed instanceof InlineLinkEmbed} 30 + <LinkComponent {embed} /> 31 + 32 + {:else if embed instanceof RawVideoEmbed || embed instanceof InlineVideoEmbed} 33 + <VideoComponent {embed} /> 34 + 35 + {:else} 36 + <p>[{embed.type}]</p> 37 + {/if} 38 + </div> 39 + 40 + <style> 41 + .embed :global { 42 + a.link-card { 43 + display: block; 44 + position: relative; 45 + max-width: 500px; 46 + margin-bottom: 12px; 47 + } 48 + 49 + a.link-card:hover { 50 + text-decoration: none; 51 + } 52 + 53 + a.link-card > div { 54 + background-color: #fcfcfd; 55 + border: 1px solid #d8d8d8; 56 + border-radius: 8px; 57 + padding: 11px 15px; 58 + } 59 + 60 + a.link-card:hover > div { 61 + background-color: #f6f7f8; 62 + border: 1px solid #c8c8c8; 63 + } 64 + 65 + a.link-card > div:not(:has(p.description)) { 66 + padding-bottom: 14px; 67 + } 68 + 69 + a.link-card p.domain { 70 + color: #888; 71 + font-size: 10pt; 72 + margin-top: 1px; 73 + margin-bottom: 5px; 74 + } 75 + 76 + a.link-card h2 { 77 + font-size: 12pt; 78 + color: #333; 79 + margin-top: 8px; 80 + margin-bottom: 0; 81 + } 82 + 83 + a.link-card p.description { 84 + color: #666; 85 + font-size: 11pt; 86 + margin-top: 8px; 87 + margin-bottom: 4px; 88 + line-height: 135%; 89 + white-space: pre-line; 90 + } 91 + 92 + a.link-card.record > div:has(.avatar) { 93 + padding-left: 65px; 94 + } 95 + 96 + a.link-card.record h2 { 97 + margin-top: 3px; 98 + } 99 + 100 + a.link-card.record .handle { 101 + color: #666; 102 + margin-left: 1px; 103 + font-weight: normal; 104 + font-size: 11pt; 105 + vertical-align: text-top; 106 + } 107 + 108 + a.link-card.record .avatar { 109 + width: 36px; 110 + height: 36px; 111 + border: 1px solid #ddd; 112 + border-radius: 6px; 113 + position: absolute; 114 + top: 15px; 115 + left: 15px; 116 + } 117 + 118 + a.link-card.record .stats { 119 + margin-top: 9px; 120 + margin-bottom: 1px; 121 + font-size: 10pt; 122 + color: #666; 123 + } 124 + 125 + a.link-card.record .stats i.fa-heart { 126 + font-size: 9pt; 127 + color: #aaa; 128 + } 129 + } 130 + 131 + @media (prefers-color-scheme: dark) { 132 + .embed :global { 133 + a.link-card > div { 134 + background-color: #303030; 135 + border-color: #606060; 136 + } 137 + 138 + a.link-card:hover > div { 139 + background-color: #383838; 140 + border-color: #707070; 141 + } 142 + 143 + a.link-card p.domain { 144 + color: #666; 145 + } 146 + 147 + a.link-card h2 { 148 + color: #ccc; 149 + } 150 + 151 + a.link-card p.description { 152 + color: #888; 153 + } 154 + 155 + a.link-card.record .handle { 156 + color: #666; 157 + } 158 + 159 + a.link-card.record .avatar { 160 + border-color: #888; 161 + } 162 + } 163 + } 164 + </style>
+29
src/components/embeds/FeedGeneratorView.svelte
··· 1 + <script lang="ts"> 2 + import { atURI } from '../../utils.js'; 3 + import { FeedGeneratorRecord } from '../../models/records.js'; 4 + 5 + let { feed }: { feed: FeedGeneratorRecord } = $props(); 6 + 7 + function linkToFeed(feed: FeedGeneratorRecord) { 8 + let { repo, rkey } = atURI(feed.uri); 9 + return `https://bsky.app/profile/${repo}/feed/${rkey}`; 10 + } 11 + </script> 12 + 13 + <a class="link-card record" href={linkToFeed(feed)} target="_blank"> 14 + <div> 15 + {#if feed.avatar} 16 + <img class="avatar" alt="Avatar" src={feed.avatar}> 17 + {/if} 18 + 19 + <h2>{feed.title} <span class="handle">โ€ข Feed by @{feed.author.handle}</span></h2> 20 + 21 + {#if feed.description} 22 + <p class="description">{feed.description}</p> 23 + {/if} 24 + 25 + <p class="stats"> 26 + <i class="fa-solid fa-heart"></i> <output>{feed.likeCount}</output> 27 + </p> 28 + </div> 29 + </a>
+46
src/components/embeds/GIFPlayer.svelte
··· 1 + <script lang="ts"> 2 + let { gifURL, staticURL, alt }: { gifURL: string, staticURL: string, alt: string | undefined } = $props(); 3 + 4 + let loaded = $state(false); 5 + let paused = $state(false); 6 + 7 + let maxWidth = $state(500); 8 + let maxHeight = $state(200); 9 + 10 + function onload(e: Event) { 11 + let img = e.target as HTMLImageElement; 12 + 13 + if (img.naturalWidth < img.naturalHeight) { 14 + maxWidth = 200; 15 + maxHeight = 400; 16 + } 17 + 18 + loaded = true; 19 + } 20 + 21 + function onclick() { 22 + paused = !paused; 23 + } 24 + </script> 25 + 26 + <div class="gif"> 27 + <img src={paused ? staticURL : gifURL} 28 + class={paused ? 'static' : ''} 29 + alt={alt ? `Gif: ${alt}` : `Gif animation`} 30 + {onload} 31 + {onclick} 32 + style:opacity={loaded ? 1 : 0} 33 + style:max-width="{maxWidth}px" 34 + style:max-height="{maxHeight}px"> 35 + </div> 36 + 37 + <style> 38 + .gif img { 39 + user-select: none; 40 + -webkit-user-select: none; 41 + } 42 + 43 + .gif img.static { 44 + opacity: 0.75; 45 + } 46 + </style>
+50
src/components/embeds/ImagesComponent.svelte
··· 1 + <script lang="ts"> 2 + import { getPostContext } from '../posts/PostComponent.svelte'; 3 + import { InlineImageEmbed, RawImageEmbed } from '../../models/embeds'; 4 + 5 + let { embed }: { embed: InlineImageEmbed | RawImageEmbed } = $props(); 6 + let { post } = getPostContext(); 7 + 8 + function imageURL(img: json): string { 9 + if (img.fullsize) { 10 + return img.fullsize; 11 + } else { 12 + let cid = img.image.ref['$link']; 13 + return `https://cdn.bsky.app/img/feed_fullsize/plain/${post.author.did}/${cid}@jpeg`; 14 + } 15 + } 16 + </script> 17 + 18 + <div> 19 + {#each embed.images as image} 20 + <p>[<a href={imageURL(image)}>Image</a>]</p> 21 + 22 + {#if image.alt} 23 + <details class="image-alt"> 24 + <summary>Show alt</summary> 25 + {image.alt} 26 + </details> 27 + {/if} 28 + {/each} 29 + </div> 30 + 31 + <style> 32 + .image-alt { 33 + font-size: 11pt; 34 + color: #666; 35 + margin-bottom: 20px; 36 + } 37 + 38 + .image-alt summary { 39 + font-size: 11pt; 40 + color: #666; 41 + margin-bottom: 5px; 42 + user-select: none; 43 + -webkit-user-select: none; 44 + cursor: default; 45 + } 46 + 47 + @media (prefers-color-scheme: dark) { 48 + .image-alt, .image-alt summary { color: #999; } 49 + } 50 + </style>
+49
src/components/embeds/LinkComponent.svelte
··· 1 + <script lang="ts"> 2 + import { getPostContext } from '../posts/PostComponent.svelte'; 3 + import { isValidURL, truncateText } from '../../utils.js'; 4 + import GIFPlayer from './GIFPlayer.svelte'; 5 + import { InlineLinkEmbed, RawLinkEmbed } from '../../models/embeds.js'; 6 + 7 + let { embed }: { embed: InlineLinkEmbed | RawLinkEmbed } = $props(); 8 + let { post } = getPostContext(); 9 + 10 + let showingGIF = $state(false); 11 + 12 + let hostname = $derived(new URL(embed.url).hostname); 13 + let isTenorGIF = $derived(hostname == 'media.tenor.com'); 14 + let onclick = $derived(isTenorGIF ? playGIF : undefined); 15 + 16 + function playGIF(e: Event) { 17 + e.preventDefault(); 18 + showingGIF = true; 19 + } 20 + 21 + function thumbnailURL() { 22 + if (typeof embed.thumb == 'string') { 23 + return embed.thumb; 24 + } else { 25 + return `https://cdn.bsky.app/img/avatar/feed_thumbnail/${post.author.did}/${embed.thumb.ref.$link}@jpeg`; 26 + } 27 + } 28 + </script> 29 + 30 + {#if showingGIF} 31 + <GIFPlayer gifURL={embed.url} staticURL={thumbnailURL()} alt={embed.title} /> 32 + {:else} 33 + {#if isValidURL(embed.url)} 34 + <a class="link-card" href={embed.url} target="_blank" {onclick}> 35 + <div> 36 + <p class="domain">{hostname}</p> 37 + <h2>{embed.title || embed.url}</h2> 38 + 39 + {#if embed.description} 40 + <p class="description">{truncateText(embed.description, 300)}</p> 41 + {/if} 42 + </div> 43 + </a> 44 + {:else} 45 + <p> 46 + [Link: <a href={embed.url}>{embed.title || embed.url}</a>] 47 + </p> 48 + {/if} 49 + {/if}
+109
src/components/embeds/QuoteComponent.svelte
··· 1 + <script lang="ts"> 2 + import { api } from '../../api.js'; 3 + import { getPostContext } from '../posts/PostComponent.svelte'; 4 + import { BasePost, Post, MissingPost } from '../../models/posts.js'; 5 + import { InlineRecordEmbed, InlineRecordWithMediaEmbed } from '../../models/embeds.js'; 6 + import { ATProtoRecord, FeedGeneratorRecord, StarterPackRecord, UserListRecord } from '../../models/records.js'; 7 + import { atURI } from '../../utils.js'; 8 + 9 + import FeedGeneratorView from '../embeds/FeedGeneratorView.svelte'; 10 + import PostWrapper from '../posts/PostWrapper.svelte'; 11 + import StarterPackView from '../embeds/StarterPackView.svelte'; 12 + import UserListView from '../embeds/UserListView.svelte'; 13 + 14 + let { record }: { record: ATProtoRecord } = $props(); 15 + let { post } = getPostContext(); 16 + 17 + async function loadQuotedRecord(): Promise<ATProtoRecord> { 18 + let { collection } = atURI(record.uri); 19 + 20 + if (collection == 'app.bsky.feed.post') { 21 + let reloaded = await api.loadPostIfExists(record.uri); 22 + 23 + if (reloaded) { 24 + return new Post(reloaded); 25 + } else { 26 + return new MissingPost(post.data); 27 + } 28 + } else { 29 + let reloadedPost = await api.loadPostIfExists(post.uri).then(x => x && new Post(x)); 30 + let newEmbed = reloadedPost?.embed; 31 + 32 + if (newEmbed instanceof InlineRecordEmbed || newEmbed instanceof InlineRecordWithMediaEmbed) { 33 + return newEmbed.record; 34 + } else { 35 + return new MissingPost(record); 36 + } 37 + } 38 + } 39 + </script> 40 + 41 + {#if record.constructor === ATProtoRecord && !record.type} 42 + {#await loadQuotedRecord()} 43 + <div class="quote-embed"> 44 + <p class="post placeholder">Loading quoted post...</p> 45 + </div> 46 + {:then record} 47 + {@render quoteContent(record)} 48 + {:catch} 49 + <div class="quote-embed"> 50 + <p class="post placeholder">Error loading quoted post</p> 51 + </div> 52 + {/await} 53 + {:else} 54 + {@render quoteContent(record)} 55 + {/if} 56 + 57 + {#snippet quoteContent(record: ATProtoRecord)} 58 + {#if record instanceof BasePost} 59 + <div class="quote-embed"> 60 + <PostWrapper post={record} placement="quote" /> 61 + </div> 62 + 63 + {:else if record instanceof FeedGeneratorRecord} 64 + <FeedGeneratorView feed={record} /> 65 + 66 + {:else if record instanceof StarterPackRecord} 67 + <StarterPackView starterPack={record} /> 68 + 69 + {:else if record instanceof UserListRecord} 70 + <UserListView list={record} /> 71 + 72 + {:else} 73 + <div class="quote-embed"> 74 + <p>[{record.type}]</p> 75 + </div> 76 + {/if} 77 + {/snippet} 78 + 79 + <style> 80 + .quote-embed { 81 + border: 1px solid #ddd; 82 + border-radius: 8px; 83 + background-color: #fbfcfd; 84 + margin-top: 25px; 85 + margin-bottom: 15px; 86 + margin-left: 0px; 87 + max-width: 800px; 88 + } 89 + 90 + .quote-embed :global(.post) { 91 + margin-top: 16px; 92 + padding-left: 16px; 93 + padding-right: 16px; 94 + padding-bottom: 5px; 95 + } 96 + 97 + .placeholder { 98 + font-style: italic; 99 + font-size: 11pt; 100 + color: #888; 101 + } 102 + 103 + @media (prefers-color-scheme: dark) { 104 + .quote-embed { 105 + background-color: #303030; 106 + border-color: #606060; 107 + } 108 + } 109 + </style>
+21
src/components/embeds/StarterPackView.svelte
··· 1 + <script lang="ts"> 2 + import { atURI } from '../../utils.js'; 3 + import { StarterPackRecord } from '../../models/records.js'; 4 + 5 + let { starterPack }: { starterPack: StarterPackRecord } = $props(); 6 + 7 + function linkToStarterPack(starterPack: StarterPackRecord) { 8 + let { repo, rkey } = atURI(starterPack.uri); 9 + return `https://bsky.app/starter-pack/${repo}/${rkey}`; 10 + } 11 + </script> 12 + 13 + <a class="link-card record" href={linkToStarterPack(starterPack)} target="_blank"> 14 + <div> 15 + <h2>{starterPack.title} <span class="handle">โ€ข Starter pack by @{starterPack.author.handle}</span></h2> 16 + 17 + {#if starterPack.description} 18 + <p class="description">{starterPack.description}</p> 19 + {/if} 20 + </div> 21 + </a>
+36
src/components/embeds/UserListView.svelte
··· 1 + <script lang="ts"> 2 + import { atURI } from '../../utils.js'; 3 + import { UserListRecord } from '../../models/records.js'; 4 + 5 + let { list }: { list: UserListRecord } = $props(); 6 + 7 + function linkToList(list: UserListRecord) { 8 + let { repo, rkey } = atURI(list.uri); 9 + return `https://bsky.app/profile/${repo}/lists/${rkey}`; 10 + } 11 + 12 + function listType(list: UserListRecord) { 13 + switch (list.purpose) { 14 + case 'app.bsky.graph.defs#curatelist': 15 + return "User list"; 16 + case 'app.bsky.graph.defs#modlist': 17 + return "Mute list"; 18 + default: 19 + return "List"; 20 + } 21 + } 22 + </script> 23 + 24 + <a class="link-card record" href={linkToList(list)} target="_blank"> 25 + <div> 26 + {#if list.avatar} 27 + <img class="avatar" alt="Avatar" src={list.avatar}> 28 + {/if} 29 + 30 + <h2>{list.title} <span class="handle">โ€ข {listType(list)} by @{list.author.handle}</span></h2> 31 + 32 + {#if list.description} 33 + <p class="description">{list.description}</p> 34 + {/if} 35 + </div> 36 + </a>
+27
src/components/embeds/VideoComponent.svelte
··· 1 + <script lang="ts"> 2 + import { getPostContext } from '../posts/PostComponent.svelte'; 3 + import { InlineVideoEmbed, RawVideoEmbed } from '../../models/embeds'; 4 + 5 + let { embed }: { embed: InlineVideoEmbed | RawVideoEmbed } = $props(); 6 + let { post } = getPostContext(); 7 + 8 + function videoURL(embed: InlineVideoEmbed | RawVideoEmbed) { 9 + if (embed instanceof InlineVideoEmbed) { 10 + return embed.playlistURL; 11 + } else { 12 + let cid = embed.video.ref['$link']; 13 + return `https://video.bsky.app/watch/${post.author.did}/${cid}/playlist.m3u8`; 14 + } 15 + } 16 + </script> 17 + 18 + <div> 19 + <p>[<a href={videoURL(embed)}>Video</a>]</p> 20 + 21 + {#if embed.alt} 22 + <details class="image-alt"> 23 + <summary>Show alt</summary> 24 + {embed.alt} 25 + </details> 26 + {/if} 27 + </div>
+22
src/components/posts/BlockedPostContent.svelte
··· 1 + <script lang="ts"> 2 + import { setPostContext } from './PostComponent.svelte'; 3 + import { Post } from '../../models/posts.js'; 4 + 5 + import EmbedComponent from '../embeds/EmbedComponent.svelte'; 6 + import PostBody from './PostBody.svelte'; 7 + import ThreadRootParentRaw from './ThreadRootParentRaw.svelte'; 8 + 9 + let { post, placement }: { post: Post, placement: PostPlacement } = $props(); 10 + 11 + setPostContext({ post, placement }); 12 + </script> 13 + 14 + {#if post.isPageRoot && post.parentReference} 15 + <ThreadRootParentRaw uri={post.parentReference.uri} /> 16 + {/if} 17 + 18 + <PostBody /> 19 + 20 + {#if post.embed} 21 + <EmbedComponent embed={post.embed} /> 22 + {/if}
+96
src/components/posts/BlockedPostView.svelte
··· 1 + <script lang="ts"> 2 + import { api } from '../../api.js'; 3 + import { BlockedPost, DetachedQuotePost, MissingPost, Post } from '../../models/posts.js'; 4 + import { settings } from '../../models/settings.svelte.js'; 5 + 6 + import BlockedPostContent from './BlockedPostContent.svelte'; 7 + import MissingPostView from './MissingPostView.svelte'; 8 + import PostSubtreeLink from './PostSubtreeLink.svelte'; 9 + import ReferencedPostAuthorLink from './ReferencedPostAuthorLink.svelte'; 10 + 11 + type Props = { 12 + reason: string; 13 + post: BlockedPost | DetachedQuotePost; 14 + placement: PostPlacement; 15 + } 16 + 17 + let { reason, post, placement }: Props = $props(); 18 + 19 + let biohazardEnabled = $derived(settings.biohazardsEnabled !== false); 20 + let loading = $state(false); 21 + let postNotFound = $state(false); 22 + let reloadedPost: Post | undefined = $state(); 23 + 24 + async function loadPost(e: Event) { 25 + e.preventDefault(); 26 + loading = true; 27 + 28 + let result = await api.reloadBlockedPost(post.uri); 29 + 30 + if (result) { 31 + reloadedPost = result; 32 + } else { 33 + postNotFound = true; 34 + } 35 + } 36 + 37 + function canShowLoadThreadLink(reloadedPost: Post) { 38 + let viewerInfo = reloadedPost.author.viewer; 39 + 40 + if (viewerInfo) { 41 + // don't show the link if author is blocked/blocking us, since full thread won't load anyway 42 + return !(viewerInfo.blockedBy || viewerInfo.blocking); 43 + } else { 44 + // in incognito mode there will be no author viewer info - but in this case we can always load the thread 45 + return true; 46 + } 47 + } 48 + 49 + function blockStatus() { 50 + if (post instanceof DetachedQuotePost) { 51 + return undefined; 52 + } else if (post.blockedByUser) { 53 + return "has blocked you"; 54 + } else if (post.blocksUser) { 55 + return "you've blocked them"; 56 + } else { 57 + return undefined; 58 + } 59 + } 60 + </script> 61 + 62 + {#if !postNotFound && !reloadedPost} 63 + <p class="blocked-header"> 64 + <i class="fa-solid fa-ban"></i> <span>{reason}</span> 65 + 66 + {#if biohazardEnabled} 67 + <ReferencedPostAuthorLink {post} status={blockStatus()} /> 68 + {/if} 69 + </p> 70 + 71 + {#if biohazardEnabled} 72 + <p class="load-post"> 73 + {#if !loading} 74 + <a href="#" onclick={loadPost}>Load postโ€ฆ</a> 75 + {:else} 76 + &nbsp; 77 + {/if} 78 + </p> 79 + {/if} 80 + {:else if reloadedPost} 81 + <p class="blocked-header"> 82 + <i class="fa-solid fa-ban"></i> <span>{reason}</span> 83 + 84 + <ReferencedPostAuthorLink {post} status={blockStatus()} /> 85 + 86 + {#if canShowLoadThreadLink(reloadedPost)} 87 + <span class="separator">&bull;</span> 88 + 89 + <PostSubtreeLink post={reloadedPost} title="Load thread" /> 90 + {/if} 91 + </p> 92 + 93 + <BlockedPostContent post={reloadedPost} {placement} /> 94 + {:else} 95 + <MissingPostView post={new MissingPost(post.data)} /> 96 + {/if}
+58
src/components/posts/EdgeMargin.svelte
··· 1 + <script lang="ts"> 2 + let { collapsed = $bindable(false) }: { collapsed: boolean } = $props(); 3 + 4 + function toggleFold() { 5 + collapsed = !collapsed; 6 + } 7 + </script> 8 + 9 + <div class="margin"> 10 + <div class="edge" onclick={toggleFold}> 11 + <div class="line"></div> 12 + </div> 13 + 14 + <img class="plus" alt="{collapsed ? '+' : '-'}" src="icons/{collapsed ? 'add-square.png' : 'subtract-square.png'}" onclick={toggleFold}> 15 + </div> 16 + 17 + <style> 18 + .edge { 19 + position: absolute; 20 + left: -2px; 21 + top: 30px; 22 + bottom: 0px; 23 + width: 6px; 24 + } 25 + 26 + .line { 27 + position: absolute; 28 + left: 2px; 29 + top: 0px; 30 + bottom: 0px; 31 + border-left: 1px solid #aaa; 32 + } 33 + 34 + .edge:hover .line { 35 + border-left: 2px solid #888; 36 + } 37 + 38 + .plus { 39 + position: absolute; 40 + top: 8px; 41 + left: -6px; 42 + width: 14px; 43 + } 44 + 45 + :global(.post.collapsed) .line { 46 + display: none; 47 + } 48 + 49 + :global(.post.flat) > .margin { 50 + display: none; 51 + } 52 + 53 + @media (prefers-color-scheme: dark) { 54 + .line { border-left-color: #666; } 55 + .edge:hover .line { border-left-color: #888; } 56 + .plus { filter: invert(); } 57 + } 58 + </style>
+52
src/components/posts/FediSourceLink.svelte
··· 1 + <script lang="ts"> 2 + let { url }: { url: string } = $props(); 3 + 4 + let hostname = $derived(new URL(url).hostname); 5 + </script> 6 + 7 + <a class="fedi-link" href={url} target="_blank"> 8 + <div> 9 + <i class="fa-solid fa-arrow-up-right-from-square fa-sm"></i> View on {hostname} 10 + </div> 11 + </a> 12 + 13 + <style> 14 + .fedi-link { 15 + display: inline-block; 16 + margin-bottom: 6px; 17 + margin-top: 2px; 18 + } 19 + 20 + .fedi-link:hover { 21 + text-decoration: none; 22 + } 23 + 24 + div { 25 + border: 1px solid #d0d0d0; 26 + border-radius: 8px; 27 + padding: 5px 9px; 28 + color: #555; 29 + font-size: 10pt; 30 + } 31 + 32 + i { 33 + margin-right: 3px; 34 + } 35 + 36 + .fedi-link:hover div { 37 + background-color: #f6f7f8; 38 + border: 1px solid #c8c8c8; 39 + } 40 + 41 + @media (prefers-color-scheme: dark) { 42 + div { 43 + border-color: #606060; 44 + color: #909090; 45 + } 46 + 47 + .fedi-link:hover div { 48 + background-color: #444; 49 + border-color: #909090; 50 + } 51 + } 52 + </style>
+24
src/components/posts/FeedPostParent.svelte
··· 1 + <script lang="ts"> 2 + import { accountAPI, api } from '../../api.js'; 3 + import { linkToPostById } from '../../router.js'; 4 + import { atURI } from '../../utils.js'; 5 + 6 + let { uri }: { uri: string } = $props(); 7 + let { repo, rkey } = $derived(atURI(uri)); 8 + </script> 9 + 10 + <p class="back"> 11 + <i class="fa-solid fa-reply"></i> 12 + 13 + {#if accountAPI && repo == accountAPI.user.did} 14 + <a href="{linkToPostById(repo, rkey)}">Reply to you</a> 15 + {:else} 16 + {#await api.fetchHandleForDid(repo)} 17 + <a href="{linkToPostById(repo, rkey)}">Reply</a> 18 + {:then handle} 19 + <a href="{linkToPostById(handle, rkey)}">Reply to @{handle}</a> 20 + {:catch} 21 + <a href="{linkToPostById(repo, rkey)}">Reply to {repo}</a> 22 + {/await} 23 + {/if} 24 + </p>
+63
src/components/posts/HiddenRepliesLink.svelte
··· 1 + <script lang="ts"> 2 + import { api } from '../../api.js'; 3 + import { showBiohazardDialog } from '../Dialogs.svelte'; 4 + import { settings } from '../../models/settings.svelte.js'; 5 + import { parseThreadPost } from '../../models/posts.js'; 6 + import { linkToPostThread } from '../../router.js'; 7 + import { getPostContext } from './PostComponent.svelte'; 8 + 9 + type Props = { 10 + onLoad: (posts: (AnyPost | null)[]) => void, 11 + onError: (error: Error) => void 12 + } 13 + 14 + let { onLoad, onError }: Props = $props(); 15 + let { post } = getPostContext(); 16 + let loading = $state(false); 17 + 18 + function onLinkClick(e: Event) { 19 + e.preventDefault(); 20 + 21 + if (settings.biohazardsEnabled === true) { 22 + loadHiddenReplies(); 23 + } else { 24 + showBiohazardDialog(() => { 25 + loadHiddenReplies(); 26 + }); 27 + } 28 + } 29 + 30 + async function loadHiddenReplies() { 31 + loading = true; 32 + 33 + try { 34 + let repliesData = await api.loadHiddenReplies(post); 35 + let replies = repliesData.map(x => x && parseThreadPost(x.thread, post.pageRoot, 1, post.absoluteLevel + 1)); 36 + loading = false; 37 + onLoad(replies); 38 + } catch (error) { 39 + loading = false; 40 + onError(error); 41 + } 42 + } 43 + </script> 44 + 45 + <p class="hidden-replies"> 46 + {#if !loading} 47 + โ˜ฃ๏ธ <a href={linkToPostThread(post)} onclick={onLinkClick}>Load hidden repliesโ€ฆ</a> 48 + {:else} 49 + <img class="loader" src="icons/sunny.png" alt="Loading..."> 50 + {/if} 51 + </p> 52 + 53 + <style> 54 + .hidden-replies { 55 + margin-top: 20px; 56 + font-size: 11pt; 57 + } 58 + 59 + .hidden-replies a { 60 + font-size: 12pt; 61 + color: saddlebrown; 62 + } 63 + </style>
+45
src/components/posts/LoadMoreLink.svelte
··· 1 + <script lang="ts"> 2 + import { api } from '../../api.js'; 3 + import { Post, parseThreadPost } from '../../models/posts.js'; 4 + import { linkToPostThread } from '../../router.js'; 5 + import { getPostContext } from './PostComponent.svelte'; 6 + 7 + type Props = { 8 + onLoad: (root: Post) => void, 9 + onError: (error: Error) => void 10 + } 11 + 12 + let { onLoad, onError }: Props = $props(); 13 + let { post } = getPostContext(); 14 + let loading = $state(false); 15 + 16 + async function onLinkClick(e: Event) { 17 + e.preventDefault(); 18 + loading = true; 19 + 20 + try { 21 + let json = await api.loadThreadByAtURI(post.uri); 22 + let root = parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel); 23 + 24 + loading = false; 25 + 26 + if (root instanceof Post) { 27 + window.subtreeRoot = root; 28 + onLoad(root); 29 + } else { 30 + onError(new Error('Post is not available')); 31 + } 32 + } catch (error) { 33 + loading = false; 34 + onError(error); 35 + } 36 + } 37 + </script> 38 + 39 + <p> 40 + {#if !loading} 41 + <a href={linkToPostThread(post)} onclick={onLinkClick}>Load more repliesโ€ฆ</a> 42 + {:else} 43 + <img class="loader" src="icons/sunny.png" alt="Loading..."> 44 + {/if} 45 + </p>
+11
src/components/posts/MissingPostView.svelte
··· 1 + <script lang="ts"> 2 + import { MissingPost } from '../../models/posts'; 3 + import ReferencedPostAuthorLink from './ReferencedPostAuthorLink.svelte'; 4 + 5 + let { post }: { post: MissingPost } = $props(); 6 + </script> 7 + 8 + <p class="blocked-header"> 9 + <i class="fa-solid fa-ban"></i> <span>Deleted post</span> 10 + <ReferencedPostAuthorLink {post} /> 11 + </p>
+78
src/components/posts/PostBody.svelte
··· 1 + <script lang="ts"> 2 + import { getPostContext } from './PostComponent.svelte'; 3 + import { sanitizeHTML } from '../../utils.js'; 4 + import { type Facet } from '../../../lib/rich_text_lite.js'; 5 + import RichTextFromFacets from '../RichTextFromFacets.svelte'; 6 + 7 + const highlightID = 'search-results'; 8 + 9 + let { post } = getPostContext(); 10 + let { highlightedMatches = undefined }: { highlightedMatches?: string[] | undefined } = $props(); 11 + 12 + let bodyElement: HTMLElement | undefined = $state(); 13 + 14 + function highlightSearchResults(terms: string[]) { 15 + let regexp = new RegExp(`\\b(${terms.join('|')})\\b`, 'gi'); 16 + let walker = document.createTreeWalker(bodyElement!, NodeFilter.SHOW_TEXT); 17 + let ranges: Range[] = []; 18 + 19 + while (walker.nextNode()) { 20 + let node = walker.currentNode; 21 + if (!node.textContent) { continue; } 22 + 23 + regexp.lastIndex = 0; 24 + 25 + for (;;) { 26 + let match = regexp.exec(node.textContent); 27 + if (match === null) break; 28 + 29 + let range = new Range(); 30 + range.setStart(node, match.index); 31 + range.setEnd(node, match.index + match[0].length); 32 + ranges.push(range); 33 + } 34 + } 35 + 36 + let highlight = CSS.highlights.get(highlightID) || new Highlight(); 37 + ranges.forEach(r => highlight.add(r)); 38 + CSS.highlights.set(highlightID, highlight); 39 + } 40 + 41 + $effect(() => { 42 + if (highlightedMatches && highlightedMatches.length > 0) { 43 + highlightSearchResults(highlightedMatches); 44 + 45 + return () => { 46 + CSS.highlights.delete(highlightID); 47 + }; 48 + } else { 49 + return; 50 + } 51 + }); 52 + </script> 53 + 54 + {#if post.originalFediContent} 55 + <div class="bridged-body" bind:this={bodyElement}> 56 + {@html sanitizeHTML(post.originalFediContent)} 57 + </div> 58 + {:else} 59 + <p class="body" bind:this={bodyElement}> 60 + <RichTextFromFacets text={post.text} facets={post.facets as Facet[]} /> 61 + </p> 62 + {/if} 63 + 64 + <style> 65 + .bridged-body :global(p + p) { 66 + margin-top: 18px; 67 + } 68 + 69 + ::highlight(search-results) { 70 + background-color: rgba(255, 255, 0, 0.75); 71 + } 72 + 73 + @media (prefers-color-scheme: dark) { 74 + ::highlight(search-results) { 75 + background-color: rgba(255, 255, 0, 0.35); 76 + } 77 + } 78 + </style>
+223
src/components/posts/PostComponent.svelte
··· 1 + <script module lang="ts"> 2 + export const [getPostContext, setPostContext] = createContext<{ post: Post, placement: PostPlacement}>(); 3 + </script> 4 + 5 + <script lang="ts"> 6 + import { createContext } from 'svelte'; 7 + import { settings } from '../../models/settings.svelte.js'; 8 + import { Post, BlockedPost } from '../../models/posts.js'; 9 + import { Embed, InlineLinkEmbed } from '../../models/embeds.js'; 10 + import { isValidURL, showError } from '../../utils.js'; 11 + 12 + import EdgeMargin from './EdgeMargin.svelte'; 13 + import FediSourceLink from './FediSourceLink.svelte'; 14 + import HiddenRepliesLink from './HiddenRepliesLink.svelte'; 15 + import LoadMoreLink from './LoadMoreLink.svelte'; 16 + import PostBody from './PostBody.svelte'; 17 + import PostComponent from './PostComponent.svelte'; 18 + import PostHeader from './PostHeader.svelte'; 19 + import PostTagsRow from './PostTagsRow.svelte'; 20 + import PostFooter from './PostFooter.svelte'; 21 + import PostWrapper from './PostWrapper.svelte'; 22 + 23 + import EmbedComponent from '../embeds/EmbedComponent.svelte'; 24 + 25 + /** 26 + Contexts: 27 + - thread - a post in the thread tree 28 + - parent - parent reference above the thread root 29 + - quote - a quote embed 30 + - quotes - a post on the quotes page 31 + - feed - a post on the hashtag feed page 32 + */ 33 + 34 + type Props = { 35 + post: Post, 36 + placement: PostPlacement, 37 + highlightedMatches?: string[] | undefined, 38 + class?: string | undefined 39 + } 40 + 41 + let { post, placement, highlightedMatches = undefined, ...props }: Props = $props(); 42 + 43 + let collapsed = $state(false); 44 + let replies: AnyPost[] = $state(post.replies); 45 + let repliesLoaded = $state(false); 46 + let missingHiddenReplies: number | undefined = $state(); 47 + 48 + setPostContext({ post, placement }); 49 + 50 + // TODO: make Post reactive 51 + let quoteCount: number | undefined = $state(post.quoteCount); 52 + 53 + export function setQuoteCount(x: number) { 54 + quoteCount = x; 55 + } 56 + 57 + function shouldRenderReply(reply: AnyPost): boolean { 58 + if (reply instanceof Post) { 59 + return true; 60 + } else if (reply instanceof BlockedPost) { 61 + return (settings.biohazardsEnabled !== false); 62 + } else { 63 + return false; 64 + } 65 + } 66 + 67 + function shouldRenderEmbed(embed: Embed): boolean { 68 + if (post.originalFediURL) { 69 + if (embed instanceof InlineLinkEmbed && embed.title?.startsWith('Original post on ')) { 70 + return false; 71 + } 72 + } 73 + 74 + return true; 75 + } 76 + 77 + function onMoreRepliesLoaded(newPost: Post) { 78 + post.updateDataFromPost(newPost); 79 + replies = post.replies; 80 + } 81 + 82 + function onHiddenRepliesLoaded(newReplies: (AnyPost | null)[]) { 83 + let okReplies = newReplies.filter(x => x !== null); 84 + replies.push(...okReplies); 85 + post.replies = replies; 86 + 87 + if (okReplies.length === newReplies.length && okReplies.length > 0) { 88 + missingHiddenReplies = undefined; 89 + } else { 90 + missingHiddenReplies = newReplies.length - okReplies.length; 91 + } 92 + 93 + repliesLoaded = true; 94 + } 95 + 96 + function onRepliesLoadingError(error: Error) { 97 + showError(error); 98 + } 99 + </script> 100 + 101 + {#snippet body()} 102 + <PostBody {highlightedMatches} /> 103 + 104 + {#if post.tags} 105 + <PostTagsRow /> 106 + {/if} 107 + 108 + {#if post.embed && shouldRenderEmbed(post.embed)} 109 + <EmbedComponent embed={post.embed} /> 110 + {/if} 111 + 112 + {#if post.originalFediURL && isValidURL(post.originalFediURL)} 113 + <FediSourceLink url={post.originalFediURL} /> 114 + {/if} 115 + 116 + {#if post.likeCount !== undefined || post.repostCount !== undefined} 117 + <PostFooter {quoteCount} /> 118 + {/if} 119 + {/snippet} 120 + 121 + <div class="post post-{placement} {props.class || ''}" class:muted={post.muted} class:collapsed={collapsed}> 122 + <PostHeader /> 123 + 124 + {#if placement == 'thread' && !post.isPageRoot} 125 + <EdgeMargin bind:collapsed /> 126 + {/if} 127 + 128 + <div class="content"> 129 + {#if post.muted} 130 + <details> 131 + <summary>{post.muteList ? `Muted (${post.muteList})` : 'Muted - click to show'}</summary> 132 + 133 + {@render body()} 134 + </details> 135 + {:else} 136 + {@render body()} 137 + {/if} 138 + 139 + {#if post.replyCount == 1 && (replies[0] instanceof Post) && replies[0].author.did == post.author.did} 140 + <PostComponent post={replies[0]} placement="thread" class="flat" /> 141 + {:else} 142 + {#each replies as reply (reply.uri)} 143 + {#if shouldRenderReply(reply)} 144 + <PostWrapper post={reply} placement="thread" /> 145 + {/if} 146 + {/each} 147 + {/if} 148 + 149 + {#if placement == 'thread' && !repliesLoaded} 150 + {#key replies} 151 + {#if post.hasMoreReplies} 152 + <LoadMoreLink onLoad={onMoreRepliesLoaded} onError={onRepliesLoadingError} /> 153 + {:else if post.hasHiddenReplies && settings.biohazardsEnabled !== false} 154 + <HiddenRepliesLink onLoad={onHiddenRepliesLoaded} onError={onRepliesLoadingError} /> 155 + {/if} 156 + {/key} 157 + {/if} 158 + 159 + {#if missingHiddenReplies !== undefined} 160 + <p class="missing-replies-info"> 161 + <i class="fa-solid fa-ban"></i> 162 + {#if missingHiddenReplies > 1} 163 + {missingHiddenReplies} replies are missing 164 + {:else if missingHiddenReplies == 1} 165 + 1 reply is missing 166 + {:else} 167 + Some replies are missing 168 + {/if} 169 + (likely taken down by moderation) 170 + </p> 171 + {/if} 172 + </div> 173 + </div> 174 + 175 + <style> 176 + :global(.post) { 177 + position: relative; 178 + padding-left: 21px; 179 + margin-top: 30px; 180 + } 181 + 182 + .post.collapsed .content { 183 + display: none; 184 + } 185 + 186 + .post.flat { 187 + padding-left: 0px; 188 + margin-top: 25px; 189 + } 190 + 191 + .post.muted > :global(h2) { 192 + opacity: 0.3; 193 + font-weight: 600; 194 + } 195 + 196 + .post.muted > :global(.content > details > p), .post.muted > :global(.content > details summary) { 197 + opacity: 0.3; 198 + } 199 + 200 + details { 201 + margin-top: 12px; 202 + margin-bottom: 10px; 203 + } 204 + 205 + summary { 206 + font-size: 10pt; 207 + user-select: none; 208 + -webkit-user-select: none; 209 + cursor: default; 210 + } 211 + 212 + .missing-replies-info { 213 + font-size: 11pt; 214 + color: darkred; 215 + margin-top: 25px; 216 + } 217 + 218 + .post :global(img.loader) { 219 + width: 24px; 220 + animation: rotation 3s infinite linear; 221 + margin-top: 5px; 222 + } 223 + </style>
+154
src/components/posts/PostFooter.svelte
··· 1 + <script lang="ts"> 2 + import { accountAPI } from '../../api.js'; 3 + import { getPostContext } from './PostComponent.svelte'; 4 + import { linkToPostThread, linkToQuotesPage } from '../../router.js'; 5 + import { account } from '../../models/account.svelte.js'; 6 + import { showLoginDialog } from '../Dialogs.svelte'; 7 + import { showError, pluralize } from '../../utils.js'; 8 + 9 + let { post, placement } = getPostContext(); 10 + let { quoteCount }: { quoteCount: number | undefined } = $props(); 11 + 12 + let isLiked = $state(post.liked); 13 + let likeCount = $state(post.likeCount); 14 + let isUnavailableForLiking = $state(false); 15 + 16 + async function onHeartClick() { 17 + try { 18 + if (post.hasViewerInfo) { 19 + await likePost(); 20 + } else if (account.loggedIn) { 21 + await checkIfCanBeLiked(); 22 + } else { 23 + showLoginDialog({ showClose: true }); 24 + } 25 + } catch (error) { 26 + showError(error); 27 + } 28 + } 29 + 30 + async function checkIfCanBeLiked() { 31 + let data = await accountAPI.loadPostViewerInfo(post); 32 + 33 + if (data) { 34 + if (post.liked) { 35 + isLiked = true; 36 + } else { 37 + await likePost(); 38 + } 39 + } else { 40 + isUnavailableForLiking = true; 41 + } 42 + } 43 + 44 + async function likePost() { 45 + if (!isLiked) { 46 + let like = await accountAPI.likePost(post); 47 + post.viewerLike = like.uri; 48 + 49 + isLiked = true; 50 + likeCount += 1; 51 + } else { 52 + await accountAPI.removeLike(post.viewerLike); 53 + post.viewerLike = undefined; 54 + 55 + isLiked = false; 56 + likeCount -= 1; 57 + } 58 + } 59 + </script> 60 + 61 + <p class="stats"> 62 + <span> 63 + <i class="fa-solid fa-heart {isLiked ? 'liked' : ''}" onclick={onHeartClick}></i> <output>{likeCount}</output> 64 + </span> 65 + 66 + {#if post.repostCount > 0} 67 + <span><i class="fa-solid fa-retweet"></i> {post.repostCount}</span> 68 + {/if} 69 + 70 + {#if post.replyCount > 0 && (placement == 'quotes' || placement == 'feed')} 71 + <span> 72 + <i class="fa-regular fa-message"></i> 73 + <a href="{linkToPostThread(post)}">{pluralize(post.replyCount, 'reply', 'replies')}</a> 74 + </span> 75 + {/if} 76 + 77 + {#if quoteCount && placement != 'quote'} 78 + {#if placement == 'quotes' || placement == 'feed' || post.isPageRoot} 79 + <span> 80 + <i class="fa-regular fa-comments"></i> 81 + <a href={linkToQuotesPage(post.linkToPost)}>{pluralize(quoteCount, 'quote')}</a> 82 + </span> 83 + {:else} 84 + <a href={linkToQuotesPage(post.linkToPost)}> 85 + <i class="fa-regular fa-comments"></i> {quoteCount} 86 + </a> 87 + {/if} 88 + {/if} 89 + 90 + {#if placement == 'thread' && post.isRestrictingReplies} 91 + <span><i class="fa-solid fa-ban"></i> Limited replies</span> 92 + {/if} 93 + 94 + {#if isUnavailableForLiking} 95 + <span class="blocked-info">๐Ÿšซ Post unavailable</span> 96 + {/if} 97 + </p> 98 + 99 + <style> 100 + .stats { 101 + font-size: 10pt; 102 + color: #666; 103 + } 104 + 105 + a { 106 + color: #666; 107 + text-decoration: none; 108 + } 109 + 110 + a:hover { 111 + text-decoration: underline; 112 + } 113 + 114 + i { 115 + font-size: 9pt; 116 + color: #888; 117 + } 118 + 119 + i.fa-heart { 120 + color: #aaa; 121 + } 122 + 123 + i.fa-heart.liked { 124 + color: #e03030; 125 + } 126 + 127 + i.fa-heart:hover { 128 + color: #888; 129 + cursor: pointer; 130 + } 131 + 132 + i.fa-heart.liked:hover { 133 + color: #c02020; 134 + } 135 + 136 + span { 137 + margin-right: 7px; 138 + } 139 + 140 + .blocked-info { 141 + color: #a02020; 142 + font-weight: bold; 143 + margin-left: 5px; 144 + } 145 + 146 + @media (prefers-color-scheme: dark) { 147 + .stats { color: #aaa; } 148 + i { color: #888; } 149 + i.fa-heart { color: #aaa; } 150 + i.fa-heart.liked { color: #f04040; } 151 + i.fa-heart:hover { color: #eee; } 152 + i.fa-heart.liked:hover { color: #ff7070; } 153 + } 154 + </style>
+110
src/components/posts/PostHeader.svelte
··· 1 + <script lang="ts"> 2 + import { getPostContext } from './PostComponent.svelte'; 3 + import { avatarPreloader } from '../../utils.js'; 4 + import { PostPresenter } from '../../utils/post_presenter.js'; 5 + import PostSubtreeLink from './PostSubtreeLink.svelte'; 6 + 7 + let { post, placement } = getPostContext(); 8 + let presenter = new PostPresenter(post, placement); 9 + 10 + let avatar: HTMLImageElement | undefined = $state(); 11 + 12 + $effect(() => { 13 + if (avatar) { 14 + avatarPreloader.observe(avatar); 15 + } 16 + 17 + return () => { 18 + avatar && avatarPreloader.unobserve(avatar); 19 + }; 20 + }); 21 + </script> 22 + 23 + <h2> 24 + {#if post.muted} 25 + <i class="muted-avatar fa-regular fa-circle-user fa-2x"></i> 26 + {:else if post.author.avatar} 27 + <img class="avatar" alt="Avatar" loading="lazy" src={post.author.avatar} bind:this={avatar}> 28 + {:else} 29 + <i class="no-avatar fa-regular fa-face-smile fa-2x"></i> 30 + {/if} 31 + 32 + {post.authorDisplayName} 33 + 34 + {#if post.isFediPost} 35 + <a class="handle" href="{post.linkToAuthor}" target="_blank">@{post.authorFediHandle}</a> 36 + <img src="icons/mastodon.svg" class="mastodon" alt="Mastodon logo"> 37 + {:else} 38 + <a class="handle" href="{post.linkToAuthor}" target="_blank">{post.hasValidHandle ? `@${post.author.handle}` : '[invalid handle]'}</a> 39 + {/if} 40 + 41 + <span class="separator">&bull;</span> 42 + 43 + <a class="time" href="{post.linkToPost}" target="_blank" title="{post.createdAt.toISOString()}">{presenter.formattedTimestamp}</a> 44 + 45 + {#if (post.replyCount > 0 && !post.isPageRoot) || ['quote', 'quotes', 'feed'].includes(placement)} 46 + <span class="separator">&bull;</span> 47 + 48 + {#if ['quote', 'quotes', 'feed'].includes(placement)} 49 + <PostSubtreeLink {post} title="Load thread" /> 50 + {:else} 51 + <PostSubtreeLink {post} title="Load this subtree" /> 52 + {/if} 53 + {/if} 54 + </h2> 55 + 56 + <style> 57 + h2 { 58 + font-size: 12pt; 59 + margin-bottom: 0; 60 + } 61 + 62 + .avatar { 63 + width: 32px; 64 + height: 32px; 65 + border-radius: 16px; 66 + vertical-align: middle; 67 + margin-bottom: 3px; 68 + margin-right: 4px; 69 + } 70 + 71 + .no-avatar, .muted-avatar { 72 + color: #aaa; 73 + background-color: #eee; 74 + border-radius: 16px; 75 + vertical-align: middle; 76 + margin-right: 4px; 77 + } 78 + 79 + .muted-avatar { 80 + color: #bbb; 81 + } 82 + 83 + .handle { 84 + color: #888; 85 + font-weight: normal; 86 + font-size: 11pt; 87 + vertical-align: text-top; 88 + } 89 + 90 + .mastodon { 91 + width: 15px; 92 + position: relative; 93 + top: 2px; 94 + margin-left: 3px; 95 + } 96 + 97 + .time { 98 + color: #666; 99 + font-weight: normal; 100 + font-size: 10pt; 101 + vertical-align: text-top; 102 + } 103 + 104 + @media (prefers-color-scheme: dark) { 105 + .handle { color: #888; } 106 + .separator { color: #888; } 107 + .time { color: #aaa; } 108 + h2 :global(.action) { color: #888; } 109 + } 110 + </style>
+10
src/components/posts/PostSubtreeLink.svelte
··· 1 + <script lang="ts"> 2 + import { linkToPostThread } from '../../router.js'; 3 + import { Post } from '../../models/posts.js'; 4 + 5 + let { post, title = '' }: { post: Post, title?: string } = $props(); 6 + </script> 7 + 8 + <a href="{linkToPostThread(post)}" class="action" {title}> 9 + <i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i> 10 + </a>
+29
src/components/posts/PostTagsRow.svelte
··· 1 + <script lang="ts"> 2 + import { getPostContext } from './PostComponent.svelte'; 3 + import { linkToHashtagPage } from '../../router.js'; 4 + 5 + let { post } = getPostContext(); 6 + </script> 7 + 8 + <p class="tags"> 9 + {#each post.tags as tag} 10 + <a href="{linkToHashtagPage(tag)}"># {tag}</a> 11 + {/each} 12 + </p> 13 + 14 + <style> 15 + a { 16 + background-color: hsl(210, 90%, 97%); 17 + border: 1px solid hsl(215, 90%, 85%); 18 + border-radius: 6px; 19 + padding: 3px 7px; 20 + margin-right: 5px; 21 + font-size: 10pt; 22 + color: #333; 23 + } 24 + 25 + a:hover { 26 + text-decoration: none; 27 + background-color: hsl(210, 90%, 93%); 28 + } 29 + </style>
+73
src/components/posts/PostWrapper.svelte
··· 1 + <script lang="ts"> 2 + import { Post, BlockedPost, DetachedQuotePost } from '../../models/posts.js'; 3 + 4 + import BlockedPostView from './BlockedPostView.svelte'; 5 + import MissingPostView from './MissingPostView.svelte'; 6 + import PostComponent from './PostComponent.svelte'; 7 + 8 + /** 9 + Contexts: 10 + - thread - a post in the thread tree 11 + - parent - parent reference above the thread root 12 + - quote - a quote embed 13 + - quotes - a post on the quotes page 14 + - feed - a post on the hashtag feed page 15 + */ 16 + 17 + let { post, placement }: { post: AnyPost, placement: PostPlacement } = $props(); 18 + </script> 19 + 20 + {#if post instanceof Post} 21 + <PostComponent {post} {placement} /> 22 + {:else} 23 + <div class="post post-{placement} blocked"> 24 + {#if post instanceof BlockedPost} 25 + <BlockedPostView {post} {placement} reason="Blocked post" /> 26 + {:else if post instanceof DetachedQuotePost} 27 + <BlockedPostView {post} {placement} reason="Hidden quote" /> 28 + {:else} 29 + <MissingPostView {post} /> 30 + {/if} 31 + </div> 32 + {/if} 33 + 34 + <style> 35 + .post.blocked :global { 36 + p, a { 37 + font-size: 11pt; 38 + color: #666; 39 + } 40 + 41 + @media (prefers-color-scheme: dark) { 42 + p, a { color: #aaa; } 43 + } 44 + } 45 + 46 + :global { 47 + .post p { 48 + margin-top: 10px; 49 + } 50 + 51 + .post .blocked-header i { 52 + margin-right: 2px; 53 + } 54 + 55 + .post h2 .separator, .post .blocked-header .separator, .blocked-header .separator { 56 + color: #888; 57 + font-weight: normal; 58 + font-size: 11pt; 59 + vertical-align: text-top; 60 + } 61 + 62 + .post h2 .action, .post .blocked-header .action, .blocked-header .action { 63 + color: #888; 64 + font-weight: normal; 65 + font-size: 10pt; 66 + vertical-align: text-top; 67 + } 68 + 69 + .post h2 .action:hover, .post .blocked-header .action:hover, .blocked-header .action:hover { 70 + color: #444; 71 + } 72 + } 73 + </style>
+23
src/components/posts/ReferencedPostAuthorLink.svelte
··· 1 + <script lang="ts"> 2 + import { api } from '../../api.js'; 3 + import { atURI } from '../../utils.js'; 4 + 5 + let { post, status = undefined }: { post: AnyPost, status?: string | undefined } = $props(); 6 + 7 + let handle: string | undefined = $state(); 8 + let handleText = $derived(handle ? `@${handle}` : 'see author'); 9 + 10 + $effect(() => { 11 + let did = atURI(post.uri).repo; 12 + 13 + api.fetchHandleForDid(did).then(loadedHandle => { 14 + handle = loadedHandle; 15 + }); 16 + }); 17 + </script> 18 + 19 + {#if status} 20 + (<a href="{post.didLinkToAuthor}" target="_blank">{handleText}</a>, {status}) 21 + {:else} 22 + (<a href="{post.didLinkToAuthor}" target="_blank">{handleText}</a>) 23 + {/if}
+26
src/components/posts/ThreadRootParent.svelte
··· 1 + <script lang="ts"> 2 + import { Post, BlockedPost, MissingPost } from '../../models/posts.js'; 3 + import { linkToPostThread } from '../../router.js'; 4 + import BlockedPostView from './BlockedPostView.svelte'; 5 + 6 + let { post }: { post: AnyPost } = $props(); 7 + </script> 8 + 9 + {#if post instanceof Post} 10 + <p class="back"> 11 + <i class="fa-solid fa-reply"></i> 12 + <a href={linkToPostThread(post)}>See parent post (@{post.author.handle})</a> 13 + </p> 14 + {:else if post instanceof BlockedPost} 15 + <div class="back"> 16 + <BlockedPostView {post} placement="parent" reason="Parent post blocked" /> 17 + </div> 18 + {:else if post instanceof MissingPost} 19 + <p class="back"> 20 + <i class="fa-solid fa-ban"></i> parent post has been deleted 21 + </p> 22 + {:else} 23 + <p class="back"> 24 + <i class="fa-solid fa-ban"></i> something went wrong, this shouldn't happen 25 + </p> 26 + {/if}
+20
src/components/posts/ThreadRootParentRaw.svelte
··· 1 + <script lang="ts"> 2 + import { api } from '../../api.js'; 3 + import { linkToPostById } from '../../router.js'; 4 + import { atURI } from '../../utils.js'; 5 + 6 + let { uri }: { uri: string } = $props(); 7 + let { repo, rkey } = $derived(atURI(uri)); 8 + </script> 9 + 10 + <p class="back"> 11 + <i class="fa-solid fa-reply"></i> 12 + 13 + {#await api.fetchHandleForDid(repo)} 14 + <a href="{linkToPostById(repo, rkey)}">See parent post</a> 15 + {:then handle} 16 + <a href="{linkToPostById(handle, rkey)}">See parent post (@{handle})</a> 17 + {:catch} 18 + <a href="{linkToPostById(repo, rkey)}">See parent post</a> 19 + {/await} 20 + </p>
+63
src/models/account.svelte.ts
··· 1 + import { accountAPI, setAPI } from '../api.js'; 2 + import { pdsEndpointForIdentifier } from '../api/identity.js'; 3 + import { settings } from './settings.svelte.js'; 4 + 5 + class Account { 6 + #loggedIn: boolean; 7 + #avatarURL: string | undefined; 8 + #avatarIsLoading: boolean; 9 + 10 + constructor() { 11 + this.#loggedIn = $state(accountAPI.isLoggedIn); 12 + this.#avatarURL = $state(accountAPI.isLoggedIn ? accountAPI.user.avatar : undefined); 13 + this.#avatarIsLoading = $state(false); 14 + } 15 + 16 + get isIncognito(): boolean { 17 + return !!settings.incognitoMode; 18 + } 19 + 20 + toggleIncognitoMode() { 21 + settings.incognitoMode = !this.isIncognito; 22 + location.reload(); 23 + } 24 + 25 + get loggedIn(): boolean { 26 + return this.#loggedIn; 27 + } 28 + 29 + get avatarURL(): string | undefined { 30 + return this.#avatarURL; 31 + } 32 + 33 + get avatarIsLoading(): boolean { 34 + return this.#avatarIsLoading; 35 + } 36 + 37 + async logIn(identifier: string, password: string) { 38 + let pdsEndpoint = await pdsEndpointForIdentifier(identifier); 39 + 40 + accountAPI.host = pdsEndpoint; 41 + await accountAPI.logIn(identifier, password); 42 + 43 + this.#loggedIn = true; 44 + this.#avatarIsLoading = true; 45 + setAPI(); 46 + 47 + accountAPI.loadCurrentUserAvatar().then(url => { 48 + this.#avatarURL = url || undefined; 49 + }).catch(error => { 50 + console.log(error); 51 + }).finally(() => { 52 + this.#avatarIsLoading = false; 53 + }); 54 + } 55 + 56 + logOut() { 57 + accountAPI.resetTokens(); 58 + settings.logOut(); 59 + location.reload(); 60 + } 61 + } 62 + 63 + export let account = new Account();
+257
src/models/embeds.js
··· 1 + import { ATProtoRecord } from './records.js'; 2 + import { PostDataError, parseViewRecord } from './posts.js'; 3 + 4 + /** 5 + * Base class for embed objects. 6 + */ 7 + 8 + export class Embed { 9 + 10 + /** @type {json} */ 11 + json; 12 + 13 + /** 14 + * More hydrated view of an embed, taken from a full post view (#postView). 15 + * 16 + * @param {json} json, @returns {Embed} 17 + */ 18 + 19 + static parseInlineEmbed(json) { 20 + switch (json.$type) { 21 + case 'app.bsky.embed.record#view': 22 + return new InlineRecordEmbed(json); 23 + 24 + case 'app.bsky.embed.recordWithMedia#view': 25 + return new InlineRecordWithMediaEmbed(json); 26 + 27 + case 'app.bsky.embed.images#view': 28 + return new InlineImageEmbed(json); 29 + 30 + case 'app.bsky.embed.external#view': 31 + return new InlineLinkEmbed(json); 32 + 33 + case 'app.bsky.embed.video#view': 34 + return new InlineVideoEmbed(json); 35 + 36 + default: 37 + if (location.protocol == 'file:') { 38 + throw new PostDataError(`Unexpected embed type: ${json.$type}`); 39 + } else { 40 + console.warn('Unexpected embed type:', json.$type); 41 + return new Embed(json); 42 + } 43 + } 44 + } 45 + 46 + /** 47 + * Raw embed extracted from raw record data of a post. Does not include quoted post contents. 48 + * 49 + * @param {json} json, @returns {Embed} 50 + */ 51 + 52 + static parseRawEmbed(json) { 53 + switch (json.$type) { 54 + case 'app.bsky.embed.record': 55 + return new RawRecordEmbed(json); 56 + 57 + case 'app.bsky.embed.recordWithMedia': 58 + return new RawRecordWithMediaEmbed(json); 59 + 60 + case 'app.bsky.embed.images': 61 + return new RawImageEmbed(json); 62 + 63 + case 'app.bsky.embed.external': 64 + return new RawLinkEmbed(json); 65 + 66 + case 'app.bsky.embed.video': 67 + return new RawVideoEmbed(json); 68 + 69 + default: 70 + if (location.protocol == 'file:') { 71 + throw new PostDataError(`Unexpected embed type: ${json.$type}`); 72 + } else { 73 + console.warn('Unexpected embed type:', json.$type); 74 + return new Embed(json); 75 + } 76 + } 77 + } 78 + 79 + /** @param {json} json */ 80 + constructor(json) { 81 + this.json = json; 82 + } 83 + 84 + /** @returns {string} */ 85 + get type() { 86 + return this.json.$type; 87 + } 88 + } 89 + 90 + export class RawImageEmbed extends Embed { 91 + 92 + /** @type {json[]} */ 93 + images; 94 + 95 + /** @param {json} json */ 96 + constructor(json) { 97 + super(json); 98 + this.images = json.images; 99 + } 100 + } 101 + 102 + export class RawLinkEmbed extends Embed { 103 + 104 + /** @type {string | undefined} */ 105 + url; 106 + 107 + /** @type {string | undefined} */ 108 + title; 109 + 110 + /** @type {json | undefined} */ 111 + thumb; 112 + 113 + /** @param {json} json */ 114 + constructor(json) { 115 + super(json); 116 + 117 + this.url = json.external.uri; 118 + this.title = json.external.title; 119 + this.thumb = json.external.thumb; 120 + } 121 + } 122 + 123 + export class RawVideoEmbed extends Embed { 124 + 125 + /** @type {json | undefined} */ 126 + video; 127 + 128 + /** @param {json} json */ 129 + constructor(json) { 130 + super(json); 131 + this.video = json.video; 132 + } 133 + } 134 + 135 + export class RawRecordEmbed extends Embed { 136 + 137 + /** @type {ATProtoRecord} */ 138 + record; 139 + 140 + /** @param {json} json */ 141 + constructor(json) { 142 + super(json); 143 + this.record = new ATProtoRecord(json.record); 144 + } 145 + } 146 + 147 + export class RawRecordWithMediaEmbed extends Embed { 148 + 149 + /** @type {ATProtoRecord} */ 150 + record; 151 + 152 + /** @type {Embed} */ 153 + media; 154 + 155 + /** @param {json} json */ 156 + constructor(json) { 157 + super(json); 158 + this.record = new ATProtoRecord(json.record.record); 159 + this.media = Embed.parseRawEmbed(json.media); 160 + } 161 + } 162 + 163 + export class InlineRecordEmbed extends Embed { 164 + 165 + /** @type {ATProtoRecord} */ 166 + record; 167 + 168 + /** 169 + * app.bsky.embed.record#view 170 + * @param {json} json 171 + */ 172 + constructor(json) { 173 + super(json); 174 + this.record = parseViewRecord(json.record); 175 + } 176 + } 177 + 178 + export class InlineRecordWithMediaEmbed extends Embed { 179 + 180 + /** @type {ATProtoRecord} */ 181 + record; 182 + 183 + /** @type {Embed} */ 184 + media; 185 + 186 + /** 187 + * app.bsky.embed.recordWithMedia#view 188 + * @param {json} json 189 + */ 190 + constructor(json) { 191 + super(json); 192 + this.record = parseViewRecord(json.record.record); 193 + this.media = Embed.parseInlineEmbed(json.media); 194 + } 195 + } 196 + 197 + export class InlineLinkEmbed extends Embed { 198 + 199 + /** @type {string | undefined} */ 200 + url; 201 + 202 + /** @type {string | undefined} */ 203 + title; 204 + 205 + /** @type {string | undefined} */ 206 + description; 207 + 208 + /** @type {json | undefined} */ 209 + thumb; 210 + 211 + /** 212 + * app.bsky.embed.external#view 213 + * @param {json} json 214 + */ 215 + constructor(json) { 216 + super(json); 217 + 218 + this.url = json.external.uri; 219 + this.title = json.external.title; 220 + this.description = json.external.description; 221 + this.thumb = json.external.thumb; 222 + } 223 + } 224 + 225 + export class InlineImageEmbed extends Embed { 226 + 227 + /** @type {json[]} */ 228 + images; 229 + 230 + /** 231 + * app.bsky.embed.images#view 232 + * @param {json} json 233 + */ 234 + constructor(json) { 235 + super(json); 236 + this.images = json.images; 237 + } 238 + } 239 + 240 + export class InlineVideoEmbed extends Embed { 241 + 242 + /** @type {string | undefined} */ 243 + playlistURL; 244 + 245 + /** @type {string | undefined} */ 246 + alt; 247 + 248 + /** 249 + * app.bsky.embed.video#view 250 + * @param {json} json 251 + */ 252 + constructor(json) { 253 + super(json); 254 + this.playlistURL = json.playlist; 255 + this.alt = json.alt; 256 + } 257 + }
+500
src/models/posts.js
··· 1 + import { api } from '../api.js'; 2 + import { atURI, castToInt } from '../utils.js'; 3 + import { ATProtoRecord, FeedGeneratorRecord, StarterPackRecord, UserListRecord } from './records.js'; 4 + import { Embed } from './embeds.js'; 5 + 6 + /** 7 + * Thrown when parsing post JSON fails. 8 + */ 9 + 10 + export class PostDataError extends Error { 11 + 12 + /** @param {string} message */ 13 + constructor(message) { 14 + super(message); 15 + } 16 + } 17 + 18 + 19 + /** 20 + * Base class shared by the full Post and post stubs like BlockedPost, MissingPost etc. 21 + */ 22 + 23 + export class BasePost extends ATProtoRecord { 24 + 25 + /** @returns {string} */ 26 + get didLinkToAuthor() { 27 + let { repo } = atURI(this.uri); 28 + return `https://bsky.app/profile/${repo}`; 29 + } 30 + } 31 + 32 + 33 + /** 34 + * View of a post as part of a thread, as returned from getPostThread. 35 + * Expected to be #threadViewPost, but may be blocked or missing. 36 + * 37 + * @param {json} json 38 + * @param {Post?} [pageRoot] 39 + * @param {number} [level] 40 + * @param {number} [absoluteLevel] 41 + * @returns {AnyPost} 42 + */ 43 + 44 + export function parseThreadPost(json, pageRoot = null, level = 0, absoluteLevel = 0) { 45 + switch (json.$type) { 46 + case 'app.bsky.feed.defs#threadViewPost': 47 + let post = new Post(json.post, { level: level, absoluteLevel: absoluteLevel }); 48 + 49 + post.pageRoot = pageRoot ?? post; 50 + 51 + if (json.replies) { 52 + let replies = json.replies.map(x => parseThreadPost(x, post.pageRoot, level + 1, absoluteLevel + 1)); 53 + post.setReplies(replies); 54 + } 55 + 56 + if (absoluteLevel <= 0 && json.parent) { 57 + post.parent = parseThreadPost(json.parent, post.pageRoot, level - 1, absoluteLevel - 1); 58 + } 59 + 60 + return post; 61 + 62 + case 'app.bsky.feed.defs#notFoundPost': 63 + return new MissingPost(json); 64 + 65 + case 'app.bsky.feed.defs#blockedPost': 66 + return new BlockedPost(json); 67 + 68 + default: 69 + throw new PostDataError(`Unexpected record type: ${json.$type}`); 70 + } 71 + } 72 + 73 + /** 74 + * View of a post embedded as a quote. 75 + * Expected to be app.bsky.embed.record#viewRecord, but may be blocked, missing or a different type of record 76 + * (e.g. a list or a feed generator). For unknown record embeds, we fall back to generic ATProtoRecord. 77 + * 78 + * @param {json} json 79 + * @returns {ATProtoRecord} 80 + */ 81 + 82 + export function parseViewRecord(json) { 83 + switch (json.$type) { 84 + case 'app.bsky.embed.record#viewRecord': 85 + return new Post(json, { isEmbed: true }); 86 + 87 + case 'app.bsky.embed.record#viewNotFound': 88 + return new MissingPost(json); 89 + 90 + case 'app.bsky.embed.record#viewBlocked': 91 + return new BlockedPost(json); 92 + 93 + case 'app.bsky.embed.record#viewDetached': 94 + return new DetachedQuotePost(json); 95 + 96 + case 'app.bsky.feed.defs#generatorView': 97 + return new FeedGeneratorRecord(json); 98 + 99 + case 'app.bsky.graph.defs#listView': 100 + return new UserListRecord(json); 101 + 102 + case 'app.bsky.graph.defs#starterPackViewBasic': 103 + return new StarterPackRecord(json); 104 + 105 + default: 106 + console.warn('Unknown record type:', json.$type); 107 + return new ATProtoRecord(json); 108 + } 109 + } 110 + 111 + /** 112 + * View of a post as part of a feed (e.g. a profile feed, home timeline or a custom feed). It should be an 113 + * app.bsky.feed.defs#feedViewPost - blocked or missing posts don't appear here, they just aren't included. 114 + * 115 + * @param {json} json 116 + * @returns {Post} 117 + */ 118 + 119 + export function parseFeedPost(json) { 120 + let post = new Post(json.post); 121 + 122 + if (json.reply) { 123 + post.parent = parsePostView(json.reply.parent); 124 + post.threadRoot = parsePostView(json.reply.root); 125 + 126 + if (json.reply.grandparentAuthor) { 127 + post.grandparentAuthor = json.reply.grandparentAuthor; 128 + } 129 + } 130 + 131 + if (json.reason) { 132 + post.reason = json.reason; 133 + } 134 + 135 + return post; 136 + } 137 + 138 + /** 139 + * Parses a #postView - the inner post object that includes the actual post - but still checks if it's not 140 + * a blocked or missing post. The #postView must include a $type. 141 + * (This is used for e.g. parent/root of a #feedViewPost.) 142 + * 143 + * @param {json} json, @returns {AnyPost} 144 + */ 145 + 146 + export function parsePostView(json) { 147 + switch (json.$type) { 148 + case 'app.bsky.feed.defs#postView': 149 + return new Post(json); 150 + 151 + case 'app.bsky.feed.defs#notFoundPost': 152 + return new MissingPost(json); 153 + 154 + case 'app.bsky.feed.defs#blockedPost': 155 + return new BlockedPost(json); 156 + 157 + default: 158 + throw new PostDataError(`Unexpected record type: ${json.$type}`); 159 + } 160 + } 161 + 162 + 163 + /** 164 + * Standard Bluesky post record. 165 + */ 166 + 167 + export class Post extends BasePost { 168 + /** 169 + * Post object which is the direct parent of this post. 170 + * @type {AnyPost | undefined} 171 + */ 172 + parent; 173 + 174 + /** 175 + * Post object which is the root of the whole thread (as specified in the post record). 176 + * @type {AnyPost | undefined} 177 + */ 178 + threadRoot; 179 + 180 + /** 181 + * Post which is at the top of the (sub)thread currently loaded on the page (might not be the same as threadRoot). 182 + * @type {Post | undefined} 183 + */ 184 + pageRoot; 185 + 186 + /** 187 + * Post's direct replies (if it's displayed in a thread). 188 + * @type {AnyPost[]} 189 + */ 190 + replies; 191 + 192 + /** 193 + * Info about the author of the "grandparent" post. Included only in feedPost views, for the purposes 194 + * of feed filtering algorithm. 195 + * @type {json | undefined} 196 + */ 197 + grandparentAuthor; 198 + 199 + /** 200 + * Depth of the post in the getPostThread response it was loaded from, starting from 0. May be negative. 201 + * @type {number | undefined} 202 + */ 203 + level; 204 + 205 + /** 206 + * Depth of the post in the whole tree visible on the page (pageRoot's absoluteLevel is 0). May be negative. 207 + * @type {number | undefined} 208 + */ 209 + absoluteLevel; 210 + 211 + /** 212 + * For posts in feeds and timelines - specifies e.g. that a post was reposted by someone. 213 + * @type {object | undefined} 214 + */ 215 + reason; 216 + 217 + /** 218 + * True if the post was extracted from inner embed of a quote, not from a #postView. 219 + * @type {boolean | undefined} 220 + */ 221 + isEmbed; 222 + 223 + 224 + /** @param {json} data, @param {json} [extra] */ 225 + 226 + constructor(data, extra) { 227 + super(data); 228 + Object.assign(this, extra ?? {}); 229 + 230 + if (this.absoluteLevel === 0) { 231 + this.pageRoot = this; 232 + } 233 + 234 + this.record = this.isPostView ? data.record : data.value; 235 + 236 + if (this.isPostView && data.embed) { 237 + this.embed = Embed.parseInlineEmbed(data.embed); 238 + } else if (this.isEmbed && data.embeds && data.embeds[0]) { 239 + this.embed = Embed.parseInlineEmbed(data.embeds[0]); 240 + } else if (this.record.embed) { 241 + this.embed = Embed.parseRawEmbed(this.record.embed); 242 + } 243 + 244 + this.author = this.author ?? data.author; 245 + this.replies = []; 246 + 247 + this.viewerData = data.viewer; 248 + this.viewerLike = data.viewer?.like; 249 + 250 + if (this.author) { 251 + api.cacheProfile(this.author); 252 + } 253 + } 254 + 255 + /** @param {Post} post */ 256 + 257 + updateDataFromPost(post) { 258 + this.record = post.record; 259 + this.embed = post.embed; 260 + this.author = post.author; 261 + this.viewerData = post.viewerData; 262 + this.viewerLike = post.viewerLike; 263 + this.level = post.level; 264 + this.absoluteLevel = post.absoluteLevel; 265 + this.setReplies(post.replies); 266 + } 267 + 268 + /** @param {AnyPost[]} replies */ 269 + 270 + setReplies(replies) { 271 + this.replies = replies; 272 + this.replies.sort(this.sortReplies.bind(this)); 273 + } 274 + 275 + /** @param {AnyPost} a, @param {AnyPost} b, @returns {-1 | 0 | 1} */ 276 + 277 + sortReplies(a, b) { 278 + if (a instanceof Post && b instanceof Post) { 279 + if (a.author.did == this.author.did && b.author.did != this.author.did) { 280 + return -1; 281 + } else if (a.author.did != this.author.did && b.author.did == this.author.did) { 282 + return 1; 283 + } else if (a.text != "๐Ÿ“Œ" && b.text == "๐Ÿ“Œ") { 284 + return -1; 285 + } else if (a.text == "๐Ÿ“Œ" && b.text != "๐Ÿ“Œ") { 286 + return 1; 287 + } else if (a.createdAt.getTime() < b.createdAt.getTime()) { 288 + return -1; 289 + } else if (a.createdAt.getTime() > b.createdAt.getTime()) { 290 + return 1; 291 + } else { 292 + return 0; 293 + } 294 + } else if (a instanceof Post) { 295 + return -1; 296 + } else if (b instanceof Post) { 297 + return 1; 298 + } else { 299 + return 0; 300 + } 301 + } 302 + 303 + /** @returns {boolean} */ 304 + get isPostView() { 305 + return !this.isEmbed; 306 + } 307 + 308 + /** @returns {boolean} */ 309 + get isFediPost() { 310 + return this.author?.handle.endsWith('.ap.brid.gy'); 311 + } 312 + 313 + /** @returns {string | undefined} */ 314 + get originalFediContent() { 315 + return this.record.bridgyOriginalText; 316 + } 317 + 318 + /** @returns {string | undefined} */ 319 + get originalFediURL() { 320 + return this.record.bridgyOriginalUrl; 321 + } 322 + 323 + /** @returns {boolean} */ 324 + get isPageRoot() { 325 + // I AM ROOOT 326 + return (this.pageRoot === this); 327 + } 328 + 329 + /** @returns {string} */ 330 + get authorFediHandle() { 331 + if (this.isFediPost) { 332 + return this.author.handle.replace(/\.ap\.brid\.gy$/, '').replace('.', '@'); 333 + } else { 334 + throw "Not a Fedi post"; 335 + } 336 + } 337 + 338 + /** @returns {boolean} */ 339 + get hasValidHandle() { 340 + return this.author.handle != 'handle.invalid'; 341 + } 342 + 343 + /** @returns {string} */ 344 + get authorDisplayName() { 345 + if (this.author.displayName) { 346 + return this.author.displayName.trim(); 347 + } else if (this.author.handle.endsWith('.bsky.social')) { 348 + return this.author.handle.replace(/\.bsky\.social$/, ''); 349 + } else { 350 + return this.author.handle; 351 + } 352 + } 353 + 354 + /** @returns {string} */ 355 + get linkToAuthor() { 356 + return 'https://bsky.app/profile/' + (this.hasValidHandle ? this.author.handle : this.author.did); 357 + } 358 + 359 + /** @returns {string} */ 360 + get linkToPost() { 361 + return this.linkToAuthor + '/post/' + this.rkey; 362 + } 363 + 364 + /** @returns {string} */ 365 + get text() { 366 + return this.record.text; 367 + } 368 + 369 + /** @returns {string} */ 370 + get lowercaseText() { 371 + if (!this._lowercaseText) { 372 + this._lowercaseText = this.record.text.toLowerCase(); 373 + } 374 + 375 + return this._lowercaseText; 376 + } 377 + 378 + /** @returns {json} */ 379 + get facets() { 380 + return this.record.facets; 381 + } 382 + 383 + /** @returns {string[] | undefined} */ 384 + get tags() { 385 + return this.record.tags; 386 + } 387 + 388 + /** @returns {Date} */ 389 + get createdAt() { 390 + return new Date(this.record.createdAt); 391 + } 392 + 393 + /** @returns {number} */ 394 + get likeCount() { 395 + return castToInt(this.data.likeCount); 396 + } 397 + 398 + /** @returns {number} */ 399 + get replyCount() { 400 + return castToInt(this.data.replyCount); 401 + } 402 + 403 + /** @returns {number} */ 404 + get quoteCount() { 405 + return castToInt(this.data.quoteCount); 406 + } 407 + 408 + /** @returns {boolean} */ 409 + get hasMoreReplies() { 410 + let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length); 411 + 412 + return shouldHaveMoreReplies && (this.replies.length === 0) && (this.level !== undefined && this.level > 4); 413 + } 414 + 415 + /** @returns {boolean} */ 416 + get hasHiddenReplies() { 417 + let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length); 418 + 419 + return shouldHaveMoreReplies && (this.replies.length > 0 || (this.level !== undefined && this.level <= 4)); 420 + } 421 + 422 + /** @returns {boolean} */ 423 + get isRestrictingReplies() { 424 + return !!(this.data.threadgate && this.data.threadgate.record.allow); 425 + } 426 + 427 + /** @returns {number} */ 428 + get repostCount() { 429 + return castToInt(this.data.repostCount); 430 + } 431 + 432 + /** @returns {boolean} */ 433 + get liked() { 434 + return (this.viewerLike !== undefined); 435 + } 436 + 437 + /** @returns {boolean | undefined} */ 438 + get muted() { 439 + return this.author.viewer?.muted; 440 + } 441 + 442 + /** @returns {string | undefined} */ 443 + get muteList() { 444 + return this.author.viewer?.mutedByList?.name; 445 + } 446 + 447 + /** @returns {boolean} */ 448 + get hasViewerInfo() { 449 + return (this.viewerData !== undefined); 450 + } 451 + 452 + /** @returns {ATProtoRecord | undefined} */ 453 + get parentReference() { 454 + return this.record.reply?.parent && new ATProtoRecord(this.record.reply?.parent); 455 + } 456 + 457 + /** @returns {ATProtoRecord | undefined} */ 458 + get rootReference() { 459 + return this.record.reply?.root && new ATProtoRecord(this.record.reply?.root); 460 + } 461 + } 462 + 463 + 464 + /** 465 + * Post which is blocked for some reason (the author is blocked, the author has blocked you, or there is a block 466 + * between the post author and the parent author). It only includes a reference, but no post content. 467 + */ 468 + 469 + export class BlockedPost extends BasePost { 470 + 471 + /** @param {json} data */ 472 + constructor(data) { 473 + super(data); 474 + this.author = data.author; 475 + } 476 + 477 + /** @returns {boolean} */ 478 + get blocksUser() { 479 + return !!this.author.viewer?.blocking; 480 + } 481 + 482 + /** @returns {boolean} */ 483 + get blockedByUser() { 484 + return this.author.viewer?.blockedBy; 485 + } 486 + } 487 + 488 + 489 + /** 490 + * Stub of a post which was deleted or hidden. 491 + */ 492 + 493 + export class MissingPost extends BasePost {} 494 + 495 + 496 + /** 497 + * Stub of a quoted post which was un-quoted by the original author. 498 + */ 499 + 500 + export class DetachedQuotePost extends BasePost {}
+126
src/models/records.js
··· 1 + import { atURI, castToInt } from '../utils.js'; 2 + 3 + /** 4 + * Generic record type, base class for all records or record view objects. 5 + */ 6 + 7 + export class ATProtoRecord { 8 + 9 + /** @param {json} data, @param {json} [extra] */ 10 + constructor(data, extra) { 11 + this.data = data; 12 + Object.assign(this, extra ?? {}); 13 + } 14 + 15 + /** @returns {string} */ 16 + get uri() { 17 + return this.data.uri; 18 + } 19 + 20 + /** @returns {string} */ 21 + get cid() { 22 + return this.data.cid; 23 + } 24 + 25 + /** @returns {string} */ 26 + get rkey() { 27 + return atURI(this.uri).rkey; 28 + } 29 + 30 + /** @returns {string} */ 31 + get type() { 32 + return this.data.$type; 33 + } 34 + } 35 + 36 + 37 + /** 38 + * Record representing a feed generator. 39 + */ 40 + 41 + export class FeedGeneratorRecord extends ATProtoRecord { 42 + 43 + /** @param {json} data */ 44 + constructor(data) { 45 + super(data); 46 + this.author = data.creator; 47 + } 48 + 49 + /** @returns {string | undefined} */ 50 + get title() { 51 + return this.data.displayName; 52 + } 53 + 54 + /** @returns {string | undefined} */ 55 + get description() { 56 + return this.data.description; 57 + } 58 + 59 + /** @returns {number} */ 60 + get likeCount() { 61 + return castToInt(this.data.likeCount); 62 + } 63 + 64 + /** @returns {string | undefined} */ 65 + get avatar() { 66 + return this.data.avatar; 67 + } 68 + } 69 + 70 + 71 + /** 72 + * Record representing a user list or moderation list. 73 + */ 74 + 75 + export class UserListRecord extends ATProtoRecord { 76 + 77 + /** @param {json} data */ 78 + constructor(data) { 79 + super(data); 80 + this.author = data.creator; 81 + } 82 + 83 + /** @returns {string | undefined} */ 84 + get title() { 85 + return this.data.name; 86 + } 87 + 88 + /** @returns {string | undefined} */ 89 + get purpose() { 90 + return this.data.purpose; 91 + } 92 + 93 + /** @returns {string | undefined} */ 94 + get description() { 95 + return this.data.description; 96 + } 97 + 98 + /** @returns {string | undefined} */ 99 + get avatar() { 100 + return this.data.avatar; 101 + } 102 + } 103 + 104 + 105 + /** 106 + * Record representing a starter pack. 107 + */ 108 + 109 + export class StarterPackRecord extends ATProtoRecord { 110 + 111 + /** @param {json} data */ 112 + constructor(data) { 113 + super(data); 114 + this.author = data.creator; 115 + } 116 + 117 + /** @returns {string | undefined} */ 118 + get title() { 119 + return this.data.record.name; 120 + } 121 + 122 + /** @returns {string | undefined} */ 123 + get description() { 124 + return this.data.record.description; 125 + } 126 + }
+59
src/models/settings.svelte.ts
··· 1 + interface SettingsData { 2 + dateLocale?: string; 3 + incognito?: boolean; 4 + biohazard?: boolean; 5 + } 6 + 7 + declare global { 8 + interface Window { 9 + settings: typeof settings; 10 + } 11 + } 12 + 13 + class Settings { 14 + data: SettingsData; 15 + 16 + constructor() { 17 + let savedData = localStorage.getItem('settings'); 18 + this.data = $state(savedData ? JSON.parse(savedData) : {}); 19 + } 20 + 21 + save() { 22 + localStorage.setItem('settings', JSON.stringify(this.data)); 23 + } 24 + 25 + logOut() { 26 + delete this.data.incognito; 27 + this.save(); 28 + } 29 + 30 + get dateLocale(): string | undefined { 31 + return this.data.dateLocale; 32 + } 33 + 34 + set dateLocale(value: string) { 35 + this.data.dateLocale = value; 36 + this.save(); 37 + } 38 + 39 + get incognitoMode(): boolean | undefined { 40 + return this.data.incognito; 41 + } 42 + 43 + set incognitoMode(value: boolean) { 44 + this.data.incognito = value; 45 + this.save(); 46 + } 47 + 48 + get biohazardsEnabled(): boolean | undefined { 49 + return this.data.biohazard; 50 + } 51 + 52 + set biohazardsEnabled(value: boolean) { 53 + this.data.biohazard = value; 54 + this.save(); 55 + } 56 + } 57 + 58 + export const settings = new Settings(); 59 + window.settings = settings;
+73
src/pages/HashtagPage.svelte
··· 1 + <script lang="ts"> 2 + import { api } from '../api.js'; 3 + import { Post } from '../models/posts.js'; 4 + import * as paginator from '../utils/paginator.js'; 5 + import MainLoader from '../components/MainLoader.svelte'; 6 + import PostComponent from '../components/posts/PostComponent.svelte'; 7 + 8 + let { hashtag }: { hashtag: string } = $props(); 9 + hashtag = hashtag.replace(/^\#/, ''); 10 + 11 + let posts: Post[] = $state([]); 12 + let firstPageLoaded = $state(false); 13 + let loadingFailed = $state(false); 14 + 15 + let isLoading = false; 16 + let finished = false; 17 + let cursor: string | undefined; 18 + 19 + paginator.loadInPages(async () => { 20 + if (isLoading || finished) { return } 21 + isLoading = true; 22 + 23 + try { 24 + let data = await api.getHashtagFeed(hashtag, cursor); 25 + let batch = data.posts.map((j: json) => new Post(j)) as Post[]; 26 + firstPageLoaded = true; 27 + 28 + posts.push(...batch); 29 + 30 + isLoading = false; 31 + cursor = data.cursor; 32 + 33 + if (!cursor || posts.length == 0) { 34 + finished = true; 35 + } 36 + } catch(error) { 37 + console.log(error); 38 + isLoading = false; 39 + loadingFailed = true; 40 + } 41 + }); 42 + </script> 43 + 44 + <svelte:head> 45 + <title>#{hashtag} - Skythread</title> 46 + </svelte:head> 47 + 48 + {#if firstPageLoaded} 49 + <main class="hashtag"> 50 + <header> 51 + <h2> 52 + {#if posts.length > 0} 53 + Posts tagged: #{hashtag} 54 + {:else} 55 + No posts tagged #{hashtag}. 56 + {/if} 57 + </h2> 58 + </header> 59 + 60 + {#each posts as post (post.uri)} 61 + <PostComponent {post} placement="feed" /> 62 + {/each} 63 + </main> 64 + {:else if !loadingFailed} 65 + <MainLoader /> 66 + {/if} 67 + 68 + <style> 69 + .hashtag > :global(.post) { 70 + padding-bottom: 10px; 71 + border-bottom: 1px solid #ddd; 72 + } 73 + </style>
+85
src/pages/LikeStatsPage.svelte
··· 1 + <script lang="ts"> 2 + import LikeStatsTable from '../components/LikeStatsTable.svelte'; 3 + import { LikeStats, type LikeStat } from '../services/like_stats.js'; 4 + import { numberOfDays } from '../utils.js'; 5 + 6 + let timeRangeDays = $state(7); 7 + let progress: number | undefined = $state(); 8 + let scanInProgress = $derived(progress !== undefined); 9 + let givenLikesUsers: LikeStat[] | undefined = $state(); 10 + let receivedLikesUsers: LikeStat[] | undefined = $state(); 11 + 12 + let likeStats = new LikeStats(); 13 + 14 + async function startScan(e: Event) { 15 + e.preventDefault(); 16 + 17 + try { 18 + if (!scanInProgress) { 19 + givenLikesUsers = undefined; 20 + receivedLikesUsers = undefined; 21 + 22 + let result = await likeStats.findLikes(timeRangeDays, (p) => { progress = p }); 23 + 24 + givenLikesUsers = result.givenLikes; 25 + receivedLikesUsers = result.receivedLikes; 26 + progress = undefined; 27 + } else { 28 + likeStats.abortScan(); 29 + progress = undefined; 30 + } 31 + } catch (error) { 32 + if (error.name !== 'AbortError') { 33 + throw error; 34 + } 35 + } 36 + } 37 + </script> 38 + 39 + <main> 40 + <h2>Like statistics</h2> 41 + 42 + <form onsubmit={startScan}> 43 + <p> 44 + Time range: <input id="like_stats_range" type="range" min="1" max="60" bind:value={timeRangeDays}> 45 + <label for="like_stats_range">{numberOfDays(timeRangeDays)}</label> 46 + </p> 47 + 48 + <p> 49 + <input type="submit" value="{scanInProgress ? 'Cancel' : 'Start scan'}"> 50 + 51 + {#if scanInProgress} 52 + <progress value={progress} style="display: inline;"></progress> 53 + {/if} 54 + </p> 55 + </form> 56 + 57 + {#if givenLikesUsers && receivedLikesUsers} 58 + <LikeStatsTable cssClass="given-likes" header="โค๏ธ Likes from you:" users={givenLikesUsers} /> 59 + <LikeStatsTable cssClass="received-likes" header="๐Ÿ’› Likes on your posts:" users={receivedLikesUsers} /> 60 + {/if} 61 + </main> 62 + 63 + <style> 64 + input[type="range"] { 65 + width: 250px; 66 + vertical-align: middle; 67 + } 68 + 69 + input[type="submit"] { 70 + font-size: 12pt; 71 + margin: 5px 0px; 72 + padding: 5px 10px; 73 + } 74 + 75 + progress { 76 + width: 300px; 77 + margin-left: 10px; 78 + vertical-align: middle; 79 + display: none; 80 + } 81 + 82 + :global(.scan-result.given-likes) { 83 + margin-right: 100px; 84 + } 85 + </style>
+268
src/pages/LycanSearchPage.svelte
··· 1 + <script lang="ts"> 2 + import { Post } from '../models/posts'; 3 + import { settings } from '../models/settings.svelte'; 4 + import { DevLycan, Lycan } from '../services/lycan'; 5 + import PostComponent from '../components/posts/PostComponent.svelte'; 6 + import SearchPage from './SearchPage.svelte'; 7 + 8 + const collections = [ 9 + { id: 'likes', title: 'Likes' }, 10 + { id: 'reposts', title: 'Reposts' }, 11 + { id: 'quotes', title: 'Quotes' }, 12 + { id: 'pins', title: 'Pins' } 13 + ] 14 + 15 + let { lycan }: { lycan: string | undefined } = $props(); 16 + 17 + let lycanService = $derived(createService(lycan)); 18 + 19 + let isCheckingStatus = $state(false); 20 + let importStatus: string | undefined = $state(); 21 + let importStatusLabel: string | undefined = $state(); 22 + let importProgress = $state(0); 23 + let wasImporting = $state(false); 24 + let importTimer: number | undefined; 25 + 26 + let selectedCollection = $state(collections[0].id); 27 + let query = $state(''); 28 + 29 + let loadingPosts = $state(false); 30 + let finishedPosts = $state(false); 31 + let results: Post[] = $state([]); 32 + let highlightedMatches: string[] = $state([]); 33 + 34 + checkImportStatus(); 35 + 36 + 37 + function createService(param: string | undefined): Lycan { 38 + if (!param) { 39 + // default = production instance 40 + return new Lycan(); 41 + } else if (param == 'local' || param == 'localhost') { 42 + // ?lycan=local 43 + return new DevLycan("http://localhost:3000"); 44 + } else if (param.startsWith('local:') || param.startsWith('localhost:')) { 45 + // ?lycan=local:4000 46 + let port = param.split(':')[1]; 47 + return new DevLycan(`http://localhost:${port}`); 48 + } else { 49 + // ?lycan=your.instance.org 50 + return new Lycan(`did:web:${lycan}#lycan`); 51 + } 52 + } 53 + 54 + function onFormSubmit(e: Event) { 55 + e.preventDefault(); 56 + 57 + showImportStatus({ status: 'requested' }); 58 + wasImporting = true; 59 + 60 + lycanService.startImport().catch((error) => { 61 + console.error('Failed to start Lycan import', error); 62 + showImportError(`Import failed: ${error}`); 63 + }); 64 + } 65 + 66 + function onKeyPress(e: KeyboardEvent) { 67 + if (e.key == 'Enter') { 68 + e.preventDefault(); 69 + 70 + let q = query.trim().toLowerCase(); 71 + 72 + if (q.length == 0 || importStatus != 'finished') { 73 + return; 74 + } 75 + 76 + results = []; 77 + wasImporting = false; 78 + loadingPosts = true; 79 + finishedPosts = false; 80 + 81 + lycanService.searchPosts(selectedCollection, q, { 82 + onPostsLoaded: ({ posts, terms }) => { 83 + loadingPosts = false; 84 + results.push(...posts); 85 + highlightedMatches = terms; 86 + }, 87 + onFinish: () => { 88 + finishedPosts = true; 89 + } 90 + }); 91 + } 92 + } 93 + 94 + async function checkImportStatus() { 95 + if (isCheckingStatus) { 96 + return; 97 + } 98 + 99 + isCheckingStatus = true; 100 + 101 + try { 102 + let response = await lycanService.getImportStatus(); 103 + showImportStatus(response); 104 + } catch (error) { 105 + showImportError(`Couldn't check import status: ${error}`); 106 + } finally { 107 + isCheckingStatus = false; 108 + } 109 + } 110 + 111 + function showImportStatus(info: json) { 112 + console.log(info); 113 + 114 + if (!info.status) { 115 + showImportError("Error checking import status"); 116 + return; 117 + } 118 + 119 + importStatus = info.status; 120 + 121 + let isImporting = ['in_progress', 'scheduled', 'requested'].includes(info.status); 122 + wasImporting = wasImporting || isImporting; 123 + 124 + if (info.status == 'not_started') { 125 + // do nothing 126 + } else if (isImporting) { 127 + showImportProgress(info); 128 + } else if (info.status == 'finished') { 129 + showImportProgress({ status: 'finished', progress: 1.0 }); 130 + } else { 131 + showImportError("Error checking import status"); 132 + } 133 + 134 + isImporting ? startImportTimer() : stopImportTimer(); 135 + } 136 + 137 + function showImportProgress(info: json) { 138 + importProgress = Math.max(0, Math.min(info.progress || 0, 1)); 139 + 140 + if (info.progress == 1.0) { 141 + importStatusLabel = `Import complete โœ“`; 142 + } else if (info.position) { 143 + let date = new Date(info.position).toLocaleString(settings.dateLocale, { day: 'numeric', month: 'short', year: 'numeric' }); 144 + importStatusLabel = `Downloaded data until: ${date}`; 145 + } else if (info.status == 'requested') { 146 + importStatusLabel = 'Requesting importโ€ฆ'; 147 + } else { 148 + importStatusLabel = 'Import startedโ€ฆ'; 149 + } 150 + } 151 + 152 + function showImportError(message: string) { 153 + importStatus = 'error'; 154 + wasImporting = true; 155 + importStatusLabel = message; 156 + stopImportTimer(); 157 + } 158 + 159 + function startImportTimer() { 160 + if (!importTimer) { 161 + importTimer = setInterval(checkImportStatus, 3000); 162 + } 163 + } 164 + 165 + function stopImportTimer() { 166 + if (importTimer) { 167 + clearInterval(importTimer); 168 + importTimer = undefined; 169 + } 170 + } 171 + </script> 172 + 173 + <SearchPage> 174 + <h2>Archive search</h2> 175 + 176 + <form class="search-form"> 177 + <p class="search"> 178 + Search: 179 + <input type="text" class="search-query" autocomplete="off" 180 + disabled={importStatus != 'finished'} onkeydown={onKeyPress} bind:value={query}> 181 + </p> 182 + 183 + <div class="search-collections"> 184 + {#each collections as col} 185 + <input type="radio" name="collection" value={col.id} id="collection-{col.id}" bind:group={selectedCollection}> 186 + <label for="collection-{col.id}">{col.title}</label> 187 + {/each} 188 + </div> 189 + </form> 190 + 191 + {#if wasImporting || importStatus == 'not_started'} 192 + <div class="lycan-import"> 193 + {#if importStatus == 'not_started'} 194 + <form onsubmit={onFormSubmit}> 195 + <h4>Data not imported yet</h4> 196 + 197 + <p> 198 + In order to search within your likes and bookmarks, the posts you've liked or saved need to be imported into a database. 199 + This is a one-time process, but it can take several minutes or more, depending on the age of your account. 200 + </p> 201 + <p> 202 + 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. 203 + After the import is complete, the database will be kept up to date automatically going forward. 204 + </p> 205 + <p> 206 + <input type="submit" value="Start import"> 207 + </p> 208 + </form> 209 + {:else} 210 + <div class="import-progress"> 211 + <h4>Import in progress</h4> 212 + 213 + <p class="import-status">{importStatusLabel}</p> 214 + 215 + {#if importStatus != 'error'} 216 + <p> 217 + <progress value={importProgress}></progress> 218 + <output>{Math.round(importProgress * 100)}%</output> 219 + </p> 220 + {/if} 221 + </div> 222 + {/if} 223 + </div> 224 + {/if} 225 + 226 + <div class="results"> 227 + {#if loadingPosts} 228 + <p>...</p> 229 + {:else} 230 + {#each results as post (post.uri)} 231 + <PostComponent {post} placement="feed" {highlightedMatches} /> 232 + {/each} 233 + {#if finishedPosts} 234 + <p class="results-end">{results.length > 0 ? "No more results." : "No results."}</p> 235 + {/if} 236 + {/if} 237 + </div> 238 + </SearchPage> 239 + 240 + <style> 241 + .search-collections label { 242 + vertical-align: middle; 243 + margin-right: 5px; 244 + } 245 + 246 + .lycan-import { 247 + margin-top: 30px; 248 + border-top: 1px solid #ccc; 249 + padding-top: 5px; 250 + } 251 + 252 + .lycan-import form p { 253 + line-height: 135%; 254 + } 255 + 256 + .import-progress progress { 257 + margin-left: 0; 258 + margin-right: 6px; 259 + } 260 + 261 + .import-progress progress + output { 262 + font-size: 11pt; 263 + } 264 + 265 + @media (prefers-color-scheme: dark) { 266 + .lycan-import { border-top-color: #888; } 267 + } 268 + </style>
+93
src/pages/NotificationsPage.svelte
··· 1 + <script lang="ts"> 2 + import { accountAPI } from '../api.js'; 3 + import { Post } from '../models/posts.js'; 4 + import * as paginator from '../utils/paginator.js'; 5 + import FeedPostParent from '../components/posts/FeedPostParent.svelte'; 6 + import MainLoader from '../components/MainLoader.svelte'; 7 + import PostComponent from '../components/posts/PostComponent.svelte'; 8 + 9 + let posts: Post[] = $state([]); 10 + let firstPageLoaded = $state(false); 11 + let loadingFailed = $state(false); 12 + 13 + let isLoading = false; 14 + let finished = false; 15 + let cursor: string | undefined; 16 + 17 + paginator.loadInPages(async (next) => { 18 + if (isLoading || finished) { return } 19 + isLoading = true; 20 + 21 + try { 22 + let data = await accountAPI.loadMentions(cursor); 23 + let batch = data.posts.map(x => new Post(x)); 24 + 25 + if (!firstPageLoaded && batch.length > 0) { 26 + firstPageLoaded = true; 27 + } 28 + 29 + posts.push(...batch); 30 + 31 + isLoading = false; 32 + cursor = data.cursor; 33 + 34 + if (!cursor) { 35 + finished = true; 36 + } else if (batch.length == 0) { 37 + next(); 38 + } 39 + } catch(error) { 40 + console.log(error); 41 + isLoading = false; 42 + loadingFailed = true; 43 + } 44 + }); 45 + </script> 46 + 47 + <svelte:head> 48 + <title>Notifications - Skythread</title> 49 + </svelte:head> 50 + 51 + {#if firstPageLoaded} 52 + <main class="notifications"> 53 + <header> 54 + <h2>Replies & Mentions:</h2> 55 + </header> 56 + 57 + {#each posts as post (post.uri)} 58 + <!-- note: posts here are loaded via getPosts, so they don't include full parent/thread info --> 59 + {#if post.parentReference} 60 + <FeedPostParent uri={post.parentReference.uri} /> 61 + {/if} 62 + 63 + <PostComponent {post} placement="feed" /> 64 + {/each} 65 + </main> 66 + {:else if !loadingFailed} 67 + <MainLoader /> 68 + {/if} 69 + 70 + <style> 71 + .notifications :global { 72 + .post { 73 + padding-bottom: 4px; 74 + border-bottom: 1px solid #ddd; 75 + margin-top: 24px; 76 + } 77 + 78 + .back { 79 + margin-left: 22px; 80 + margin-bottom: -12px; 81 + margin-top: 15px; 82 + } 83 + 84 + .back, .back a { 85 + font-size: 10pt; 86 + } 87 + 88 + .back i { 89 + font-size: 9pt; 90 + margin-right: 2px; 91 + } 92 + } 93 + </style>
+203
src/pages/PostingStatsPage.svelte
··· 1 + <script lang="ts"> 2 + import UserAutocomplete, { type AutocompleteUser } from '../components/UserAutocomplete.svelte'; 3 + import PostingStatsTable, { type TableOptions } from '../components/PostingStatsTable.svelte'; 4 + import { accountAPI } from '../api.js'; 5 + import { PostingStats, type PostingStatsResult } from '../services/posting_stats.js'; 6 + import { numberOfDays } from '../utils.js'; 7 + 8 + const tabs = [ 9 + { id: 'home', title: 'Home timeline' }, 10 + { id: 'list', title: 'List feed' }, 11 + { id: 'users', title: 'Selected users' }, 12 + { id: 'you', title: 'Your profile' } 13 + ] as const; 14 + 15 + let lists: json[] = $state([]); 16 + 17 + let timeRangeDays = $state(7); 18 + let selectedTab: typeof tabs[number]['id'] = $state(tabs[0].id); 19 + let selectedUsers: AutocompleteUser[] = $state([]); 20 + let selectedList: string | undefined = $state(); 21 + 22 + let scanInProgress = $state(false); 23 + let requestedDays: number | undefined = $state(); 24 + let progress: number | undefined = $state(); 25 + let scanInfo = $state(); 26 + 27 + let tableOptions: TableOptions = $state({}); 28 + let results: PostingStatsResult | null = $state(null); 29 + 30 + let scanner = new PostingStats((p) => { progress = Math.max(progress || 0, p) }); 31 + 32 + $effect(() => { 33 + fetchLists(); 34 + }) 35 + 36 + function onTabChange() { 37 + results = null; 38 + } 39 + 40 + async function fetchLists() { 41 + let result = await accountAPI.loadUserLists(); 42 + 43 + lists = result.sort((a, b) => { 44 + let aName = a.name.toLocaleLowerCase(); 45 + let bName = b.name.toLocaleLowerCase(); 46 + 47 + return aName.localeCompare(bName); 48 + }); 49 + 50 + selectedList = lists[0]?.uri; 51 + } 52 + 53 + async function onsubmit(e: Event) { 54 + e.preventDefault(); 55 + 56 + try { 57 + if (!scanInProgress) { 58 + await runScan(); 59 + } else { 60 + scanInProgress = false; 61 + scanner.abortScan(); 62 + } 63 + } catch (error) { 64 + if (error.name !== 'AbortError') { 65 + throw error; 66 + } 67 + } 68 + } 69 + 70 + async function runScan() { 71 + if ((selectedTab == 'list' && !selectedList) || (selectedTab == 'users' && selectedUsers.length == 0)) { 72 + return; 73 + } 74 + 75 + scanInfo = undefined; 76 + results = null; 77 + requestedDays = timeRangeDays; 78 + progress = 0; 79 + scanInProgress = true; 80 + 81 + let startTime = new Date().getTime(); 82 + let data: PostingStatsResult | null; 83 + let options: TableOptions; 84 + 85 + if (selectedTab == 'home') { 86 + options = {}; 87 + data = await scanner.scanHomeTimeline(requestedDays); 88 + } else if (selectedTab == 'list') { 89 + options = { showReposts: false }; 90 + data = await scanner.scanListTimeline(selectedList!, requestedDays); 91 + } else if (selectedTab == 'users') { 92 + options = { showTotal: false, showPercentages: false }; 93 + data = await scanner.scanUserTimelines(selectedUsers, requestedDays); 94 + } else { // selectedTab == 'you' 95 + options = { showTotal: false, showPercentages: false }; 96 + data = await scanner.scanYourTimeline(requestedDays); 97 + } 98 + 99 + let now = new Date().getTime(); 100 + 101 + if (now - startTime < 150) { 102 + // artificial UI delay in case scan finishes immediately 103 + await new Promise(resolve => setTimeout(resolve, 150)); 104 + } 105 + 106 + tableOptions = options; 107 + results = data; 108 + scanInProgress = false; 109 + } 110 + </script> 111 + 112 + <main> 113 + <h2>Bluesky posting statistics</h2> 114 + 115 + <form {onsubmit}> 116 + <p> 117 + Scan posts from: 118 + 119 + {#each tabs as tab} 120 + <input type="radio" name="scan_type" id="scan_type_{tab.id}" value="{tab.id}" bind:group={selectedTab} onclick={onTabChange}> 121 + <label for="scan_type_{tab.id}">{tab.title}</label> 122 + {/each} 123 + </p> 124 + 125 + <p> 126 + Time range: <input id="posting_stats_range" type="range" min="1" max="60" bind:value={timeRangeDays}> 127 + <label for="posting_stats_range">{numberOfDays(timeRangeDays)}</label> 128 + </p> 129 + 130 + {#if selectedTab == 'list'} 131 + <p class="list-choice"> 132 + <label for="posting_stats_list">Select list:</label> 133 + <select id="posting_stats_list" name="scan_list" bind:value={selectedList}> 134 + {#each lists as list} 135 + <option value={list.uri}>{list.name}ย </option> 136 + {/each} 137 + </select> 138 + </p> 139 + {/if} 140 + 141 + {#if selectedTab == 'users'} 142 + <UserAutocomplete bind:selectedUsers /> 143 + {/if} 144 + 145 + <p> 146 + <input type="submit" value="{!scanInProgress ? 'Start scan' : 'Cancel'}"> 147 + 148 + {#if scanInProgress} 149 + <progress max={requestedDays} value={progress}></progress> 150 + {/if} 151 + </p> 152 + </form> 153 + 154 + {#if scanInfo} 155 + <p class="scan-info">{scanInfo}</p> 156 + {/if} 157 + 158 + {#if results} 159 + <PostingStatsTable {...tableOptions} {...results} /> 160 + {/if} 161 + </main> 162 + 163 + <style> 164 + input[type="radio"] { 165 + position: relative; 166 + top: -1px; 167 + margin-left: 5px; 168 + } 169 + 170 + input[type="radio"] + label { 171 + user-select: none; 172 + -webkit-user-select: none; 173 + margin-right: 4px; 174 + } 175 + 176 + input[type="range"] { 177 + width: 250px; 178 + vertical-align: middle; 179 + } 180 + 181 + input[type="submit"] { 182 + font-size: 12pt; 183 + margin: 5px 0px; 184 + padding: 5px 10px; 185 + } 186 + 187 + select { 188 + font-size: 12pt; 189 + margin-left: 5px; 190 + } 191 + 192 + progress { 193 + width: 300px; 194 + margin-left: 10px; 195 + vertical-align: middle; 196 + } 197 + 198 + .scan-info { 199 + font-weight: 600; 200 + line-height: 125%; 201 + margin: 20px 0px; 202 + } 203 + </style>
+93
src/pages/QuotesPage.svelte
··· 1 + <script lang="ts"> 2 + import { api, blueAPI } from '../api.js'; 3 + import { Post } from '../models/posts.js'; 4 + import { showError } from '../utils.js'; 5 + import * as paginator from '../utils/paginator.js'; 6 + import FeedPostParent from '../components/posts/FeedPostParent.svelte'; 7 + import MainLoader from '../components/MainLoader.svelte'; 8 + import PostComponent from '../components/posts/PostComponent.svelte'; 9 + 10 + let isLoading = false; 11 + let cursor: string | undefined; 12 + let finished = false; 13 + 14 + let { postURL }: { postURL: string } = $props(); 15 + 16 + let posts: Post[] = $state([]); 17 + let quoteCount: number | undefined = $state(); 18 + let loadingFailed = $state(false); 19 + 20 + paginator.loadInPages(async () => { 21 + if (isLoading || finished) { return } 22 + isLoading = true; 23 + 24 + try { 25 + let data = await blueAPI.getQuotes(postURL, cursor); 26 + let jsons = await api.loadPosts(data.posts); 27 + let batch = jsons.map(j => new Post(j)); 28 + 29 + if (quoteCount === undefined) { 30 + quoteCount = data.quoteCount; 31 + } 32 + 33 + posts.push(...batch); 34 + 35 + isLoading = false; 36 + cursor = data.cursor; 37 + 38 + if (!cursor || posts.length == 0) { 39 + finished = true; 40 + } 41 + } catch(error) { 42 + console.log(error); 43 + isLoading = false; 44 + loadingFailed = true; 45 + showError(error); 46 + } 47 + }); 48 + </script> 49 + 50 + {#if quoteCount !== undefined} 51 + <main class="quotes"> 52 + <header> 53 + <h2> 54 + {#if quoteCount > 1} 55 + {quoteCount} quotes: 56 + {:else if quoteCount == 1} 57 + 1 quote: 58 + {:else} 59 + No quotes found. 60 + {/if} 61 + </h2> 62 + </header> 63 + 64 + {#each posts as post (post.uri)} 65 + <!-- note: posts here are loaded via getPosts, so they don't include full parent/thread info --> 66 + {#if post.parentReference} 67 + <FeedPostParent uri={post.parentReference.uri} /> 68 + {/if} 69 + 70 + <PostComponent {post} placement="quotes" /> 71 + {/each} 72 + </main> 73 + {:else if !loadingFailed} 74 + <MainLoader /> 75 + {/if} 76 + 77 + <style> 78 + .quotes :global(p.back) { 79 + padding-left: 10px; 80 + } 81 + 82 + .quotes :global(.post) { 83 + padding-bottom: 5px; 84 + } 85 + 86 + .quotes :global(.post-quote .quote-embed) { 87 + display: none; 88 + } 89 + 90 + .quotes :global(.post-quote p.stats) { 91 + display: none; 92 + } 93 + </style>
+68
src/pages/SearchPage.svelte
··· 1 + <script lang="ts"> 2 + let { children } = $props(); 3 + </script> 4 + 5 + <main class="search-page"> 6 + {@render children()} 7 + </main> 8 + 9 + <style> 10 + .search-page :global { 11 + input[type="submit"] { 12 + font-size: 12pt; 13 + margin: 5px 0px; 14 + padding: 5px 10px; 15 + } 16 + 17 + progress { 18 + width: 300px; 19 + margin-left: 10px; 20 + vertical-align: middle; 21 + } 22 + 23 + .search-query { 24 + font-size: 12pt; 25 + border: 1px solid #ccc; 26 + border-radius: 6px; 27 + padding: 5px 6px; 28 + margin-left: 8px; 29 + } 30 + 31 + .results { 32 + margin-top: 30px; 33 + } 34 + 35 + .results > .post { 36 + margin-left: -15px; 37 + padding-left: 15px; 38 + border-bottom: 1px solid #ddd; 39 + padding-bottom: 10px; 40 + margin-top: 24px; 41 + } 42 + 43 + .results-end { 44 + font-size: 12pt; 45 + color: #333; 46 + } 47 + 48 + .post + .results-end { 49 + font-size: 11pt; 50 + } 51 + } 52 + 53 + @media (prefers-color-scheme: dark) { 54 + .search-page :global { 55 + .search-query { 56 + border: 1px solid #666; 57 + } 58 + 59 + .results-end { 60 + color: #888; 61 + } 62 + 63 + .results > .post { 64 + border-bottom: 1px solid #555; 65 + } 66 + } 67 + } 68 + </style>
+78
src/pages/ThreadPage.svelte
··· 1 + <script lang="ts"> 2 + import { api, blueAPI } from '../api.js'; 3 + import { Post, parseThreadPost } from '../models/posts.js'; 4 + import { showError } from '../utils.js'; 5 + import MainLoader from '../components/MainLoader.svelte'; 6 + import PostComponent from '../components/posts/PostComponent.svelte'; 7 + import PostWrapper from '../components/posts/PostWrapper.svelte'; 8 + import ThreadRootParent from '../components/posts/ThreadRootParent.svelte'; 9 + import ThreadRootParentRaw from '../components/posts/ThreadRootParentRaw.svelte'; 10 + 11 + type Props = { url: string } | { author: string, rkey: string }; 12 + 13 + let props: Props = $props(); 14 + let post: AnyPost | undefined = $state(); 15 + let loadingFailed = $state(false); 16 + 17 + let rootComponent: PostComponent | undefined = $state(); 18 + let response: Promise<json>; 19 + 20 + if ('url' in props) { 21 + let { url } = props; 22 + 23 + if (url.startsWith('at://')) { 24 + response = api.loadThreadByAtURI(url); 25 + } else { 26 + response = api.loadThreadByURL(url); 27 + } 28 + } else { 29 + let { author, rkey } = props; 30 + 31 + response = api.loadThreadById(author, rkey); 32 + } 33 + 34 + response.then((json) => { 35 + let root = parseThreadPost(json.thread); 36 + window.root = root; 37 + window.subtreeRoot = root; 38 + 39 + post = root; 40 + 41 + if (root instanceof Post) { 42 + root.data.quoteCount = undefined; 43 + 44 + blueAPI.getQuoteCount(root.uri).then(count => { 45 + rootComponent?.setQuoteCount(count); 46 + }).catch(error => { 47 + console.warn("Couldn't load quote count: " + error); 48 + }); 49 + } 50 + }).catch((error) => { 51 + showError(error); 52 + loadingFailed = true; 53 + }); 54 + </script> 55 + 56 + <svelte:head> 57 + {#if post instanceof Post} 58 + <title>{post.author.displayName}: "{post.text}" - Skythread</title> 59 + {/if} 60 + </svelte:head> 61 + 62 + {#if post} 63 + <main> 64 + {#if post instanceof Post} 65 + {#if post.parent} 66 + <ThreadRootParent post={post.parent} /> 67 + {:else if post.parentReference} 68 + <ThreadRootParentRaw uri={post.parentReference.uri} /> 69 + {/if} 70 + 71 + <PostComponent {post} placement="thread" bind:this={rootComponent} /> 72 + {:else} 73 + <PostWrapper {post} placement="thread" /> 74 + {/if} 75 + </main> 76 + {:else if !loadingFailed} 77 + <MainLoader /> 78 + {/if}
+101
src/pages/TimelineSearchPage.svelte
··· 1 + <script lang="ts"> 2 + import PostComponent from '../components/posts/PostComponent.svelte'; 3 + import SearchPage from './SearchPage.svelte'; 4 + import { Post } from '../models/posts'; 5 + import { TimelineSearch } from '../services/timeline_search.js'; 6 + import { numberOfDays } from '../utils.js'; 7 + 8 + let timeRangeDays = $state(7); 9 + let progressMax: number | undefined = $state(); 10 + let progress: number | undefined = $state(); 11 + let fetchInProgress = $derived(progress !== undefined); 12 + let daysFetched: number | undefined = $state(); 13 + 14 + let query = $state(''); 15 + let results: Post[] = $state([]); 16 + 17 + let timelineSearch = new TimelineSearch(); 18 + 19 + async function startScan(e: Event) { 20 + e.preventDefault(); 21 + 22 + try { 23 + if (!fetchInProgress) { 24 + progressMax = timeRangeDays; 25 + progress = 0; 26 + 27 + await timelineSearch.fetchTimeline(timeRangeDays, (p) => { progress = p }); 28 + 29 + daysFetched = progress; 30 + progress = undefined; 31 + } else { 32 + progress = undefined; 33 + timelineSearch.abortFetch(); 34 + } 35 + } catch (error) { 36 + if (error.name !== 'AbortError') { 37 + throw error; 38 + } 39 + } 40 + } 41 + 42 + function onKeyPress(e: KeyboardEvent) { 43 + if (e.key == 'Enter') { 44 + e.preventDefault(); 45 + 46 + let q = query.trim().toLowerCase(); 47 + results = timelineSearch.searchPosts(q); 48 + } 49 + } 50 + </script> 51 + 52 + <SearchPage> 53 + <h2>Timeline search</h2> 54 + 55 + <div class="timeline-search"> 56 + <form onsubmit={startScan}> 57 + <p> 58 + Fetch timeline posts: <input id="timeline_search_range" type="range" min="1" max="60" bind:value={timeRangeDays}> 59 + <label for="timeline_search_range">{numberOfDays(timeRangeDays)}</label> 60 + </p> 61 + 62 + <p> 63 + <input type="submit" value="{fetchInProgress ? 'Cancel' : 'Fetch timeline'}"> 64 + 65 + {#if fetchInProgress} 66 + <progress max={progressMax} value={progress}></progress> 67 + {/if} 68 + </p> 69 + </form> 70 + 71 + {#if daysFetched} 72 + <p class="archive-status"> 73 + Timeline archive fetched: {numberOfDays(Math.round(daysFetched))} 74 + </p> 75 + {/if} 76 + 77 + <hr> 78 + </div> 79 + 80 + {#if daysFetched} 81 + <form class="search-form"> 82 + <p class="search"> 83 + Search: 84 + <input type="text" class="search-query" autocomplete="off" onkeydown={onKeyPress} bind:value={query}> 85 + </p> 86 + </form> 87 + 88 + <div class="results"> 89 + {#each results as post (post.uri)} 90 + <PostComponent {post} placement="feed" /> 91 + {/each} 92 + </div> 93 + {/if} 94 + </SearchPage> 95 + 96 + <style> 97 + input[type="range"] { 98 + width: 250px; 99 + vertical-align: middle; 100 + } 101 + </style>
+58
src/router.ts
··· 1 + import { URLError } from './api.js'; 2 + import { Post } from './models/posts.js'; 3 + 4 + export function getBaseLocation(): string { 5 + return location.origin + location.pathname; 6 + } 7 + 8 + export function linkToHashtagPage(hashtag: string): string { 9 + let url = new URL(getBaseLocation()); 10 + url.searchParams.set('hash', hashtag); 11 + return url.toString(); 12 + } 13 + 14 + export function linkToQuotesPage(postURL: string): string { 15 + let url = new URL(getBaseLocation()); 16 + url.searchParams.set('quotes', postURL); 17 + return url.toString(); 18 + } 19 + 20 + export function linkToPostThread(post: Post): string { 21 + return linkToPostById(post.author.handle, post.rkey); 22 + } 23 + 24 + export function linkToPostById(handle: string, postId: string): string { 25 + let url = new URL(getBaseLocation()); 26 + url.searchParams.set('author', handle); 27 + url.searchParams.set('post', postId); 28 + return url.toString(); 29 + } 30 + 31 + export function parseBlueskyPostURL(string: string): { user: string, post: string } { 32 + let url: URL; 33 + 34 + try { 35 + url = new URL(string); 36 + } catch (error) { 37 + throw new URLError(`${error}`); 38 + } 39 + 40 + if (url.protocol != 'https:' && url.protocol != 'http:') { 41 + throw new URLError('URL must start with http(s)://'); 42 + } 43 + 44 + let parts = url.pathname.split('/'); 45 + 46 + if (parts.length < 5 || parts[1] != 'profile' || parts[3] != 'post') { 47 + throw new URLError('This is not a valid thread URL'); 48 + } 49 + 50 + let user = parts[2]; 51 + let post = parts[4]; 52 + 53 + return { user, post }; 54 + } 55 + 56 + export function parseURLParams(urlQuery: string): Record<string, string> { 57 + return Object.fromEntries(new URLSearchParams(urlQuery)); 58 + }
+210
src/services/like_stats.ts
··· 1 + import { atURI, feedPostTime } from '../utils.js'; 2 + import { BlueskyAPI, accountAPI } from '../api.js'; 3 + 4 + export type LikeStatsResponse = { givenLikes: LikeStat[], receivedLikes: LikeStat[] } 5 + export type LikeStat = { handle?: string, did?: string, avatar?: string, count: number } 6 + export type LikeStatHash = Record<string, LikeStat> 7 + 8 + export class LikeStats { 9 + scanStartTime: number | undefined; 10 + appView: BlueskyAPI; 11 + progressPosts: number; 12 + progressLikeRecords: number; 13 + progressPostLikes: number; 14 + onProgress: ((days: number) => void) | undefined 15 + abortController?: AbortController; 16 + 17 + constructor() { 18 + this.appView = new BlueskyAPI('public.api.bsky.app'); 19 + 20 + this.progressPosts = 0; 21 + this.progressLikeRecords = 0; 22 + this.progressPostLikes = 0; 23 + } 24 + 25 + async findLikes(requestedDays: number, onProgress: (days: number) => void): Promise<LikeStatsResponse> { 26 + this.onProgress = onProgress; 27 + this.resetProgress(); 28 + this.scanStartTime = new Date().getTime(); 29 + this.abortController = new AbortController(); 30 + 31 + let fetchGivenLikes = this.fetchGivenLikes(requestedDays); 32 + 33 + let receivedLikes = await this.fetchReceivedLikes(requestedDays); 34 + let receivedStats = this.sumUpReceivedLikes(receivedLikes); 35 + let topReceived = this.getTopEntries(receivedStats); 36 + 37 + let givenLikes = await fetchGivenLikes; 38 + let givenStats = this.sumUpGivenLikes(givenLikes); 39 + let topGiven = this.getTopEntries(givenStats); 40 + 41 + let profileInfo = await this.appView.getRequest('app.bsky.actor.getProfiles', 42 + { actors: topGiven.map(x => x.did) }, 43 + { abortSignal: this.abortController!.signal } 44 + ); 45 + 46 + for (let profile of profileInfo.profiles) { 47 + let user = topGiven.find(x => x.did == profile.did)!; 48 + user.handle = profile.handle; 49 + user.avatar = profile.avatar; 50 + } 51 + 52 + this.scanStartTime = undefined; 53 + 54 + return { givenLikes: topGiven, receivedLikes: topReceived }; 55 + } 56 + 57 + async fetchGivenLikes(requestedDays: number): Promise<json[]> { 58 + let startTime = this.scanStartTime! 59 + 60 + return await accountAPI.fetchAll('com.atproto.repo.listRecords', { 61 + params: { 62 + repo: accountAPI.user.did, 63 + collection: 'app.bsky.feed.like', 64 + limit: 100 65 + }, 66 + field: 'records', 67 + breakWhen: (x) => Date.parse(x['value']['createdAt']) < startTime - 86400 * requestedDays * 1000, 68 + onPageLoad: (data) => { 69 + let last = data.at(-1); 70 + 71 + if (!last) { return } 72 + 73 + let lastDate = Date.parse(last.value.createdAt); 74 + let daysBack = (startTime - lastDate) / 86400 / 1000; 75 + 76 + this.updateProgress({ likeRecords: Math.min(1.0, daysBack / requestedDays) }); 77 + }, 78 + abortSignal: this.abortController!.signal 79 + }); 80 + } 81 + 82 + async fetchReceivedLikes(requestedDays: number): Promise<json[]> { 83 + let startTime = this.scanStartTime! 84 + 85 + let myPosts = await this.appView.loadUserTimeline(accountAPI.user.did, requestedDays, { 86 + filter: 'posts_with_replies', 87 + onPageLoad: (data) => { 88 + let last = data.at(-1); 89 + 90 + if (!last) { return } 91 + 92 + let lastDate = feedPostTime(last); 93 + let daysBack = (startTime - lastDate) / 86400 / 1000; 94 + 95 + this.updateProgress({ posts: Math.min(1.0, daysBack / requestedDays) }); 96 + }, 97 + abortSignal: this.abortController!.signal 98 + }); 99 + 100 + let likedPosts = myPosts.filter(x => !x['reason'] && x['post']['likeCount'] > 0); 101 + 102 + let results: json[][] = []; 103 + 104 + for (let i = 0; i < likedPosts.length; i += 10) { 105 + let batch = likedPosts.slice(i, i + 10); 106 + this.updateProgress({ postLikes: i / likedPosts.length }); 107 + 108 + let fetchBatch = batch.map(x => { 109 + return this.appView.fetchAll('app.bsky.feed.getLikes', { 110 + params: { 111 + uri: x['post']['uri'], 112 + limit: 100 113 + }, 114 + field: 'likes', 115 + abortSignal: this.abortController!.signal 116 + }); 117 + }); 118 + 119 + let batchResults = await Promise.all(fetchBatch); 120 + results = results.concat(batchResults); 121 + } 122 + 123 + this.updateProgress({ postLikes: 1.0 }); 124 + 125 + return results.flat(); 126 + } 127 + 128 + sumUpReceivedLikes(likes: json[]): LikeStatHash { 129 + let stats: LikeStatHash = {}; 130 + 131 + for (let like of likes) { 132 + let handle = like.actor.handle; 133 + 134 + if (!stats[handle]) { 135 + stats[handle] = { handle: handle, count: 0, avatar: like.actor.avatar }; 136 + } 137 + 138 + stats[handle].count += 1; 139 + } 140 + 141 + return stats; 142 + } 143 + 144 + sumUpGivenLikes(likes: json[]): LikeStatHash { 145 + let stats: LikeStatHash = {}; 146 + 147 + for (let like of likes) { 148 + let did = atURI(like.value.subject.uri).repo; 149 + 150 + if (!stats[did]) { 151 + stats[did] = { did: did, count: 0 }; 152 + } 153 + 154 + stats[did].count += 1; 155 + } 156 + 157 + return stats; 158 + } 159 + 160 + getTopEntries(counts: LikeStatHash): LikeStat[] { 161 + return Object.entries(counts).sort(this.sortResults).map(x => x[1]).slice(0, 25); 162 + } 163 + 164 + resetProgress() { 165 + this.progressPosts = 0; 166 + this.progressLikeRecords = 0; 167 + this.progressPostLikes = 0; 168 + 169 + this.onProgress?.(0); 170 + } 171 + 172 + updateProgress(data: { posts?: number, likeRecords?: number, postLikes?: number }) { 173 + if (data.posts) { 174 + this.progressPosts = data.posts; 175 + } 176 + 177 + if (data.likeRecords) { 178 + this.progressLikeRecords = data.likeRecords; 179 + } 180 + 181 + if (data.postLikes) { 182 + this.progressPostLikes = data.postLikes; 183 + } 184 + 185 + let totalProgress = ( 186 + 0.1 * this.progressPosts + 187 + 0.65 * this.progressLikeRecords + 188 + 0.25 * this.progressPostLikes 189 + ); 190 + 191 + this.onProgress?.(totalProgress); 192 + } 193 + 194 + sortResults(a: [string, LikeStat], b: [string, LikeStat]): -1 | 1 | 0 { 195 + if (a[1].count < b[1].count) { 196 + return 1; 197 + } else if (a[1].count > b[1].count) { 198 + return -1; 199 + } else { 200 + return 0; 201 + } 202 + } 203 + 204 + abortScan() { 205 + this.scanStartTime = undefined; 206 + this.onProgress = undefined; 207 + this.abortController?.abort(); 208 + delete this.abortController; 209 + } 210 + }
+85
src/services/lycan.ts
··· 1 + import { Post } from '../models/posts.js'; 2 + import * as paginator from '../utils/paginator.js'; 3 + import { BlueskyAPI, accountAPI } from '../api.js'; 4 + 5 + export type OnPostsLoaded = (data: { posts: Post[], terms: string[] }) => void 6 + export type OnFinish = () => void 7 + 8 + const DEFAULT_LYCAN = "did:web:lycan.feeds.blue#lycan"; 9 + 10 + export class Lycan { 11 + lycanAddress: string; 12 + 13 + constructor(address?: string) { 14 + this.lycanAddress = address ?? DEFAULT_LYCAN; 15 + } 16 + 17 + get proxyHeaders() { 18 + return { 'atproto-proxy': this.lycanAddress }; 19 + } 20 + 21 + async getImportStatus() { 22 + return await accountAPI.getRequest('blue.feeds.lycan.getImportStatus', null, { headers: this.proxyHeaders }); 23 + } 24 + 25 + async startImport() { 26 + await accountAPI.postRequest('blue.feeds.lycan.startImport', null, { headers: this.proxyHeaders }); 27 + } 28 + 29 + async makeQuery(collection: string, query: string, cursor: string | undefined) { 30 + let params: Record<string, string> = { collection, query }; 31 + if (cursor) params.cursor = cursor; 32 + 33 + return await accountAPI.getRequest('blue.feeds.lycan.searchPosts', params, { headers: this.proxyHeaders }); 34 + } 35 + 36 + searchPosts(collection: string, query: string, callbacks: { onPostsLoaded: OnPostsLoaded, onFinish: OnFinish }) { 37 + let isLoading = false; 38 + let finished = false; 39 + let cursor: string | undefined; 40 + 41 + paginator.loadInPages(async () => { 42 + if (isLoading || finished) { return; } 43 + isLoading = true; 44 + 45 + let response = await this.makeQuery(collection, query, cursor); 46 + let records = await accountAPI.loadPosts(response.posts); 47 + let posts = records.map(x => new Post(x)); 48 + 49 + isLoading = false; 50 + 51 + callbacks.onPostsLoaded({ posts: posts, terms: response.terms }); 52 + 53 + cursor = response.cursor; 54 + 55 + if (!cursor) { 56 + finished = true; 57 + callbacks.onFinish?.() 58 + } 59 + }); 60 + } 61 + } 62 + 63 + export class DevLycan extends Lycan { 64 + localLycan: BlueskyAPI; 65 + 66 + constructor(host: string) { 67 + super(); 68 + this.localLycan = new BlueskyAPI(host); 69 + } 70 + 71 + override async getImportStatus() { 72 + return await this.localLycan.getRequest('blue.feeds.lycan.getImportStatus', { user: accountAPI.user.did }); 73 + } 74 + 75 + override async startImport() { 76 + await this.localLycan.postRequest('blue.feeds.lycan.startImport', { user: accountAPI.user.did }); 77 + } 78 + 79 + override async makeQuery(collection: string, query: string, cursor: string | undefined) { 80 + let params: Record<string, string> = { collection, query, user: accountAPI.user.did }; 81 + if (cursor) params.cursor = cursor; 82 + 83 + return await this.localLycan.getRequest('blue.feeds.lycan.searchPosts', params); 84 + } 85 + }
+213
src/services/posting_stats.ts
··· 1 + import { BlueskyAPI, accountAPI } from '../api.js'; 2 + import { feedPostTime } from '../utils.js'; 3 + 4 + /** 5 + * Manages the Posting Stats page. 6 + */ 7 + 8 + type GenerateResultsOptions = { 9 + countFetchedDays?: boolean 10 + users?: UserWithHandle[] 11 + } 12 + 13 + export type OnProgress = ((progress: number) => void); 14 + 15 + export type UserWithHandle = { 16 + did: string, 17 + handle: string, 18 + avatar?: string 19 + } 20 + 21 + export type PostingStatsResultRow = { 22 + handle: string, 23 + avatar: string | undefined, 24 + own: number, 25 + reposts: number, 26 + all: number 27 + } 28 + 29 + export type PostingStatsResult = { 30 + users: PostingStatsResultRow[], 31 + sums: { own: number, reposts: number, all: number }, 32 + fetchedDays: number, 33 + daysBack: number 34 + } 35 + 36 + export class PostingStats { 37 + appView: BlueskyAPI; 38 + userProgress: Record<string, { pages: number, progress: number }>; 39 + onProgress: OnProgress | undefined; 40 + abortController?: AbortController; 41 + 42 + constructor(onProgress?: OnProgress) { 43 + this.onProgress = onProgress; 44 + this.appView = new BlueskyAPI('public.api.bsky.app'); 45 + this.userProgress = {}; 46 + } 47 + 48 + async scanHomeTimeline(requestedDays: number): Promise<PostingStatsResult | null> { 49 + let startTime = new Date().getTime(); 50 + this.abortController = new AbortController(); 51 + 52 + let posts = await accountAPI.loadHomeTimeline(requestedDays, { 53 + onPageLoad: (data) => this.updateProgress(data, startTime), 54 + abortSignal: this.abortController.signal, 55 + keepLastPage: true 56 + }); 57 + 58 + return this.generateResults(posts, requestedDays, startTime); 59 + } 60 + 61 + async scanListTimeline(listURI: string, requestedDays: number): Promise<PostingStatsResult | null> { 62 + let startTime = new Date().getTime(); 63 + this.abortController = new AbortController(); 64 + 65 + let posts = await accountAPI.loadListTimeline(listURI, requestedDays, { 66 + onPageLoad: (data) => this.updateProgress(data, startTime), 67 + abortSignal: this.abortController.signal, 68 + keepLastPage: true 69 + }); 70 + 71 + return this.generateResults(posts, requestedDays, startTime); 72 + } 73 + 74 + async scanUserTimelines(users: UserWithHandle[], requestedDays: number): Promise<PostingStatsResult | null> { 75 + let startTime = new Date().getTime(); 76 + let dids = users.map(u => u.did); 77 + this.resetUserProgress(dids); 78 + this.abortController = new AbortController(); 79 + 80 + let abortSignal = this.abortController.signal; 81 + let requests = dids.map(did => this.appView.loadUserTimeline(did, requestedDays, { 82 + filter: 'posts_and_author_threads', 83 + onPageLoad: (data) => this.updateUserProgress(did, data, startTime, requestedDays), 84 + abortSignal: abortSignal, 85 + keepLastPage: true 86 + })); 87 + 88 + let datasets = await Promise.all(requests); 89 + let posts = datasets.flat(); 90 + 91 + return this.generateResults(posts, requestedDays, startTime, { countFetchedDays: false, users: users }); 92 + } 93 + 94 + async scanYourTimeline(requestedDays: number): Promise<PostingStatsResult | null> { 95 + let startTime = new Date().getTime(); 96 + this.abortController = new AbortController(); 97 + 98 + let posts = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, { 99 + filter: 'posts_no_replies', 100 + onPageLoad: (data) => this.updateProgress(data, startTime), 101 + abortSignal: this.abortController.signal, 102 + keepLastPage: true 103 + }); 104 + 105 + return this.generateResults(posts, requestedDays, startTime); 106 + } 107 + 108 + generateResults(posts: json[], requestedDays: number, startTime: number, options: GenerateResultsOptions = {}) { 109 + let last = posts.at(-1); 110 + 111 + if (!last) { 112 + return null; 113 + } 114 + 115 + let users: Record<string, PostingStatsResultRow> = {}; 116 + 117 + let lastDate = feedPostTime(last); 118 + let fetchedDays = (startTime - lastDate) / 86400 / 1000; 119 + let daysBack: number; 120 + 121 + if (options.countFetchedDays !== false) { 122 + daysBack = Math.min(requestedDays, fetchedDays); 123 + } else { 124 + daysBack = requestedDays; 125 + } 126 + 127 + let timeLimit = startTime - requestedDays * 86400 * 1000; 128 + posts = posts.filter(x => (feedPostTime(x) > timeLimit)); 129 + posts.reverse(); 130 + 131 + if (options.users) { 132 + for (let user of options.users) { 133 + users[user.handle] = { handle: user.handle, own: 0, reposts: 0, avatar: user.avatar } as PostingStatsResultRow; 134 + } 135 + } 136 + 137 + let ownThreads = new Set(); 138 + let sums = { own: 0, reposts: 0, all: 0 }; 139 + 140 + for (let item of posts) { 141 + if (item.reply) { 142 + if (!ownThreads.has(item.reply.parent.uri)) { 143 + continue; 144 + } 145 + } 146 + 147 + let user = item.reason ? item.reason.by : item.post.author; 148 + let handle = user.handle; 149 + users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar }; 150 + 151 + if (item.reason) { 152 + users[handle].reposts += 1; 153 + sums.reposts += 1; 154 + } else { 155 + users[handle].own += 1; 156 + sums.own += 1; 157 + ownThreads.add(item.post.uri); 158 + } 159 + } 160 + 161 + let userRows = Object.values(users); 162 + userRows.forEach((u) => { u.all = u.own + u.reposts }); 163 + userRows.sort((a, b) => b.all - a.all); 164 + 165 + sums.all = sums.own + sums.reposts; 166 + 167 + return { users: userRows, sums, fetchedDays, daysBack }; 168 + } 169 + 170 + updateProgress(dataPage: json[], startTime: number) { 171 + let last = dataPage.at(-1); 172 + 173 + if (!last) { return } 174 + 175 + let lastDate = feedPostTime(last); 176 + let daysBack = (startTime - lastDate) / 86400 / 1000; 177 + 178 + this.onProgress?.(daysBack); 179 + } 180 + 181 + resetUserProgress(dids: string[]) { 182 + this.userProgress = {}; 183 + 184 + for (let did of dids) { 185 + this.userProgress[did] = { pages: 0, progress: 0 }; 186 + } 187 + } 188 + 189 + updateUserProgress(did: string, dataPage: json[], startTime: number, requestedDays: number) { 190 + let last = dataPage.at(-1); 191 + 192 + if (!last) { return } 193 + 194 + let lastDate = feedPostTime(last); 195 + let daysBack = (startTime - lastDate) / 86400 / 1000; 196 + 197 + this.userProgress[did].pages += 1; 198 + this.userProgress[did].progress = Math.min(daysBack / requestedDays, 1.0); 199 + 200 + let expectedPages = Object.values(this.userProgress).map(x => x.pages / x.progress); 201 + let known = expectedPages.filter(x => !isNaN(x)); 202 + let expectedTotalPages = known.reduce((a, b) => a + b) / known.length * expectedPages.length; 203 + let fetchedPages = Object.values(this.userProgress).map(x => x.pages).reduce((a, b) => a + b); 204 + 205 + let progress = (fetchedPages / expectedTotalPages) * requestedDays; 206 + this.onProgress?.(progress); 207 + } 208 + 209 + abortScan() { 210 + this.abortController?.abort(); 211 + delete this.abortController; 212 + } 213 + }
+56
src/services/timeline_search.ts
··· 1 + import { accountAPI } from '../api.js'; 2 + import { Post, parseFeedPost } from '../models/posts.js'; 3 + import { feedPostTime } from '../utils.js'; 4 + 5 + export class TimelineSearch { 6 + timelinePosts: json[]; 7 + abortController?: AbortController; 8 + 9 + constructor() { 10 + this.timelinePosts = []; 11 + } 12 + 13 + async fetchTimeline(requestedDays: number, onProgress: (progress: number) => void) { 14 + let startTime = new Date().getTime(); 15 + this.abortController = new AbortController(); 16 + 17 + let timeline = await accountAPI.loadHomeTimeline(requestedDays, { 18 + abortSignal: this.abortController.signal, 19 + onPageLoad: (data) => { 20 + let progress = this.calculateProgress(data, startTime); 21 + if (progress) { 22 + onProgress(progress); 23 + } 24 + } 25 + }); 26 + 27 + this.timelinePosts = timeline; 28 + } 29 + 30 + calculateProgress(dataPage: json[], startTime: number) { 31 + let last = dataPage.at(-1); 32 + 33 + if (!last) { return null; } 34 + 35 + let lastDate = feedPostTime(last); 36 + let daysBack = (startTime - lastDate) / 86400 / 1000; 37 + return daysBack; 38 + } 39 + 40 + searchPosts(query: string): Post[] { 41 + if (query.length == 0) { 42 + return []; 43 + } 44 + 45 + let matching = this.timelinePosts 46 + .filter(x => x.post.record.text.toLowerCase().includes(query)) 47 + .map(x => parseFeedPost(x)); 48 + 49 + return matching; 50 + } 51 + 52 + abortFetch() { 53 + this.abortController?.abort(); 54 + delete this.abortController; 55 + } 56 + }
+10
src/skythread.ts
··· 1 + import { mount } from 'svelte'; 2 + import { parseURLParams } from './router.js'; 3 + import App from './App.svelte'; 4 + 5 + function init() { 6 + let params = parseURLParams(location.search); 7 + mount(App, { target: document.body, props: { params }}); 8 + } 9 + 10 + document.addEventListener("DOMContentLoaded", init);
+13
src/types.d.ts
··· 1 + interface Window { 2 + root: AnyPost; 3 + subtreeRoot: AnyPost; 4 + } 5 + 6 + type json = Record<string, any>; 7 + 8 + type AnyPost = import("./models/posts.js").Post 9 + | import("./models/posts.js").BlockedPost 10 + | import("./models/posts.js").MissingPost 11 + | import("./models/posts.js").DetachedQuotePost; 12 + 13 + type PostPlacement = 'thread' | 'parent' | 'quote' | 'quotes' | 'feed';
+27
src/utils/at_uri.ts
··· 1 + import { URLError } from '../api.js'; 2 + 3 + export class AtURI { 4 + repo: string; 5 + collection: string; 6 + rkey: string; 7 + 8 + constructor(uri: string) { 9 + if (!uri.startsWith('at://')) { 10 + throw new URLError(`Not an at:// URI: ${uri}`); 11 + } 12 + 13 + let parts = uri.split('/'); 14 + 15 + if (parts.length != 5) { 16 + throw new URLError(`Invalid at:// URI: ${uri}`); 17 + } 18 + 19 + this.repo = parts[2]; 20 + this.collection = parts[3]; 21 + this.rkey = parts[4]; 22 + } 23 + } 24 + 25 + export function atURI(uri: string): AtURI { 26 + return new AtURI(uri); 27 + }
+15
src/utils/avatar_preloader.ts
··· 1 + function buildAvatarPreloader(): IntersectionObserver { 2 + return new IntersectionObserver((entries, observer) => { 3 + for (const entry of entries) { 4 + if (entry.isIntersecting) { 5 + const img = entry.target; 6 + img.removeAttribute('lazy'); 7 + observer.unobserve(img); 8 + } 9 + } 10 + }, { 11 + rootMargin: '1000px 0px' 12 + }); 13 + } 14 + 15 + export let avatarPreloader = buildAvatarPreloader();
+23
src/utils/paginator.ts
··· 1 + let scrollHandler: (() => void) | undefined; 2 + let resizeObserver: ResizeObserver | undefined; 3 + 4 + export function loadInPages(callback: (next: () => void) => void) { 5 + if (scrollHandler) { 6 + document.removeEventListener('scroll', scrollHandler); 7 + } 8 + 9 + resizeObserver?.disconnect(); 10 + 11 + scrollHandler = () => { 12 + if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 500) { 13 + callback(scrollHandler!); 14 + } 15 + }; 16 + 17 + callback(scrollHandler); 18 + 19 + document.addEventListener('scroll', scrollHandler); 20 + 21 + resizeObserver = new ResizeObserver(scrollHandler); 22 + resizeObserver.observe(document.body); 23 + }
+40
src/utils/post_presenter.ts
··· 1 + import { sameDay } from '../utils.js'; 2 + import { Post } from '../models/posts.js'; 3 + import { settings } from '../models/settings.svelte.js'; 4 + 5 + export class PostPresenter { 6 + 7 + /** 8 + * Contexts: 9 + * - thread - a post in the thread tree 10 + * - parent - parent reference above the thread root 11 + * - quote - a quote embed 12 + * - quotes - a post on the quotes page 13 + * - feed - a post on the hashtag feed page 14 + */ 15 + 16 + post: Post; 17 + placement: PostPlacement; 18 + 19 + constructor(post: Post, placement: PostPlacement) { 20 + this.post = post; 21 + this.placement = placement; 22 + } 23 + 24 + get timeFormatForTimestamp(): Intl.DateTimeFormatOptions { 25 + if (this.placement == 'quotes' || this.placement == 'feed') { 26 + return { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' }; 27 + } else if (this.post.isPageRoot || this.placement != 'thread') { 28 + return { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' }; 29 + } else if (this.post.pageRoot && !sameDay(this.post.createdAt, this.post.pageRoot.createdAt)) { 30 + return { day: 'numeric', month: 'short', hour: 'numeric', minute: 'numeric' }; 31 + } else { 32 + return { hour: 'numeric', minute: 'numeric' }; 33 + } 34 + } 35 + 36 + get formattedTimestamp() { 37 + let timeFormat = this.timeFormatForTimestamp; 38 + return this.post.createdAt.toLocaleString(settings.dateLocale, timeFormat); 39 + } 40 + }
+35
src/utils/text.ts
··· 1 + import DOMPurify from 'dompurify'; 2 + 3 + export function numberOfDays(days: number): string { 4 + return pluralize(days, 'day'); 5 + } 6 + 7 + export function pluralize(value: number, word: string, plural?: string) { 8 + if (value == 1) { 9 + return `1 ${word}`; 10 + } else { 11 + plural = plural ?? `${word}s`; 12 + return `${value} ${plural}`; 13 + } 14 + } 15 + 16 + export function sanitizeHTML(html: string): string { 17 + return DOMPurify.sanitize(html, { 18 + ALLOWED_TAGS: [ 19 + 'a', 'b', 'blockquote', 'br', 'code', 'dd', 'del', 'div', 'dl', 'dt', 'em', 'font', 20 + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'q', 'pre', 's', 'span', 'strong', 21 + 'sub', 'sup', 'u', 'wbr', '#text' 22 + ], 23 + ALLOWED_ATTR: [ 24 + 'align', 'alt', 'class', 'clear', 'color', 'dir', 'href', 'lang', 'rel', 'title', 'translate' 25 + ] 26 + }); 27 + } 28 + 29 + export function truncateText(text: string, maxLen: number): string { 30 + if (text.length <= maxLen) { 31 + return text; 32 + } else { 33 + return text.slice(0, maxLen - 1) + 'โ€ฆ'; 34 + } 35 + }
+39
src/utils.ts
··· 1 + export * from './utils/at_uri.js'; 2 + export * from './utils/avatar_preloader.js'; 3 + export * from './utils/text.js'; 4 + 5 + export function castToInt(value: any): number | null | undefined { 6 + if (value === undefined || value === null || typeof value == "number") { 7 + return value; 8 + } else { 9 + return parseInt(value, 10); 10 + } 11 + } 12 + 13 + export function feedPostTime(feedPost: json): number { 14 + let timestamp = feedPost.reason ? feedPost.reason.indexedAt : feedPost.post.record.createdAt; 15 + return Date.parse(timestamp); 16 + } 17 + 18 + export function isValidURL(url: string): boolean { 19 + try { 20 + new URL(url); 21 + return true; 22 + } catch (error) { 23 + console.error("Invalid URL: " + error); 24 + return false; 25 + } 26 + } 27 + 28 + export function sameDay(date1: Date, date2: Date): boolean { 29 + return ( 30 + date1.getDate() == date2.getDate() && 31 + date1.getMonth() == date2.getMonth() && 32 + date1.getFullYear() == date2.getFullYear() 33 + ); 34 + } 35 + 36 + export function showError(error: Error) { 37 + console.log(error); 38 + alert(error); 39 + }
+2 -1339
style.css
··· 1 1 :root { 2 2 color-scheme: light dark; 3 - supported-color-schemes: light dark; 4 3 } 5 4 6 5 @keyframes rotation { ··· 34 33 color: rgb(0, 0, 255); 35 34 } 36 35 37 - #github { 38 - position: fixed; 39 - bottom: 10px; 40 - right: 10px; 41 - z-index: 10; 42 - } 43 - 44 - #github img { 45 - width: 20px; 46 - opacity: 0.4; 47 - } 48 - 49 - #github a:hover img { 50 - opacity: 0.6; 51 - } 52 - 53 - #search { 54 - visibility: hidden; 55 - position: fixed; 56 - top: 0; 57 - bottom: 0; 58 - left: 0; 59 - right: 0; 60 - display: flex; 61 - align-items: center; 62 - justify-content: center; 63 - padding-bottom: 5%; 64 - } 65 - 66 - #search form { 67 - border: 2px solid hsl(210, 100%, 80%); 68 - border-radius: 10px; 69 - padding: 15px 20px; 70 - margin-left: 50px; 71 - } 72 - 73 - #search input { 74 - font-size: 16pt; 75 - width: 600px; 76 - border: 0; 77 - margin-left: 8px; 78 - } 79 - 80 - #search input:focus { 81 - outline: none; 82 - } 83 - 84 - #account { 85 - position: fixed; 86 - top: 10px; 87 - left: 10px; 88 - line-height: 24px; 89 - z-index: 20; 90 - user-select: none; 91 - -webkit-user-select: none; 92 - } 93 - 94 - #account i { 95 - opacity: 0.4; 96 - } 97 - 98 - #account i:hover { 99 - cursor: pointer; 100 - opacity: 0.6; 101 - } 102 - 103 - #account img.avatar { 104 - width: 24px; 105 - height: 24px; 106 - border-radius: 13px; 107 - box-shadow: 0px 0px 2px black; 108 - } 109 - 110 - #account_menu { 111 - position: fixed; 112 - visibility: hidden; 113 - top: 5px; 114 - left: 5px; 115 - padding-top: 30px; 116 - z-index: 15; 117 - background: hsl(210, 33.33%, 94.0%); 118 - border: 1px solid #ccc; 119 - border-radius: 5px; 120 - user-select: none; 121 - -webkit-user-select: none; 122 - } 123 - 124 - #account_menu ul { 125 - list-style-type: none; 126 - margin: 0px 0px 10px; 127 - padding: 6px 11px; 128 - } 129 - 130 - #account_menu li a[data-action] { 131 - display: inline-block; 132 - color: #333; 133 - font-size: 11pt; 134 - border: 1px solid #bbb; 135 - padding: 3px 5px; 136 - margin-top: 8px; 137 - border-radius: 5px; 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 { 163 - display: none; 164 - } 165 - 166 - .dialog { 167 - visibility: hidden; 168 - position: fixed; 169 - top: 0; 170 - bottom: 0; 171 - left: 0; 172 - right: 0; 173 - display: flex; 174 - align-items: center; 175 - justify-content: center; 176 - padding-bottom: 5%; 177 - z-index: 10; 178 - background-color: rgba(240, 240, 240, 0.4); 179 - } 180 - 181 - .dialog.expanded { 182 - padding-bottom: 0; 183 - } 184 - 185 - .dialog form { 186 - position: relative; 187 - border: 2px solid hsl(210, 100%, 85%); 188 - background-color: hsl(210, 100%, 98%); 189 - border-radius: 10px; 190 - padding: 15px 25px; 191 - } 192 - 193 - .dialog .close { 194 - position: absolute; 195 - top: 5px; 196 - right: 5px; 197 - color: hsl(210, 100%, 75%); 198 - opacity: 0.6; 199 - } 200 - 201 - .dialog .close:hover { 202 - color: hsl(210, 100%, 65%); 203 - opacity: 1.0; 204 - } 205 - 206 - .dialog p { 207 - text-align: center; 208 - line-height: 125%; 209 - } 210 - 211 - .dialog h2 { 212 - font-size: 13pt; 213 - font-weight: 600; 214 - text-align: center; 215 - margin-bottom: 25px; 216 - padding-right: 10px; 217 - } 218 - 219 - .dialog p.submit { 220 - margin-top: 25px; 221 - } 222 - 223 - .dialog p.info { 224 - font-size: 9pt; 225 - } 226 - 227 - .dialog p.info a { 228 - color: #666; 229 - } 230 - 231 - .dialog input[type="text"], .dialog input[type="password"] { 232 - width: 200px; 233 - font-size: 11pt; 234 - border: 1px solid #d6d6d6; 235 - border-radius: 4px; 236 - padding: 5px 6px; 237 - margin: 0px 15px; 238 - } 239 - 240 - .dialog input[type="submit"] { 241 - width: 150px; 242 - font-size: 11pt; 243 - border: 1px solid hsl(210, 90%, 85%); 244 - background-color: hsl(210, 100%, 92%); 245 - border-radius: 4px; 246 - padding: 5px 6px; 247 - } 248 - 249 - .dialog input[type="submit"]:hover { 250 - background-color: hsl(210, 100%, 90%); 251 - border: 1px solid hsl(210, 90%, 82%); 252 - } 253 - 254 - .dialog input[type="submit"]:active { 255 - background-color: hsl(210, 100%, 87%); 256 - border: 1px solid hsl(210, 90%, 80%); 257 - } 258 - 259 - #login #cloudy { 260 - color: hsl(210, 60%, 75%); 261 - margin: 14px 0px; 262 - display: none; 263 - } 264 - 265 - #login .info-box { 266 - display: none; 267 - border: 1px solid hsl(45, 100%, 60%); 268 - background-color: hsl(50, 100%, 96%); 269 - width: 360px; 270 - font-size: 11pt; 271 - border-radius: 6px; 272 - } 273 - 274 - #login.expanded .info-box { 275 - display: block; 276 - } 277 - 278 - #login .info-box p { 279 - margin: 15px 15px; 280 - text-align: left; 281 - } 282 - 283 - #biohazard_dialog form { 284 - width: 400px; 285 - } 286 - 287 - #biohazard_dialog p.submit { 288 - margin-top: 40px; 289 - margin-bottom: 20px; 290 - } 291 - 292 - #biohazard_dialog input[type="submit"] { 293 - width: 180px; 294 - margin-left: 5px; 295 - margin-right: 5px; 296 - } 297 - 298 - #loader { 299 - display: none; 300 - position: fixed; 301 - top: 0; 302 - bottom: 0; 303 - left: 0; 304 - right: 0; 305 - margin: auto; 306 - width: 36px; 307 - height: 36px; 308 - } 309 - 310 - #loader img { 311 - width: 36px; 312 - animation: rotation 3s infinite linear; 313 - } 314 - 315 - #thread { 36 + main { 316 37 padding-top: 1px; 317 38 } 318 39 319 - #thread.overlay { 320 - filter: blur(8px); 321 - } 322 - 323 - #thread header h2 { 40 + main header h2 { 324 41 margin-left: 20px; 325 42 margin-top: 40px; 326 43 margin-bottom: 50px; 327 44 font-size: 18pt; 328 45 } 329 46 330 - #thread.quotes .post { 331 - padding-bottom: 5px; 332 - } 333 - 334 - #thread.hashtag .post { 335 - padding-bottom: 10px; 336 - border-bottom: 1px solid #ddd; 337 - } 338 - 339 - #thread.notifications .post { 340 - padding-bottom: 4px; 341 - border-bottom: 1px solid #ddd; 342 - margin-top: 24px; 343 - } 344 - 345 - #thread.notifications .back { 346 - margin-left: 22px; 347 - margin-bottom: -12px; 348 - margin-top: 15px; 349 - } 350 - 351 - #thread.notifications .back, #thread.notifications .back a { 352 - font-size: 10pt; 353 - } 354 - 355 - #thread.notifications .back i { 356 - font-size: 9pt; 357 - margin-right: 2px; 358 - } 359 - 360 - #thread + p.note { 361 - margin-top: 30px; 362 - margin-left: 15px; 363 - font-size: 11pt; 364 - color: #666; 365 - } 366 - 367 47 .back, .back a { 368 48 font-size: 11pt; 369 49 color: #666; ··· 376 56 p.back i { 377 57 font-size: 10pt; 378 58 color: #888; 379 - margin-right: 5px; 380 - } 381 - 382 - .post { 383 - position: relative; 384 - padding-left: 21px; 385 - margin-top: 30px; 386 - } 387 - 388 - .post .edge { 389 - position: absolute; 390 - left: -2px; 391 - top: 30px; 392 - bottom: 0px; 393 - width: 6px; 394 - } 395 - 396 - .post .edge .line { 397 - position: absolute; 398 - left: 2px; 399 - top: 0px; 400 - bottom: 0px; 401 - border-left: 1px solid #aaa; 402 - } 403 - 404 - .post .edge:hover .line { 405 - border-left: 2px solid #888; 406 - } 407 - 408 - .post .plus { 409 - position: absolute; 410 - top: 8px; 411 - left: -6px; 412 - width: 14px; 413 - } 414 - 415 - .post.collapsed .line { 416 - display: none; 417 - } 418 - 419 - .post.collapsed .content { 420 - display: none; 421 - } 422 - 423 - .post.flat { 424 - padding-left: 0px; 425 - margin-top: 25px; 426 - } 427 - 428 - .post.flat > .margin { 429 - display: none; 430 - } 431 - 432 - .post .avatar { 433 - width: 32px; 434 - height: 32px; 435 - border-radius: 16px; 436 - vertical-align: middle; 437 - margin-bottom: 3px; 438 - margin-right: 8px; 439 - } 440 - 441 - .post .missing { 442 - color: #aaa; 443 - background-color: #eee; 444 - border-radius: 16px; 445 - vertical-align: middle; 446 - margin-right: 8px; 447 - } 448 - 449 - .post.muted .missing { 450 - color: #bbb; 451 - } 452 - 453 - .post h2 { 454 - font-size: 12pt; 455 - margin-bottom: 0; 456 - } 457 - 458 - .post h2 .handle { 459 - color: #888; 460 - font-weight: normal; 461 - font-size: 11pt; 462 - vertical-align: text-top; 463 - } 464 - 465 - .post h2 .separator, .post .blocked-header .separator, .blocked-header .separator { 466 - color: #888; 467 - font-weight: normal; 468 - font-size: 11pt; 469 - vertical-align: text-top; 470 - } 471 - 472 - .post h2 .time { 473 - color: #666; 474 - font-weight: normal; 475 - font-size: 10pt; 476 - vertical-align: text-top; 477 - } 478 - 479 - .post h2 .action, .post .blocked-header .action, .blocked-header .action { 480 - color: #888; 481 - font-weight: normal; 482 - font-size: 10pt; 483 - vertical-align: text-top; 484 - } 485 - 486 - .post h2 .action:hover, .post .blocked-header .action:hover, .blocked-header .action:hover { 487 - color: #444; 488 - } 489 - 490 - .post h2 img.mastodon { 491 - width: 15px; 492 - position: relative; 493 - top: 2px; 494 - margin-left: 3px; 495 - } 496 - 497 - .post p { 498 - margin-top: 10px; 499 - } 500 - 501 - .post div.body p + p { 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; 515 - background-color: #fbfcfd; 516 - margin-top: 25px; 517 - margin-bottom: 15px; 518 - margin-left: 0px; 519 - max-width: 800px; 520 - } 521 - 522 - .post .quote-embed .post { 523 - margin-top: 16px; 524 - padding-left: 16px; 525 - padding-right: 16px; 526 - padding-bottom: 5px; 527 - } 528 - 529 - .post .quote-embed .placeholder { 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 { 544 - font-size: 11pt; 545 - color: #666; 546 - margin-bottom: 20px; 547 - } 548 - 549 - .post .image-alt summary { 550 - font-size: 11pt; 551 - color: #666; 552 - margin-bottom: 5px; 553 - user-select: none; 554 - -webkit-user-select: none; 555 - cursor: default; 556 - } 557 - 558 - .post.blocked p, .post.blocked a { 559 - font-size: 11pt; 560 - color: #666; 561 - } 562 - 563 - .post.blocked .blocked-header i { 564 - margin-right: 2px; 565 - } 566 - 567 - .post.muted > h2, .post.muted > .content > details > p, .post.muted > .content > details summary { 568 - opacity: 0.3; 569 - } 570 - 571 - .post.muted > h2 { 572 - font-weight: 600; 573 - } 574 - 575 - .post.muted details { 576 - margin-top: 12px; 577 - margin-bottom: 10px; 578 - } 579 - 580 - .post.muted details summary { 581 - font-size: 10pt; 582 - user-select: none; 583 - -webkit-user-select: none; 584 - cursor: default; 585 - } 586 - 587 - .post a.link-card { 588 - display: block; 589 - position: relative; 590 - max-width: 500px; 591 - margin-bottom: 12px; 592 - } 593 - 594 - .post a.link-card:hover { 595 - text-decoration: none; 596 - } 597 - 598 - .post a.link-card > div { 599 - background-color: #fcfcfd; 600 - border: 1px solid #d8d8d8; 601 - border-radius: 8px; 602 - padding: 11px 15px; 603 - } 604 - 605 - .post a.link-card:hover > div { 606 - background-color: #f6f7f8; 607 - border: 1px solid #c8c8c8; 608 - } 609 - 610 - .post a.link-card > div:not(:has(p.description)) { 611 - padding-bottom: 14px; 612 - } 613 - 614 - .post a.link-card p.domain { 615 - color: #888; 616 - font-size: 10pt; 617 - margin-top: 1px; 618 - margin-bottom: 5px; 619 - } 620 - 621 - .post a.link-card h2 { 622 - color: #333; 623 - margin-top: 8px; 624 - } 625 - 626 - .post a.link-card p.description { 627 - color: #666; 628 - font-size: 11pt; 629 - margin-top: 8px; 630 - margin-bottom: 4px; 631 - line-height: 135%; 632 - } 633 - 634 - .post a.link-card.record > div:has(.avatar) { 635 - padding-left: 65px; 636 - } 637 - 638 - .post a.link-card.record h2 { 639 - margin-top: 3px; 640 - } 641 - 642 - .post a.link-card.record .handle { 643 - color: #666; 644 - margin-left: 5px; 645 - } 646 - 647 - .post a.link-card.record .avatar { 648 - width: 36px; 649 - height: 36px; 650 - border: 1px solid #ddd; 651 - border-radius: 6px; 652 - position: absolute; 653 - top: 15px; 654 - left: 15px; 655 - } 656 - 657 - .post a.link-card.record .stats { 658 - margin-top: 9px; 659 - margin-bottom: 1px; 660 - } 661 - 662 - .post a.link-card.record .stats i.fa-heart:hover { 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 59 margin-right: 3px; 686 60 } 687 61 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; 705 - } 706 - 707 - .post .stats a { 708 - color: #666; 709 - text-decoration: none; 710 - } 711 - 712 - .post .stats a:hover { 713 - text-decoration: underline; 714 - } 715 - 716 - .post .stats i { 717 - font-size: 9pt; 718 - color: #888; 719 - } 720 - 721 - .post .stats i.fa-heart { 722 - color: #aaa; 723 - } 724 - 725 - .post .stats i.fa-heart.liked { 726 - color: #e03030; 727 - } 728 - 729 - .post .stats i.fa-heart:hover { 730 - color: #888; 731 - cursor: pointer; 732 - } 733 - 734 - .post .stats i.fa-heart.liked:hover { 735 - color: #c02020; 736 - } 737 - 738 - .post .stats span { 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; 751 - margin-top: 5px; 752 - } 753 - 754 - .post .tags a { 755 - background-color: hsl(210, 90%, 97%); 756 - border: 1px solid hsl(215, 90%, 85%); 757 - border-radius: 6px; 758 - padding: 3px 7px; 759 - margin-right: 5px; 760 - font-size: 10pt; 761 - color: #333; 762 - } 763 - 764 - .post .tags a:hover { 765 - text-decoration: none; 766 - background-color: hsl(210, 90%, 93%); 767 - } 768 - 769 - .post p.hidden-replies { 770 - margin-top: 20px; 771 - font-size: 11pt; 772 - } 773 - 774 - .post p.hidden-replies a { 775 - font-size: 12pt; 776 - color: saddlebrown; 777 - } 778 - 779 - .post p.missing-replies-info { 780 - font-size: 11pt; 781 - color: darkred; 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 62 @media (prefers-color-scheme: dark) { 1136 63 body { 1137 64 background-color: rgb(39, 39, 37); ··· 1146 73 color: rgb(0, 133, 255); 1147 74 } 1148 75 1149 - #loader { 1150 - filter: invert(); 1151 - } 1152 - 1153 - #search form { 1154 - border-color: hsl(210, 40%, 60%); 1155 - } 1156 - 1157 - #search form input { 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 - 1180 - #login { 1181 - background-color: rgba(240, 240, 240, 0.15); 1182 - } 1183 - 1184 - #login form { 1185 - border-color: hsl(210, 20%, 40%); 1186 - background-color: hsl(210, 12%, 25%); 1187 - } 1188 - 1189 - #login .close { 1190 - color: hsl(210, 20%, 50%); 1191 - opacity: 0.6; 1192 - } 1193 - 1194 - #login .close:hover { 1195 - color: hsl(210, 20%, 50%); 1196 - opacity: 1.0; 1197 - } 1198 - 1199 - #login p.info a { 1200 - color: #888; 1201 - } 1202 - 1203 - #login input[type="text"], #login input[type="password"] { 1204 - border-color: #666; 1205 - } 1206 - 1207 - #login input[type="submit"] { 1208 - border-color: hsl(210, 15%, 40%); 1209 - background-color: hsl(210, 12%, 35%); 1210 - } 1211 - 1212 - #login input[type="submit"]:active { 1213 - border-color: hsl(210, 15%, 35%); 1214 - background-color: hsl(210, 12%, 30%); 1215 - } 1216 - 1217 - #login #cloudy { 1218 - color: hsl(210, 60%, 75%); 1219 - } 1220 - 1221 - #login .info-box { 1222 - border-color: hsl(45, 100%, 45%); 1223 - background-color: hsl(50, 40%, 30%); 1224 - } 1225 - 1226 - #login .info-box a { 1227 - color: hsl(45, 100%, 50%); 1228 - } 1229 - 1230 - #github { 1231 - filter: invert(); 1232 - } 1233 - 1234 76 .back, .back a { 1235 77 color: #888; 1236 78 } 1237 79 1238 80 p.back i { 1239 81 color: #888; 1240 - } 1241 - 1242 - .post h2 .handle { 1243 - color: #888; 1244 - } 1245 - 1246 - .post h2 .separator { 1247 - color: #888; 1248 - } 1249 - 1250 - .post h2 .time { 1251 - color: #aaa; 1252 - } 1253 - 1254 - .post h2 .action { 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; 1265 - } 1266 - 1267 - .post .image-alt, .post .image-alt summary { 1268 - color: #999; 1269 - } 1270 - 1271 - .post.blocked p, .post.blocked a { 1272 - color: #aaa; 1273 - } 1274 - 1275 - .post .edge .line { 1276 - border-left-color: #666; 1277 - } 1278 - 1279 - .post .edge:hover .line { 1280 - border-left-color: #888; 1281 - } 1282 - 1283 - .post .plus { 1284 - filter: invert(); 1285 - } 1286 - 1287 - .post .stats { 1288 - color: #aaa; 1289 - } 1290 - 1291 - .post .stats i { 1292 - color: #888; 1293 - } 1294 - 1295 - .post .stats i.fa-heart { 1296 - color: #aaa; 1297 - } 1298 - 1299 - .post .stats i.fa-heart.liked { 1300 - color: #f04040; 1301 - } 1302 - 1303 - .post .stats i.fa-heart:hover { 1304 - color: #eee; 1305 - } 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 82 } 1420 83 }
-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 - }
+24
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "es2024", 4 + "module": "esnext", 5 + "moduleResolution": "bundler", 6 + "moduleDetection": "force", 7 + "isolatedModules": true, 8 + "verbatimModuleSyntax": true, 9 + "noEmit": true, 10 + "checkJs": true, 11 + "strict": true, 12 + "allowUnreachableCode": false, 13 + "allowUnusedLabels": false, 14 + "exactOptionalPropertyTypes": true, 15 + "noFallthroughCasesInSwitch": true, 16 + "noImplicitOverride": true, 17 + "noImplicitReturns": true, 18 + "noUncheckedSideEffectImports": true, 19 + "noUnusedLocals": true, 20 + "noUnusedParameters": true, 21 + "useUnknownInCatchVariables": false, 22 + }, 23 + "include": ["src/**/*.js", "src/**/*.ts", "lib/**/*.d.ts"] 24 + }
+58
typecheck.js
··· 1 + import ts from 'typescript'; 2 + import { readFileSync } from "node:fs"; 3 + import { dirname, resolve } from "node:path"; 4 + 5 + const configPath = 'tsconfig.json'; 6 + 7 + let incrementalProgram; 8 + 9 + export function runTypecheck() { 10 + let configFilePath = ts.findConfigFile(process.cwd(), ts.sys.fileExists, configPath); 11 + let configFile = ts.readConfigFile(configFilePath, ts.sys.readFile); 12 + 13 + if (configFile.error) { 14 + throw new Error(ts.formatDiagnosticsWithColorAndContext([configFile.error], { 15 + getCurrentDirectory: ts.sys.getCurrentDirectory, 16 + getCanonicalFileName: x => x, 17 + getNewLine: () => ts.sys.newLine 18 + })); 19 + } 20 + 21 + let parsedConfig = ts.parseJsonConfigFileContent( 22 + configFile.config, 23 + ts.sys, 24 + dirname(configFilePath), 25 + { noEmit: true, incremental: true, tsBuildInfoFile: ".tsbuildinfo" }, 26 + configFilePath 27 + ); 28 + 29 + let host = ts.createIncrementalCompilerHost(parsedConfig.options); 30 + 31 + let program = ts.createIncrementalProgram({ 32 + rootNames: parsedConfig.fileNames, 33 + options: parsedConfig.options, 34 + host, 35 + oldProgram: incrementalProgram && incrementalProgram.getProgram() 36 + }); 37 + 38 + incrementalProgram = program; 39 + 40 + // TODO 41 + // https://github.com/microsoft/TypeScript/pull/31432 42 + // https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API/8e7d4bd1c85622cf078303075cbdf95a0a1bc8ab 43 + 44 + let diags = ts 45 + .getPreEmitDiagnostics(program.getProgram()) 46 + .concat(program.getSemanticDiagnostics?.() ?? []); 47 + 48 + let formatHost = { 49 + getCurrentDirectory: ts.sys.getCurrentDirectory, 50 + getCanonicalFileName: x => x, 51 + getNewLine: () => ts.sys.newLine 52 + }; 53 + 54 + let ok = (diags.length === 0); 55 + let text = ts.formatDiagnosticsWithColorAndContext(diags, formatHost); 56 + 57 + return { ok, text }; 58 + }
-30
types.d.ts
··· 1 - interface Window { 2 - dateLocale: string | undefined; 3 - root: AnyPost; 4 - subtreeRoot: AnyPost; 5 - loadInfohazard: (() => void) | undefined; 6 - } 7 - 8 - declare var accountAPI: BlueskyAPI; 9 - declare var blueAPI: BlueskyAPI; 10 - declare var appView: BlueskyAPI; 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;
-193
utils.js
··· 1 - class AtURI { 2 - /** @param {string} uri */ 3 - constructor(uri) { 4 - if (!uri.startsWith('at://')) { 5 - throw new URLError(`Not an at:// URI: ${uri}`); 6 - } 7 - 8 - let parts = uri.split('/'); 9 - 10 - if (parts.length != 5) { 11 - throw new URLError(`Invalid at:// URI: ${uri}`); 12 - } 13 - 14 - this.repo = parts[2]; 15 - this.collection = parts[3]; 16 - this.rkey = parts[4]; 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 - 66 - if (parts.length > 1) { 67 - let tagName = parts[0]; 68 - element = document.createElement(tagName); 69 - element.className = parts.slice(1).join(' '); 70 - } else { 71 - element = document.createElement(tag); 72 - } 73 - 74 - if (typeof params === 'string') { 75 - element.className = element.className + ' ' + params; 76 - } else if (params) { 77 - for (let key in params) { 78 - if (key == 'text') { 79 - element.innerText = params[key]; 80 - } else if (key == 'html') { 81 - element.innerHTML = params[key]; 82 - } else { 83 - element[key] = params[key]; 84 - } 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} */ 114 - 115 - function atURI(uri) { 116 - return new AtURI(uri); 117 - } 118 - 119 - function castToInt(value) { 120 - if (value === undefined || value === null || typeof value == "number") { 121 - return value; 122 - } else { 123 - return parseInt(value, 10); 124 - } 125 - } 126 - 127 - /** @param {string} html, @returns {string} */ 128 - 129 - function escapeHTML(html) { 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} */ 143 - 144 - function sanitizeHTML(html) { 145 - return DOMPurify.sanitize(html, { 146 - ALLOWED_TAGS: [ 147 - 'a', 'b', 'blockquote', 'br', 'code', 'dd', 'del', 'div', 'dl', 'dt', 'em', 'font', 148 - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'q', 'pre', 's', 'span', 'strong', 149 - 'sub', 'sup', 'u', 'wbr', '#text' 150 - ], 151 - ALLOWED_ATTR: [ 152 - 'align', 'alt', 'class', 'clear', 'color', 'dir', 'href', 'lang', 'rel', 'title', 'translate' 153 - ] 154 - }); 155 - } 156 - 157 - /** @returns {string} */ 158 - 159 - function getLocation() { 160 - return location.origin + location.pathname; 161 - } 162 - 163 - /** @param {object} error */ 164 - 165 - function showError(error) { 166 - console.log(error); 167 - alert(error); 168 - } 169 - 170 - /** @param {Date} date1, @param {Date} date2, @returns {boolean} */ 171 - 172 - function sameDay(date1, date2) { 173 - return ( 174 - date1.getDate() == date2.getDate() && 175 - date1.getMonth() == date2.getMonth() && 176 - date1.getFullYear() == date2.getFullYear() 177 - ); 178 - } 179 - 180 - /** @param {Post} post, @returns {string} */ 181 - 182 - function linkToPostThread(post) { 183 - return linkToPostById(post.author.handle, post.rkey); 184 - } 185 - 186 - /** @param {string} handle, @param {string} postId, @returns {string} */ 187 - 188 - function linkToPostById(handle, postId) { 189 - let url = new URL(getLocation()); 190 - url.searchParams.set('author', handle); 191 - url.searchParams.set('post', postId); 192 - return url.toString(); 193 - }