Thread viewer for Bluesky

typescriptification!

+432 -495
+1 -1
src/api/api.js
··· 255 255 return { cursor: response.cursor, posts: postGroups.flat() }; 256 256 } 257 257 258 - /** @param {Post} post, @returns {Promise<?json[]>} */ 258 + /** @param {Post} post, @returns {Promise<(json | null)[]>} */ 259 259 260 260 async loadHiddenReplies(post) { 261 261 let expectedReplyURIs;
-38
src/api/handle_cache.js
··· 1 - /** 2 - * Caches the mapping of handles to DIDs to avoid unnecessary API calls to resolveHandle or getProfile. 3 - */ 4 - 5 - export class HandleCache { 6 - prepareCache() { 7 - if (!this.cache) { 8 - this.cache = JSON.parse(localStorage.getItem('handleCache') ?? '{}'); 9 - } 10 - } 11 - 12 - saveCache() { 13 - localStorage.setItem('handleCache', JSON.stringify(this.cache)); 14 - } 15 - 16 - /** @param {string} handle, @returns {string | undefined} */ 17 - 18 - getHandleDid(handle) { 19 - this.prepareCache(); 20 - return this.cache[handle]; 21 - } 22 - 23 - /** @param {string} handle, @param {string} did */ 24 - 25 - setHandleDid(handle, did) { 26 - this.prepareCache(); 27 - this.cache[handle] = did; 28 - this.saveCache(); 29 - } 30 - 31 - /** @param {string} did, @returns {string | undefined} */ 32 - 33 - findHandleByDid(did) { 34 - this.prepareCache(); 35 - let found = Object.entries(this.cache).find((e) => e[1] == did); 36 - return found ? found[0] : undefined; 37 - } 38 - }
+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 + }
+3 -8
src/api/identity.js src/api/identity.ts
··· 8 8 9 9 export class LoginError extends Error {} 10 10 11 - 12 - /** @param {string} did, @returns {Promise<string>} */ 13 - 14 - export async function pdsEndpointForDID(did) { 15 - let documentURL; 11 + export async function pdsEndpointForDID(did: string): Promise<string> { 12 + let documentURL: URL; 16 13 17 14 if (did.startsWith('did:plc:')) { 18 15 documentURL = new URL(`https://plc.directory/${did}`); ··· 42 39 } 43 40 } 44 41 45 - /** @param {string} identifier, @returns {Promise<string>} */ 46 - 47 - export async function pdsEndpointForIdentifier(identifier) { 42 + export async function pdsEndpointForIdentifier(identifier: string): Promise<string> { 48 43 if (identifier.match(/^did:/)) { 49 44 return await pdsEndpointForDID(identifier); 50 45
+9 -14
src/components/AccountMenu.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { showLoginDialog } from '../skythread.js'; 3 3 import { account } from '../models/account.svelte.js'; 4 4 import { getBaseLocation } from '../router.js'; ··· 8 8 let menuVisible = $state(false); 9 9 10 10 $effect(() => { 11 - let html = document.body.parentNode; 11 + let html = document.body.parentNode! 12 12 13 13 html.addEventListener('click', (e) => { 14 14 menuVisible = false; 15 15 }); 16 16 }); 17 17 18 - /** @param {Event} e */ 19 - function toggleMenu(e) { 18 + function toggleMenu(e: Event) { 20 19 e.stopPropagation(); 21 20 menuVisible = !menuVisible; 22 21 } 23 22 24 - /** @param {Event} e */ 25 - function toggleBiohazard(e) { 23 + function toggleBiohazard(e: Event) { 26 24 e.preventDefault(); 27 25 28 26 let hazards = document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post'); 29 27 30 28 if (account.biohazardEnabled === false) { 31 29 account.biohazardEnabled = true; 32 - Array.from(hazards).forEach(p => { p.style.display = 'block' }); 30 + Array.from(hazards).forEach(p => { (p as HTMLElement).style.display = 'block' }); 33 31 } else { 34 32 account.biohazardEnabled = false; 35 - Array.from(hazards).forEach(p => { p.style.display = 'none' }); 33 + Array.from(hazards).forEach(p => { (p as HTMLElement).style.display = 'none' }); 36 34 } 37 35 } 38 36 39 - /** @param {Event} e */ 40 - function toggleIncognito(e) { 37 + function toggleIncognito(e: Event) { 41 38 e.preventDefault(); 42 39 account.toggleIncognitoMode(); 43 40 } 44 41 45 - /** @param {Event} e */ 46 - function showLoginScreen(e) { 42 + function showLoginScreen(e: Event) { 47 43 e.preventDefault(); 48 44 49 45 showLoginDialog(); 50 46 menuVisible = false; 51 47 } 52 48 53 - /** @param {Event} e */ 54 - function logOut(e) { 49 + function logOut(e: Event) { 55 50 e.preventDefault(); 56 51 account.logOut(); 57 52 }
+9 -2
src/components/AccountMenuButton.svelte
··· 1 - <script> 2 - let { title, label, showCheckmark, onclick } = $props(); 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(); 3 10 </script> 4 11 5 12 <li><a class="button" href="#" {onclick} {title}>
+9 -11
src/components/BiohazardDialog.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { account } from '../models/account.svelte.js'; 3 3 4 - let { onConfirm, onClose } = $props(); 4 + type Props = { onConfirm?: () => void, onClose?: () => void }; 5 + let { onConfirm = undefined, onClose = undefined }: Props = $props(); 5 6 6 - function showBiohazard(e) { 7 + function showBiohazard(e: Event) { 7 8 e.preventDefault(); 8 9 account.biohazardEnabled = true; 9 10 10 - if (onConfirm) { 11 - onConfirm(); 12 - } 13 - 14 - onClose(); 11 + onConfirm?.() 12 + onClose?.(); 15 13 } 16 14 17 - function hideBiohazard(e) { 15 + function hideBiohazard(e: Event) { 18 16 e.preventDefault(); 19 17 account.biohazardEnabled = false; 20 18 21 19 for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) { 22 - p.style.display = 'none'; 20 + (p as HTMLElement).style.display = 'none'; 23 21 } 24 22 25 - onClose(); 23 + onClose?.(); 26 24 } 27 25 </script> 28 26
+4 -4
src/components/HomeSearch.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { getBaseLocation, linkToHashtagPage, linkToPostById, parseBlueskyPostURL } from '../router.js'; 3 3 4 - let query = $state(); 5 - let searchField; 4 + let query = $state(''); 5 + let searchField: HTMLInputElement; 6 6 7 7 $effect(() => { 8 8 searchField.focus(); 9 9 }); 10 10 11 - function onsubmit(e) { 11 + function onsubmit(e: Event) { 12 12 e.preventDefault(); 13 13 14 14 let q = query.trim();
+4 -2
src/components/LikeStatsTable.svelte
··· 1 - <script> 2 - let { cssClass, header, users } = $props(); 1 + <script lang="ts"> 2 + import { LikeStat } from "../services/like_stats"; 3 + 4 + let { cssClass, header, users }: { cssClass: string, header: string, users: LikeStat[] } = $props(); 3 5 </script> 4 6 5 7 <table class="scan-result {cssClass}" style="display: table;">
+2 -2
src/components/LoadableImage.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 let { loading, error, ...props } = $props(); 3 - let imageState = $state(); 3 + let imageState: string | undefined = $state(); 4 4 5 5 function onload() { 6 6 imageState = 'loaded';
+10 -11
src/components/LoginDialog.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { submitLogin } from '../skythread.js'; 3 + import { APIError } from '../api/minisky.js'; 3 4 4 - let { onClose } = $props(); 5 + let { onClose = undefined }: { onClose?: () => void } = $props(); 5 6 6 - let identifier = $state(); 7 - let password = $state(); 7 + let identifier: string = $state(''); 8 + let password: string = $state(''); 8 9 let loginInfoVisible = $state(false); 9 10 let submitting = $state(false); 10 - let loginField, passwordField; 11 + let loginField: HTMLInputElement; 12 + let passwordField: HTMLInputElement; 11 13 12 - /** @param {Event} e */ 13 - function toggleLoginInfo(e) { 14 + function toggleLoginInfo(e: Event) { 14 15 e.preventDefault(); 15 16 loginInfoVisible = !loginInfoVisible; 16 17 } 17 18 18 - /** @param {Event} e, @returns {Promise<void>} */ 19 - async function onsubmit(e) { 19 + async function onsubmit(e: Event) { 20 20 e.preventDefault(); 21 21 submitting = true; 22 22 ··· 31 31 } 32 32 } 33 33 34 - /** @param {Error} error */ 35 - function showError(error) { 34 + function showError(error: Error) { 36 35 console.log(error); 37 36 38 37 if (error.code == 401 && error.json.error == 'AuthFactorTokenRequired') {
+10 -4
src/components/PostingStatsTable.svelte
··· 1 - <script> 2 - let { users, sums, daysBack, showReposts = true, showPercentages = true, showTotal = true } = $props(); 1 + <script lang="ts"> 2 + import { PostingStatsResult } from "../services/posting_stats"; 3 3 4 - /** @param {number} value, @returns {string} */ 4 + interface Props extends PostingStatsResult { 5 + showReposts?: boolean, 6 + showPercentages?: boolean, 7 + showTotal?: boolean 8 + }; 5 9 6 - function format(value) { 10 + let { users, sums, daysBack, showReposts = true, showPercentages = true, showTotal = true }: Props = $props(); 11 + 12 + function format(value: number): string { 7 13 return (value > 0) ? value.toFixed(1) : '–'; 8 14 } 9 15 </script>
+2 -2
src/components/RichTextFromFacets.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { RichText } from '../../lib/rich_text_lite.js'; 3 3 import { linkToHashtagPage } from '../router.js'; 4 4 5 - let { text, facets } = $props(); 5 + let { text, facets }: { text: string, facets: json[] } = $props(); 6 6 7 7 let richText = $derived(new RichText({ text, facets })); 8 8 let segments = $derived(richText.segments());
+23 -30
src/components/UserAutocomplete.svelte
··· 1 - <script> 2 - let { selectedUsers = $bindable([]) } = $props(); 1 + <script lang="ts"> 2 + export type AutocompleteUser = { 3 + did: string; 4 + handle: string; 5 + avatar?: string; 6 + displayName?: string; 7 + } 8 + 9 + let { selectedUsers = $bindable([]) }: { selectedUsers: AutocompleteUser[] } = $props(); 3 10 4 11 let typedValue = $state(''); 5 - let autocompleteResults = $state([]); 6 - let autocompleteIndex = $state(); 12 + let autocompleteResults: AutocompleteUser[] = $state([]); 13 + let autocompleteIndex = $state(-1); 7 14 8 - let selectedUserDIDs = $derived(selectedUsers.map(u => u.did)); 15 + let selectedUserDIDs: string[] = $derived(selectedUsers.map(u => u.did)); 9 16 let autocompleteVisible = $derived(autocompleteResults.length > 0); 10 - let autocompleteVerticalOffset = $state(); 11 - let autocompleteScroll = $state(0); 17 + let autocompleteVerticalOffset = $state(0); 12 18 13 - /** @type {number | undefined} */ 14 - let autocompleteTimer; 19 + let autocompleteTimer: number | undefined; 15 20 16 21 $effect(() => { 17 - let html = /** @type {Element} */ (document.body.parentNode); 22 + let html = document.body.parentNode! 18 23 html.addEventListener('click', hideAutocomplete); 19 24 20 25 return () => { ··· 37 42 } 38 43 } 39 44 40 - /** @param {KeyboardEvent} e */ 41 - 42 - function onKeyPress(e) { 45 + function onKeyPress(e: KeyboardEvent) { 43 46 if (e.key == 'Enter') { 44 47 e.preventDefault(); 45 48 ··· 57 60 } 58 61 } 59 62 60 - /** @param {string} query, @returns {Promise<void>} */ 61 - 62 - async function fetchAutocomplete(query) { 63 - let users = await accountAPI.autocompleteUsers(query); 63 + async function fetchAutocomplete(query: string) { 64 + let users = await accountAPI.autocompleteUsers(query) as AutocompleteUser[]; 64 65 65 66 let selectedDIDs = new Set(selectedUserDIDs); 66 67 users = users.filter(u => !selectedDIDs.has(u.did)); ··· 80 81 autocompleteIndex = -1; 81 82 } 82 83 83 - /** @param {1|-1} change */ 84 - 85 - function moveAutocomplete(change) { 84 + function moveAutocomplete(change: 1 | -1) { 86 85 if (autocompleteResults.length == 0) { 87 86 return; 88 87 } ··· 98 97 autocompleteIndex = newIndex; 99 98 } 100 99 101 - /** @param {MouseEvent} e, @param {number} index */ 102 - 103 - function selectAutocomplete(e, index) { 100 + function selectAutocomplete(e: MouseEvent, index: number) { 104 101 e.preventDefault(); 105 102 selectUser(index); 106 103 } 107 104 108 - /** @param {number} index */ 109 - 110 - function selectUser(index) { 105 + function selectUser(index: number) { 111 106 let user = autocompleteResults[index]; 112 107 113 108 if (!user) { ··· 119 114 hideAutocomplete(); 120 115 } 121 116 122 - /** @param {MouseEvent} e, @param {number} index */ 123 - 124 - function removeUser(e, index) { 117 + function removeUser(e: MouseEvent, index: number) { 125 118 e.preventDefault(); 126 119 selectedUsers.splice(index, 1); 127 120 } ··· 153 146 </div> 154 147 </div> 155 148 156 - {#snippet userRow(user)} 149 + {#snippet userRow(user: AutocompleteUser)} 157 150 <img class="avatar" alt="Avatar" src={user.avatar}> 158 151 <span class="name">{user.displayName || '–'}</span> 159 152 <span class="handle">{user.handle}</span>
+3 -3
src/components/embeds/EmbedComponent.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { 3 - RawRecordEmbed, RawRecordWithMediaEmbed, RawImageEmbed, RawLinkEmbed, RawVideoEmbed, 3 + Embed, RawRecordEmbed, RawRecordWithMediaEmbed, RawImageEmbed, RawLinkEmbed, RawVideoEmbed, 4 4 InlineRecordEmbed, InlineRecordWithMediaEmbed, InlineImageEmbed, InlineLinkEmbed, InlineVideoEmbed 5 5 } from '../../models/embeds.js'; 6 6 ··· 10 10 import QuoteComponent from './QuoteComponent.svelte'; 11 11 import VideoComponent from './VideoComponent.svelte'; 12 12 13 - let { embed } = $props(); 13 + let { embed }: { embed: Embed } = $props(); 14 14 </script> 15 15 16 16 {#if embed instanceof RawRecordEmbed || embed instanceof InlineRecordEmbed}
+4 -3
src/components/embeds/FeedGeneratorView.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { atURI } from '../../utils.js'; 3 + import { FeedGeneratorRecord } from '../../models/records.js'; 3 4 4 - let { feed } = $props(); 5 + let { feed }: { feed: FeedGeneratorRecord } = $props(); 5 6 6 - function linkToFeed(feed) { 7 + function linkToFeed(feed: FeedGeneratorRecord) { 7 8 let { repo, rkey } = atURI(feed.uri); 8 9 return `https://bsky.app/profile/${repo}/feed/${rkey}`; 9 10 }
+4 -4
src/components/embeds/GIFPlayer.svelte
··· 1 - <script> 2 - let { gifURL, staticURL } = $props(); 1 + <script lang="ts"> 2 + let { gifURL, staticURL }: { gifURL: string, staticURL: string } = $props(); 3 3 4 4 let loaded = $state(false); 5 5 let paused = $state(false); ··· 7 7 let maxWidth = $state(500); 8 8 let maxHeight = $state(200); 9 9 10 - function onload(e) { 11 - let img = e.target; 10 + function onload(e: Event) { 11 + let img = e.target as HTMLImageElement; 12 12 13 13 if (img.naturalWidth < img.naturalHeight) { 14 14 maxWidth = 200;
+9 -7
src/components/embeds/ImagesComponent.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { getContext } from 'svelte'; 3 + import { InlineImageEmbed, RawImageEmbed } from '../../models/embeds'; 4 + import { Post } from '../../models/posts'; 3 5 4 - let { embed } = $props(); 5 - let { post } = getContext('post'); 6 + let { embed }: { embed: InlineImageEmbed | RawImageEmbed } = $props(); 7 + let { post }: { post: Post } = getContext('post'); 6 8 7 - function imageURL(embed) { 8 - if (embed.fullsize) { 9 - return embed.fullsize; 9 + function imageURL(img: json): string { 10 + if (img.fullsize) { 11 + return img.fullsize; 10 12 } else { 11 - let cid = embed.image.ref['$link']; 13 + let cid = img.image.ref['$link']; 12 14 return `https://cdn.bsky.app/img/feed_fullsize/plain/${post.author.did}/${cid}@jpeg`; 13 15 } 14 16 }
+6 -4
src/components/embeds/LinkComponent.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { getContext } from 'svelte'; 3 3 import { isValidURL, truncateText } from '../../utils.js'; 4 4 import GIFPlayer from './GIFPlayer.svelte'; 5 + import { InlineLinkEmbed, RawLinkEmbed } from '../../models/embeds.js'; 6 + import { Post } from '../../models/posts.js'; 5 7 6 - let { embed } = $props(); 7 - let { post } = getContext('post'); 8 + let { embed }: { embed: InlineLinkEmbed | RawLinkEmbed } = $props(); 9 + let { post }: { post: Post } = getContext('post'); 8 10 9 11 let showingGIF = $state(false); 10 12 ··· 12 14 let isTenorGIF = $derived(hostname == 'media.tenor.com'); 13 15 let onclick = $derived(isTenorGIF ? playGIF : undefined); 14 16 15 - function playGIF(e) { 17 + function playGIF(e: Event) { 16 18 e.preventDefault(); 17 19 showingGIF = true; 18 20 }
+6 -6
src/components/embeds/QuoteComponent.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { getContext } from 'svelte'; 3 3 import { BasePost, Post, MissingPost } from '../../models/posts.js'; 4 4 import { InlineRecordEmbed, InlineRecordWithMediaEmbed } from '../../models/embeds.js'; ··· 10 10 import StarterPackView from '../embeds/StarterPackView.svelte'; 11 11 import UserListView from '../embeds/UserListView.svelte'; 12 12 13 - let { post } = getContext('post'); 14 - let { record } = $props(); 13 + let { post }: { post: Post } = getContext('post'); 14 + let { record }: { record: ATProtoRecord } = $props(); 15 15 16 - async function loadQuotedRecord() { 17 - let { repo, collection, rkey } = atURI(record.uri); 16 + async function loadQuotedRecord(): Promise<ATProtoRecord> { 17 + let { collection } = atURI(record.uri); 18 18 19 19 if (collection == 'app.bsky.feed.post') { 20 20 let reloaded = await api.loadPostIfExists(record.uri); ··· 53 53 {@render quoteContent(record)} 54 54 {/if} 55 55 56 - {#snippet quoteContent(record)} 56 + {#snippet quoteContent(record: ATProtoRecord)} 57 57 {#if record instanceof BasePost} 58 58 <div class="quote-embed"> 59 59 <PostComponent post={record} context="quote" />
+4 -3
src/components/embeds/StarterPackView.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { atURI } from '../../utils.js'; 3 + import { StarterPackRecord } from '../../models/records.js'; 3 4 4 - let { starterPack } = $props(); 5 + let { starterPack }: { starterPack: StarterPackRecord } = $props(); 5 6 6 - function linkToStarterPack(starterPack) { 7 + function linkToStarterPack(starterPack: StarterPackRecord) { 7 8 let { repo, rkey } = atURI(starterPack.uri); 8 9 return `https://bsky.app/starter-pack/${repo}/${rkey}`; 9 10 }
+5 -4
src/components/embeds/UserListView.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { atURI } from '../../utils.js'; 3 + import { UserListRecord } from '../../models/records.js'; 3 4 4 - let { list } = $props(); 5 + let { list }: { list: UserListRecord } = $props(); 5 6 6 - function linkToList(list) { 7 + function linkToList(list: UserListRecord) { 7 8 let { repo, rkey } = atURI(list.uri); 8 9 return `https://bsky.app/profile/${repo}/lists/${rkey}`; 9 10 } 10 11 11 - function listType(list) { 12 + function listType(list: UserListRecord) { 12 13 switch (list.purpose) { 13 14 case 'app.bsky.graph.defs#curatelist': 14 15 return "User list";
+7 -5
src/components/embeds/VideoComponent.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { getContext } from 'svelte'; 3 + import { InlineVideoEmbed, RawVideoEmbed } from '../../models/embeds'; 4 + import { Post } from '../../models/posts'; 3 5 4 - let { embed } = $props(); 5 - let { post } = getContext('post'); 6 + let { embed }: { embed: InlineVideoEmbed | RawVideoEmbed } = $props(); 7 + let { post }: { post: Post } = getContext('post'); 6 8 7 - function videoURL(embed) { 8 - if (embed.playlistURL) { 9 + function videoURL(embed: InlineVideoEmbed | RawVideoEmbed) { 10 + if (embed instanceof InlineVideoEmbed) { 9 11 return embed.playlistURL; 10 12 } else { 11 13 let cid = embed.video.ref['$link'];
+7 -6
src/components/posts/BlockedPostView.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { getContext } from 'svelte'; 3 3 import { account } from '../../models/account.svelte.js'; 4 + import { Post } from '../../models/posts.js'; 4 5 5 6 import EmbedComponent from '../embeds/EmbedComponent.svelte'; 6 7 import MissingPostView from './MissingPostView.svelte'; ··· 9 10 import ReferencedPostAuthorLink from './ReferencedPostAuthorLink.svelte'; 10 11 import ThreadRootParentRaw from './ThreadRootParentRaw.svelte'; 11 12 12 - let { reason } = $props(); 13 - let { post } = getContext('post'); 13 + let { reason }: { reason: string } = $props(); 14 + let { post }: { post: Post } = getContext('post'); 14 15 15 16 let biohazardEnabled = $derived(account.biohazardEnabled !== false); 16 17 let loading = $state(false); 17 18 let postNotFound = $state(false); 18 - let reloadedPost = $state(); 19 + let reloadedPost: Post | undefined = $state(); 19 20 20 - async function loadPost(e) { 21 + async function loadPost(e: Event) { 21 22 e.preventDefault(); 22 23 loading = true; 23 24 ··· 36 37 } else if (post.blocksUser) { 37 38 return "you've blocked them"; 38 39 } else { 39 - return null; 40 + return undefined; 40 41 } 41 42 } 42 43 </script>
+2 -3
src/components/posts/EdgeMargin.svelte
··· 1 - <script> 2 - let { collapsed = $bindable(false), onToggle = undefined } = $props(); 1 + <script lang="ts"> 2 + let { collapsed = $bindable(false) }: { collapsed: boolean } = $props(); 3 3 4 4 function toggleFold() { 5 5 collapsed = !collapsed; 6 - onToggle && onToggle(collapsed); 7 6 } 8 7 </script> 9 8
+2 -2
src/components/posts/FediSourceLink.svelte
··· 1 - <script> 2 - let { url } = $props(); 1 + <script lang="ts"> 2 + let { url }: { url: string } = $props(); 3 3 4 4 let hostname = $derived(new URL(url).hostname); 5 5 </script>
+2 -2
src/components/posts/FeedPostParent.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { linkToPostById } from '../../router.js'; 3 3 import { atURI } from '../../utils.js'; 4 4 5 - let { uri } = $props(); 5 + let { uri }: { uri: string } = $props(); 6 6 let { repo, rkey } = $derived(atURI(uri)); 7 7 </script> 8 8
+10 -5
src/components/posts/HiddenRepliesLink.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { showBiohazardDialog } from '../../skythread.js'; 3 3 import { account } from '../../models/account.svelte.js'; 4 - import { parseThreadPost } from '../../models/posts.js'; 4 + import { Post, parseThreadPost } from '../../models/posts.js'; 5 5 import { linkToPostThread } from '../../router.js'; 6 6 import { getContext } from 'svelte'; 7 7 8 - let { onLoad, onError } = $props(); 9 - let { post } = getContext('post'); 8 + type Props = { 9 + onLoad: (posts: (AnyPost | null)[]) => void, 10 + onError: (error: Error) => void 11 + } 12 + 13 + let { onLoad, onError }: Props = $props(); 14 + let { post }: { post: Post } = getContext('post'); 10 15 let loading = $state(false); 11 16 12 - function onLinkClick(e) { 17 + function onLinkClick(e: Event) { 13 18 e.preventDefault(); 14 19 15 20 if (account.biohazardEnabled === true) {
+11 -6
src/components/posts/LoadMoreLink.svelte
··· 1 - <script> 2 - import { parseThreadPost } from '../../models/posts.js'; 1 + <script lang="ts"> 2 + import { Post, parseThreadPost } from '../../models/posts.js'; 3 3 import { linkToPostThread } from '../../router.js'; 4 4 import { getContext } from 'svelte'; 5 5 6 - let { onLoad, onError } = $props(); 7 - let { post } = getContext('post'); 6 + type Props = { 7 + onLoad: (root: Post) => void, 8 + onError: (error: Error) => void 9 + } 10 + 11 + let { onLoad, onError }: Props = $props(); 12 + let { post }: { post: Post } = getContext('post'); 8 13 let loading = $state(false); 9 14 10 - async function onLinkClick(e) { 15 + async function onLinkClick(e: Event) { 11 16 e.preventDefault(); 12 17 loading = true; 13 18 ··· 17 22 18 23 loading = false; 19 24 window.subtreeRoot = root; 20 - onLoad(root); 25 + onLoad(root as Post); // TODO 21 26 } catch (error) { 22 27 loading = false; 23 28 onError(error);
+3 -2
src/components/posts/MissingPostView.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { getContext } from 'svelte'; 3 + import { Post } from '../../models/posts'; 3 4 import ReferencedPostAuthorLink from './ReferencedPostAuthorLink.svelte'; 4 5 5 - let { post } = getContext('post'); 6 + let { post }: { post: Post } = getContext('post'); 6 7 </script> 7 8 8 9 <p class="blocked-header">
+7 -8
src/components/posts/PostBody.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { getContext } from 'svelte'; 3 3 import { sanitizeHTML } from '../../utils.js'; 4 + import { Post } from '../../models/posts.js'; 4 5 import RichTextFromFacets from '../RichTextFromFacets.svelte'; 5 6 6 - let { post } = getContext('post'); 7 - let { highlightedMatches = undefined } = $props(); 7 + let { post }: { post: Post } = getContext('post'); 8 + let { highlightedMatches = undefined }: { highlightedMatches?: string[] } = $props(); 8 9 9 - let bodyElement; 10 + let bodyElement: HTMLElement; 10 11 11 - /** @param {string[]} terms */ 12 - 13 - function highlightSearchResults(terms) { 12 + function highlightSearchResults(terms: string[]) { 14 13 let regexp = new RegExp(`\\b(${terms.join('|')})\\b`, 'gi'); 15 14 let walker = document.createTreeWalker(bodyElement, NodeFilter.SHOW_TEXT); 16 - let ranges = []; 15 + let ranges: Range[] = []; 17 16 18 17 while (walker.nextNode()) { 19 18 let node = walker.currentNode;
+13 -13
src/components/posts/PostComponent.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { setContext } from 'svelte'; 3 3 import { HiddenRepliesError } from '../../api/api.js'; 4 4 import { account } from '../../models/account.svelte.js'; 5 5 import { Post, BlockedPost, DetachedQuotePost } from '../../models/posts.js'; 6 - import { InlineLinkEmbed } from '../../models/embeds.js'; 6 + import { Embed, InlineLinkEmbed } from '../../models/embeds.js'; 7 7 import { isValidURL, showError } from '../../utils.js'; 8 8 9 9 import BlockedPostView from './BlockedPostView.svelte'; ··· 32 32 let { post, context, highlightedMatches = undefined, ...props } = $props(); 33 33 34 34 let collapsed = $state(false); 35 - let replies = $state(post.replies); 35 + let replies: AnyPost[] = $state(post.replies); 36 36 let repliesLoaded = $state(false); 37 - let missingHiddenReplies = $state(); 38 - let hiddenRepliesError = $state(); 37 + let missingHiddenReplies: number | undefined = $state(); 38 + let hiddenRepliesError: Error | undefined = $state(); 39 39 40 40 setContext('post', { post, context }); 41 41 42 42 // TODO: make Post reactive 43 - let quoteCount = $state(post.quoteCount); 43 + let quoteCount: number | undefined = $state(post.quoteCount); 44 44 45 - export function setQuoteCount(x) { 45 + export function setQuoteCount(x: number) { 46 46 quoteCount = x; 47 47 } 48 48 49 - function shouldRenderReply(reply) { 49 + function shouldRenderReply(reply: AnyPost): boolean { 50 50 if (reply instanceof Post) { 51 51 return true; 52 52 } else if (reply instanceof BlockedPost) { ··· 56 56 } 57 57 } 58 58 59 - function shouldRenderEmbed(embed) { 59 + function shouldRenderEmbed(embed: Embed): boolean { 60 60 if (post.originalFediURL) { 61 61 if (embed instanceof InlineLinkEmbed && embed.title && embed.title.startsWith('Original post on ')) { 62 62 return false; ··· 66 66 return true; 67 67 } 68 68 69 - function onMoreRepliesLoaded(newPost) { 69 + function onMoreRepliesLoaded(newPost: Post) { 70 70 replies = post.replies = newPost.replies; 71 71 repliesLoaded = true; 72 72 } 73 73 74 - function onHiddenRepliesLoaded(newReplies) { 75 - let okReplies = newReplies.filter(x => x); 74 + function onHiddenRepliesLoaded(newReplies: (AnyPost | null)[]) { 75 + let okReplies = newReplies.filter(x => x !== null); 76 76 replies.push(...okReplies); 77 77 post.replies = replies; 78 78 ··· 80 80 repliesLoaded = true; 81 81 } 82 82 83 - function onRepliesLoadingError(error) { 83 + function onRepliesLoadingError(error: Error) { 84 84 repliesLoaded = true; 85 85 86 86 if (error instanceof HiddenRepliesError) {
+4 -9
src/components/posts/PostFooter.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { getContext } from 'svelte'; 3 3 import { linkToPostThread, linkToQuotesPage } from '../../router.js'; 4 4 import { account } from '../../models/account.svelte.js'; 5 + import { Post } from '../../models/posts.js'; 5 6 import { showLoginDialog } from '../../skythread.js'; 6 7 import { showError } from '../../utils.js'; 7 8 8 - let { post, context } = getContext('post'); 9 - let { quoteCount } = $props(); 9 + let { post, context }: { post: Post, context: PostContext } = getContext('post'); 10 + let { quoteCount }: { quoteCount: number | undefined } = $props(); 10 11 11 12 let isLiked = $state(post.liked); 12 13 let likeCount = $state(post.likeCount); 13 14 let isUnavailableForLiking = $state(false); 14 15 15 - /** @returns {Promise<void>} */ 16 - 17 16 async function onHeartClick() { 18 17 try { 19 18 if (post.hasViewerInfo) { ··· 28 27 } 29 28 } 30 29 31 - /** @returns {Promise<void>} */ 32 - 33 30 async function checkIfCanBeLiked() { 34 31 let data = await accountAPI.loadViewerInfo(); 35 32 ··· 43 40 isUnavailableForLiking = true; 44 41 } 45 42 } 46 - 47 - /** @returns {Promise<void>} */ 48 43 49 44 async function likePost() { 50 45 if (!isLiked) {
+4 -3
src/components/posts/PostHeader.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { getContext } from 'svelte'; 3 + import { Post } from '../../models/posts.js'; 3 4 import { PostPresenter } from '../../utils/post_presenter.js'; 4 5 import PostSubtreeLink from './PostSubtreeLink.svelte'; 5 6 6 - let { post, context } = getContext('post'); 7 + let { post, context }: { post: Post, context: PostContext } = getContext('post'); 7 8 let presenter = new PostPresenter(post, context); 8 9 9 - let avatar; 10 + let avatar: HTMLImageElement; 10 11 11 12 $effect(() => { 12 13 if (avatar) {
+3 -2
src/components/posts/PostSubtreeLink.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { linkToPostThread } from '../../router.js'; 3 + import { Post } from '../../models/posts.js'; 3 4 4 - let { post, title = '' } = $props(); 5 + let { post, title = '' }: { post: Post, title?: string } = $props(); 5 6 </script> 6 7 7 8 <a href="{linkToPostThread(post)}" class="action" {title}>
+3 -2
src/components/posts/PostTagsRow.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { getContext } from 'svelte'; 3 + import { Post } from '../../models/posts.js'; 3 4 import { linkToHashtagPage } from '../../router.js'; 4 5 5 - let { post } = getContext('post'); 6 + let { post }: { post: Post } = getContext('post'); 6 7 </script> 7 8 8 9 <p class="tags">
+5 -4
src/components/posts/ReferencedPostAuthorLink.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { getContext } from 'svelte'; 3 + import { Post } from '../../models/posts.js'; 3 4 import { atURI } from '../../utils.js'; 4 5 5 - let { status = undefined } = $props(); 6 - let { post } = getContext('post'); 6 + let { status = undefined }: { status?: string | undefined } = $props(); 7 + let { post }: { post: Post } = getContext('post'); 7 8 8 - let handle = $state(); 9 + let handle: string | undefined = $state(); 9 10 10 11 $effect(async () => { 11 12 let did = atURI(post.uri).repo;
+2 -2
src/components/posts/ThreadRootParent.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { Post, BlockedPost, MissingPost } from '../../models/posts.js'; 3 3 import { linkToPostThread } from '../../router.js'; 4 4 import { setContext } from 'svelte'; 5 5 import BlockedPostView from './BlockedPostView.svelte'; 6 6 7 - let { post } = $props(); 7 + let { post }: { post: AnyPost } = $props(); 8 8 9 9 setContext('post', { post: post, context: 'parent' }); 10 10 </script>
+2 -2
src/components/posts/ThreadRootParentRaw.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { linkToPostById } from '../../router.js'; 3 3 import { atURI } from '../../utils.js'; 4 4 5 - let { uri } = $props(); 5 + let { uri }: { uri: string } = $props(); 6 6 let { repo, rkey } = $derived(atURI(uri)); 7 7 </script> 8 8
+5 -5
src/pages/HashtagPage.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { Post } from '../models/posts.js'; 3 3 import * as paginator from '../utils/paginator.js'; 4 4 import MainLoader from '../components/MainLoader.svelte'; 5 5 import PostComponent from '../components/posts/PostComponent.svelte'; 6 6 7 - let { hashtag } = $props(); 7 + let { hashtag }: { hashtag: string } = $props(); 8 8 hashtag = hashtag.replace(/^\#/, ''); 9 9 10 - let posts = $state([]); 10 + let posts: Post[] = $state([]); 11 11 let firstPageLoaded = $state(false); 12 12 let loadingFailed = $state(false); 13 13 14 14 let isLoading = false; 15 15 let finished = false; 16 - let cursor; 16 + let cursor: string | undefined; 17 17 18 18 paginator.loadInPages(async () => { 19 19 if (isLoading || finished) { return } ··· 21 21 22 22 try { 23 23 let data = await api.getHashtagFeed(hashtag, cursor); 24 - let batch = data.posts.map(j => new Post(j)); 24 + let batch = data.posts.map(j => new Post(j)) as Post[]; 25 25 firstPageLoaded = true; 26 26 27 27 posts.splice(posts.length, 0, ...batch);
+7 -7
src/pages/LikeStatsPage.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import LikeStatsTable from '../components/LikeStatsTable.svelte'; 3 - import { LikeStats } from '../services/like_stats.js'; 3 + import { LikeStats, LikeStat } from '../services/like_stats.js'; 4 4 import { numberOfDays } from '../utils.js'; 5 5 6 6 let timeRangeDays = $state(7); 7 - let progress = $state(); 7 + let progress: number | undefined = $state(); 8 8 let scanInProgress = $derived(progress !== undefined); 9 - let givenLikesUsers = $state(); 10 - let receivedLikesUsers = $state(); 9 + let givenLikesUsers: LikeStat[] | undefined = $state(); 10 + let receivedLikesUsers: LikeStat[] | undefined = $state(); 11 11 12 12 let likeStats = new LikeStats(); 13 13 14 - async function startScan(e) { 14 + async function startScan(e: Event) { 15 15 e.preventDefault(); 16 16 17 17 if (!scanInProgress) { ··· 47 47 </p> 48 48 </form> 49 49 50 - {#if givenLikesUsers} 50 + {#if givenLikesUsers && receivedLikesUsers} 51 51 <LikeStatsTable cssClass="given-likes" header="❤️ Likes from you:" users={givenLikesUsers} /> 52 52 <LikeStatsTable cssClass="received-likes" header="💛 Likes on your posts:" users={receivedLikesUsers} /> 53 53 {/if}
+15 -25
src/pages/LycanSearchPage.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 + import { Post } from '../models/posts'; 3 + import { Lycan } from '../services/lycan'; 2 4 import PostComponent from '../components/posts/PostComponent.svelte'; 3 5 4 6 const collections = [ ··· 8 10 { id: 'pins', title: 'Pins' } 9 11 ] 10 12 11 - let { lycan } = $props(); 13 + let { lycan }: { lycan: Lycan } = $props(); 12 14 13 15 let isCheckingStatus = $state(false); 14 - let importStatus = $state(); 15 - let importStatusLabel = $state(); 16 - let importProgress = $state(); 16 + let importStatus: string | undefined = $state(); 17 + let importStatusLabel: string | undefined = $state(); 18 + let importProgress = $state(0); 17 19 let wasImporting = $state(false); 18 - let importTimer; 20 + let importTimer: number | undefined; 19 21 20 22 let selectedCollection = $state(collections[0].id); 21 23 let query = $state(''); 22 24 23 25 let loadingPosts = $state(false); 24 26 let finishedPosts = $state(false); 25 - let results = $state([]); 26 - let highlightedMatches = $state([]); 27 + let results: Post[] = $state([]); 28 + let highlightedMatches: string[] = $state([]); 27 29 28 30 checkImportStatus(); 29 31 30 32 31 - /** @param {Event} e */ 32 - 33 - function onFormSubmit(e) { 33 + function onFormSubmit(e: Event) { 34 34 e.preventDefault(); 35 35 36 36 showImportStatus({ status: 'requested' }); ··· 42 42 }); 43 43 } 44 44 45 - /** @param {KeyboardEvent} e */ 46 - 47 - function onKeyPress(e) { 45 + function onKeyPress(e: KeyboardEvent) { 48 46 if (e.key == 'Enter') { 49 47 e.preventDefault(); 50 48 ··· 72 70 } 73 71 } 74 72 75 - /** @returns {Promise<void>} */ 76 - 77 73 async function checkImportStatus() { 78 74 if (isCheckingStatus) { 79 75 return; ··· 91 87 } 92 88 } 93 89 94 - /** @param {json} info */ 95 - 96 - function showImportStatus(info) { 90 + function showImportStatus(info: json) { 97 91 console.log(info); 98 92 99 93 if (!info.status) { ··· 119 113 isImporting ? startImportTimer() : stopImportTimer(); 120 114 } 121 115 122 - /** @param {json} info */ 123 - 124 - function showImportProgress(info) { 116 + function showImportProgress(info: json) { 125 117 importProgress = Math.max(0, Math.min(info.progress || 0)); 126 118 127 119 if (info.progress == 1.0) { ··· 136 128 } 137 129 } 138 130 139 - /** @param {string} message */ 140 - 141 - function showImportError(message) { 131 + function showImportError(message: string) { 142 132 importStatus = 'error'; 143 133 wasImporting = true; 144 134 importStatusLabel = message;
+3 -3
src/pages/NotificationsPage.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { Post } from '../models/posts.js'; 3 3 import * as paginator from '../utils/paginator.js'; 4 4 import FeedPostParent from '../components/posts/FeedPostParent.svelte'; 5 5 import MainLoader from '../components/MainLoader.svelte'; 6 6 import PostComponent from '../components/posts/PostComponent.svelte'; 7 7 8 - let posts = $state([]); 8 + let posts: Post[] = $state([]); 9 9 let firstPageLoaded = $state(false); 10 10 let loadingFailed = $state(false); 11 11 12 12 let isLoading = false; 13 13 let finished = false; 14 - let cursor; 14 + let cursor: string | undefined; 15 15 16 16 paginator.loadInPages(async (next) => { 17 17 if (isLoading || finished) { return }
+15 -19
src/pages/PostingStatsPage.svelte
··· 1 - <script> 2 - import UserAutocomplete from '../components/UserAutocomplete.svelte'; 1 + <script lang="ts"> 2 + import UserAutocomplete, { AutocompleteUser } from '../components/UserAutocomplete.svelte'; 3 3 import PostingStatsTable from '../components/PostingStatsTable.svelte'; 4 - import { PostingStats } from '../services/posting_stats.js'; 4 + import { PostingStats, PostingStatsResult } from '../services/posting_stats.js'; 5 5 import { numberOfDays } from '../utils.js'; 6 6 7 7 const tabs = [ ··· 11 11 { id: 'you', title: 'Your profile' } 12 12 ] 13 13 14 - let lists = $state([]); 14 + let lists: json[] = $state([]); 15 15 16 16 let timeRangeDays = $state(7); 17 17 let selectedTab = $state(tabs[0].id); 18 - let selectedUsers = $state([]); 19 - let selectedList = $state(); 18 + let selectedUsers: AutocompleteUser[] = $state([]); 19 + let selectedList: string | undefined = $state(); 20 20 21 21 let scanInProgress = $state(false); 22 - let requestedDays = $state(); 23 - let progress = $state(); 22 + let requestedDays: number | undefined = $state(); 23 + let progress: number | undefined = $state(); 24 24 let scanInfo = $state(); 25 25 26 26 let tableOptions = $state({}); 27 - let results = $state(); 27 + let results: PostingStatsResult | null = $state(null); 28 28 29 - let scanner = new PostingStats((p) => { progress = Math.max(progress, p) }); 29 + let scanner = new PostingStats((p) => { progress = Math.max(progress || 0, p) }); 30 30 31 31 $effect(() => { 32 32 fetchLists(); 33 33 }) 34 34 35 - /** @param {Event} e */ 36 - 37 - function onTabChange(e) { 38 - results = undefined; 35 + function onTabChange(e: Event) { 36 + results = null; 39 37 } 40 38 41 - /** @returns {Promise<void>} */ 42 - 43 39 async function fetchLists() { 44 40 let result = await accountAPI.loadUserLists(); 45 41 ··· 53 49 selectedList = lists[0]?.uri; 54 50 } 55 51 56 - function onsubmit(e) { 52 + function onsubmit(e: Event) { 57 53 e.preventDefault(); 58 54 59 55 if (!scanInProgress) { ··· 70 66 } 71 67 72 68 scanInfo = undefined; 73 - results = undefined; 69 + results = null; 74 70 requestedDays = timeRangeDays; 75 71 progress = 0; 76 72 scanInProgress = true; ··· 88 84 results = await scanner.scanHomeTimeline(requestedDays); 89 85 } else if (selectedTab == 'list') { 90 86 tableOptions = { showReposts: false }; 91 - results = await scanner.scanListTimeline(selectedList, requestedDays); 87 + results = await scanner.scanListTimeline(selectedList!, requestedDays); 92 88 } else if (selectedTab == 'users') { 93 89 results = await scanner.scanUserTimelines(selectedUsers, requestedDays); 94 90 tableOptions = { showTotal: false, showPercentages: false };
+7 -8
src/pages/QuotesPage.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { Post } from '../models/posts.js'; 3 3 import * as paginator from '../utils/paginator.js'; 4 4 import FeedPostParent from '../components/posts/FeedPostParent.svelte'; ··· 6 6 import PostComponent from '../components/posts/PostComponent.svelte'; 7 7 8 8 let isLoading = false; 9 - let cursor; 9 + let cursor: string | undefined; 10 10 let finished = false; 11 11 12 - let { postURL } = $props(); 12 + let { postURL }: { postURL: string } = $props(); 13 13 14 - let posts = $state([]); 15 - let quoteCount = $state(); 16 - let firstPageLoaded = $derived(quoteCount !== undefined); 14 + let posts: Post[] = $state([]); 15 + let quoteCount: number | undefined = $state(); 17 16 let loadingFailed = $state(false); 18 17 19 18 paginator.loadInPages(async () => { ··· 25 24 let jsons = await api.loadPosts(data.posts); 26 25 let batch = jsons.map(j => new Post(j)); 27 26 28 - if (!firstPageLoaded) { 27 + if (quoteCount === undefined) { 29 28 quoteCount = data.quoteCount; 30 29 } 31 30 ··· 45 44 }); 46 45 </script> 47 46 48 - {#if firstPageLoaded} 47 + {#if quoteCount !== undefined} 49 48 <header> 50 49 <h2> 51 50 {#if quoteCount > 1}
+18 -13
src/pages/ThreadPage.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import { Post, parseThreadPost } from '../models/posts.js'; 3 3 import { showError } from '../utils.js'; 4 4 import MainLoader from '../components/MainLoader.svelte'; ··· 6 6 import ThreadRootParent from '../components/posts/ThreadRootParent.svelte'; 7 7 import ThreadRootParentRaw from '../components/posts/ThreadRootParentRaw.svelte'; 8 8 9 - let { url = undefined, author = undefined, rkey = undefined } = $props(); 10 - let post = $state(); 9 + type Props = { url: string } | { author: string, rkey: string }; 10 + 11 + let props: Props = $props(); 12 + let post: AnyPost | undefined = $state(); 11 13 let loadingFailed = $state(false); 12 14 13 - let response; 15 + let rootComponent: PostComponent; 16 + let response: Promise<json>; 17 + 18 + if ('url' in props) { 19 + let { url } = props; 20 + 21 + if (url.startsWith('at://')) { 22 + response = api.loadThreadByAtURI(url); 23 + } else { 24 + response = api.loadThreadByURL(url); 25 + } 26 + } else { 27 + let { author, rkey } = props; 14 28 15 - if (url && url.startsWith('at://')) { 16 - response = api.loadThreadByAtURI(url); 17 - } else if (url) { 18 - response = api.loadThreadByURL(url); 19 - } else if (author && rkey) { 20 29 response = api.loadThreadById(author, rkey); 21 - } else { 22 - throw 'Either url or author & rkey must be set'; 23 30 } 24 - 25 - let rootComponent; 26 31 27 32 response.then((json) => { 28 33 let root = parseThreadPost(json.thread);
+8 -7
src/pages/TimelineSearchPage.svelte
··· 1 - <script> 1 + <script lang="ts"> 2 2 import PostComponent from '../components/posts/PostComponent.svelte'; 3 + import { Post } from '../models/posts'; 3 4 import { TimelineSearch } from '../services/timeline_search.js'; 4 5 import { numberOfDays } from '../utils.js'; 5 6 6 7 let timeRangeDays = $state(7); 7 - let progressMax = $state(); 8 - let progress = $state(); 8 + let progressMax: number | undefined = $state(); 9 + let progress: number | undefined = $state(); 9 10 let fetchInProgress = $derived(progress !== undefined); 10 - let daysFetched = $state(); 11 + let daysFetched: number | undefined = $state(); 11 12 12 13 let query = $state(''); 13 - let results = $state([]); 14 + let results: Post[] = $state([]); 14 15 15 16 let timelineSearch = new TimelineSearch(); 16 17 17 - async function startScan(e) { 18 + async function startScan(e: Event) { 18 19 e.preventDefault(); 19 20 20 21 if (!fetchInProgress) { ··· 31 32 } 32 33 } 33 34 34 - function onKeyPress(e) { 35 + function onKeyPress(e: KeyboardEvent) { 35 36 if (e.key == 'Enter') { 36 37 e.preventDefault(); 37 38
+24 -44
src/services/like_stats.js src/services/like_stats.ts
··· 1 1 import { atURI, feedPostTime } from '../utils.js'; 2 2 import { BlueskyAPI } from '../api/api.js'; 3 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 + 4 8 export class LikeStats { 5 - 6 - /** @type {number | undefined} */ 7 - scanStartTime; 9 + scanStartTime: number | undefined; 10 + appView: BlueskyAPI; 11 + progressPosts: number; 12 + progressLikeRecords: number; 13 + progressPostLikes: number; 14 + onProgress: ((days: number) => void) | undefined 8 15 9 16 constructor() { 10 17 this.appView = new BlueskyAPI('public.api.bsky.app', false); ··· 14 21 this.progressPostLikes = 0; 15 22 } 16 23 17 - /** 18 - * @param {number} requestedDays 19 - * @param {(days: number) => void} onProgress 20 - * @returns {Promise<{ givenLikes: LikeStat[], receivedLikes: LikeStat[] }>} 21 - */ 22 - 23 - async findLikes(requestedDays, onProgress) { 24 + async findLikes(requestedDays: number, onProgress: (days: number) => void): Promise<LikeStatsResponse> { 24 25 this.onProgress = onProgress; 25 26 this.resetProgress(); 26 27 this.scanStartTime = new Date().getTime(); ··· 38 39 let profileInfo = await appView.getRequest('app.bsky.actor.getProfiles', { actors: topGiven.map(x => x.did) }); 39 40 40 41 for (let profile of profileInfo.profiles) { 41 - let user = /** @type {LikeStat} */ (topGiven.find(x => x.did == profile.did)); 42 + let user = topGiven.find(x => x.did == profile.did)!; 42 43 user.handle = profile.handle; 43 44 user.avatar = profile.avatar; 44 45 } ··· 48 49 return { givenLikes: topGiven, receivedLikes: topReceived }; 49 50 } 50 51 51 - /** @param {number} requestedDays, @returns {Promise<json[]>} */ 52 - 53 - async fetchGivenLikes(requestedDays) { 54 - let startTime = /** @type {number} */ (this.scanStartTime); 52 + async fetchGivenLikes(requestedDays: number): Promise<json[]> { 53 + let startTime = this.scanStartTime! 55 54 56 55 return await accountAPI.fetchAll('com.atproto.repo.listRecords', { 57 56 params: { ··· 74 73 }); 75 74 } 76 75 77 - /** @param {number} requestedDays, @returns {Promise<json[]>} */ 78 - 79 - async fetchReceivedLikes(requestedDays) { 80 - let startTime = /** @type {number} */ (this.scanStartTime); 76 + async fetchReceivedLikes(requestedDays: number): Promise<json[]> { 77 + let startTime = this.scanStartTime! 81 78 82 79 let myPosts = await this.appView.loadUserTimeline(accountAPI.user.did, requestedDays, { 83 80 filter: 'posts_with_replies', ··· 95 92 96 93 let likedPosts = myPosts.filter(x => !x['reason'] && x['post']['likeCount'] > 0); 97 94 98 - let results = []; 95 + let results: json[][] = []; 99 96 100 97 for (let i = 0; i < likedPosts.length; i += 10) { 101 98 let batch = likedPosts.slice(i, i + 10); ··· 120 117 return results.flat(); 121 118 } 122 119 123 - /** 124 - * @typedef {{ handle?: string, did?: string, avatar?: string, count: number }} LikeStat 125 - * @typedef {Record<string, LikeStat>} LikeStatHash 126 - */ 127 - 128 - /** @param {json[]} likes, @returns {LikeStatHash} */ 129 - 130 - sumUpReceivedLikes(likes) { 131 - /** @type {LikeStatHash} */ 132 - let stats = {}; 120 + sumUpReceivedLikes(likes: json[]): LikeStatHash { 121 + let stats: LikeStatHash = {}; 133 122 134 123 for (let like of likes) { 135 124 let handle = like.actor.handle; ··· 144 133 return stats; 145 134 } 146 135 147 - /** @param {json[]} likes, @returns {LikeStatHash} */ 148 - 149 - sumUpGivenLikes(likes) { 150 - /** @type {LikeStatHash} */ 151 - let stats = {}; 136 + sumUpGivenLikes(likes: json[]): LikeStatHash { 137 + let stats: LikeStatHash = {}; 152 138 153 139 for (let like of likes) { 154 140 let did = atURI(like.value.subject.uri).repo; ··· 163 149 return stats; 164 150 } 165 151 166 - /** @param {LikeStatHash} counts, @returns {LikeStat[]} */ 167 - 168 - getTopEntries(counts) { 152 + getTopEntries(counts: LikeStatHash): LikeStat[] { 169 153 return Object.entries(counts).sort(this.sortResults).map(x => x[1]).slice(0, 25); 170 154 } 171 155 ··· 177 161 this.onProgress && this.onProgress(0); 178 162 } 179 163 180 - /** @param {{ posts?: number, likeRecords?: number, postLikes?: number }} data */ 181 - 182 - updateProgress(data) { 164 + updateProgress(data: { posts?: number, likeRecords?: number, postLikes?: number }) { 183 165 if (data.posts) { 184 166 this.progressPosts = data.posts; 185 167 } ··· 201 183 this.onProgress && this.onProgress(totalProgress); 202 184 } 203 185 204 - /** @param {[string, LikeStat]} a, @param {[string, LikeStat]} b, @returns {-1|1|0} */ 205 - 206 - sortResults(a, b) { 186 + sortResults(a: [string, LikeStat], b: [string, LikeStat]): -1 | 1 | 0 { 207 187 if (a[1].count < b[1].count) { 208 188 return 1; 209 189 } else if (a[1].count > b[1].count) {
+10 -27
src/services/lycan.js src/services/lycan.ts
··· 2 2 import * as paginator from '../utils/paginator.js'; 3 3 import { BlueskyAPI } from '../api/api.js'; 4 4 5 - export class Lycan { 6 - 7 - /** @returns {json} */ 5 + export type OnPostsLoaded = (data: { posts: Post[], terms: string[] }) => void 6 + export type OnFinish = () => void 8 7 8 + export class Lycan { 9 9 get proxyHeaders() { 10 10 return { 'atproto-proxy': 'did:web:lycan.feeds.blue#lycan' }; 11 11 } 12 - 13 - /** @returns {Promise<json>} */ 14 12 15 13 async getImportStatus() { 16 14 return await accountAPI.getRequest('blue.feeds.lycan.getImportStatus', null, { headers: this.proxyHeaders }); 17 15 } 18 - 19 - /** @returns {Promise<void>} */ 20 16 21 17 async startImport() { 22 18 await accountAPI.postRequest('blue.feeds.lycan.startImport', null, { headers: this.proxyHeaders }); 23 19 } 24 20 25 - /** @returns {Promise<json>} */ 26 - 27 - async makeQuery(collection, query, cursor) { 28 - let params = { collection, query }; 21 + async makeQuery(collection: string, query: string, cursor: string | undefined) { 22 + let params: Record<string, string> = { collection, query }; 29 23 if (cursor) params.cursor = cursor; 30 24 31 25 return await accountAPI.getRequest('blue.feeds.lycan.searchPosts', params, { headers: this.proxyHeaders }); 32 26 } 33 27 34 - /** 35 - * @param {string} collection 36 - * @param {string} query 37 - * @param {{ onPostsLoaded: (data: { posts: Post[], terms: string[] }) => void, onFinish?: () => void }} callbacks 38 - */ 39 - 40 - searchPosts(collection, query, callbacks) { 28 + searchPosts(collection: string, query: string, callbacks: { onPostsLoaded: OnPostsLoaded, onFinish: OnFinish }) { 41 29 let isLoading = false; 42 30 let finished = false; 43 - let cursor; 31 + let cursor: string | undefined; 44 32 45 33 paginator.loadInPages(async () => { 46 34 if (isLoading || finished) { return; } ··· 65 53 } 66 54 67 55 export class DevLycan extends Lycan { 56 + localLycan: BlueskyAPI; 68 57 69 58 constructor() { 70 59 super(); 71 60 this.localLycan = new BlueskyAPI('http://localhost:3000', false); 72 61 } 73 62 74 - /** @returns {Promise<json>} */ 75 - 76 63 async getImportStatus() { 77 64 return await this.localLycan.getRequest('blue.feeds.lycan.getImportStatus', { user: accountAPI.user.did }); 78 65 } 79 - 80 - /** @returns {Promise<void>} */ 81 66 82 67 async startImport() { 83 68 await this.localLycan.postRequest('blue.feeds.lycan.startImport', { user: accountAPI.user.did }); 84 69 } 85 70 86 - /** @returns {Promise<json>} */ 87 - 88 - async makeQuery(collection, query, cursor) { 89 - let params = { collection, query, user: accountAPI.user.did }; 71 + async makeQuery(collection: string, query: string, cursor: string | undefined) { 72 + let params: Record<string, string> = { collection, query, user: accountAPI.user.did }; 90 73 if (cursor) params.cursor = cursor; 91 74 92 75 return await this.localLycan.getRequest('blue.feeds.lycan.searchPosts', params);
+43 -65
src/services/posting_stats.js src/services/posting_stats.ts
··· 5 5 * Manages the Posting Stats page. 6 6 */ 7 7 8 - export class PostingStats { 8 + type GenerateResultsOptions = { 9 + countFetchedDays?: boolean 10 + users?: UserWithHandle[] 11 + } 9 12 10 - /** @type {number | undefined} */ 11 - scanStartTime; 13 + export type OnProgress = ((progress: number) => void); 12 14 13 - /** @type {Record<string, { pages: number, progress: number }>} */ 14 - userProgress = {}; 15 + export type UserWithHandle = { 16 + did: string, 17 + handle: string, 18 + avatar?: string 19 + } 15 20 16 - /** 17 - * @typedef {{ 18 - * did: string, 19 - * handle: string, 20 - * avatar?: string 21 - * }} UserWithHandle 22 - * 23 - * @typedef {{ 24 - * handle: string, 25 - * avatar: string | undefined, 26 - * own: number, 27 - * reposts: number, 28 - * all: number 29 - * }} PostingStatsResultRow 30 - * 31 - * @typedef {{ 32 - * users: PostingStatsResultRow[], 33 - * sums: { own: number, reposts: number, all: number }, 34 - * fetchedDays: number, 35 - * daysBack: number 36 - * }} PostingStatsResult 37 - */ 21 + export type PostingStatsResultRow = { 22 + handle: string, 23 + avatar: string | undefined, 24 + own: number, 25 + reposts: number, 26 + all: number 27 + } 38 28 39 - /** @param {((progress: number) => void)=} onProgress */ 29 + export type PostingStatsResult = { 30 + users: PostingStatsResultRow[], 31 + sums: { own: number, reposts: number, all: number }, 32 + fetchedDays: number, 33 + daysBack: number 34 + } 40 35 41 - constructor(onProgress) { 36 + export class PostingStats { 37 + appView: BlueskyAPI; 38 + scanStartTime: number | undefined; 39 + userProgress: Record<string, { pages: number, progress: number }>; 40 + onProgress: OnProgress | undefined; 41 + 42 + constructor(onProgress?: OnProgress) { 42 43 this.onProgress = onProgress; 43 44 this.appView = new BlueskyAPI('public.api.bsky.app', false); 45 + this.userProgress = {}; 44 46 } 45 47 46 - /** @param {json[]} data, @param {number} startTime */ 47 - 48 - onPageLoad(data, startTime) { 48 + onPageLoad(data: json[], startTime: number): { cancel: true } | undefined { 49 49 if (this.scanStartTime == startTime) { 50 50 this.updateProgress(data, startTime); 51 51 } else { ··· 53 53 } 54 54 } 55 55 56 - /** @param {number} requestedDays, @returns {Promise<PostingStatsResult?>} */ 57 - 58 - async scanHomeTimeline(requestedDays) { 56 + async scanHomeTimeline(requestedDays: number): Promise<PostingStatsResult | null> { 59 57 let startTime = new Date().getTime(); 60 58 this.scanStartTime = startTime; 61 59 ··· 67 65 return this.generateResults(posts, requestedDays, startTime); 68 66 } 69 67 70 - /** @param {string} listURI, @param {number} requestedDays, @returns {Promise<PostingStatsResult?>} */ 71 - 72 - async scanListTimeline(listURI, requestedDays) { 68 + async scanListTimeline(listURI: string, requestedDays: number): Promise<PostingStatsResult | null> { 73 69 let startTime = new Date().getTime(); 74 70 this.scanStartTime = startTime; 75 71 ··· 81 77 return this.generateResults(posts, requestedDays, startTime); 82 78 } 83 79 84 - /** @param {UserWithHandle[]} users, @returns {Promise<PostingStatsResult?>} */ 85 - 86 - async scanUserTimelines(users, requestedDays) { 80 + async scanUserTimelines(users: UserWithHandle[], requestedDays: number): Promise<PostingStatsResult | null> { 87 81 let startTime = new Date().getTime(); 88 82 this.scanStartTime = startTime; 89 83 ··· 108 102 return this.generateResults(posts, requestedDays, startTime, { countFetchedDays: false, users: users }); 109 103 } 110 104 111 - /** @param {number} requestedDays, @returns {Promise<PostingStatsResult?>} */ 112 - 113 - async scanYourTimeline(requestedDays) { 105 + async scanYourTimeline(requestedDays: number): Promise<PostingStatsResult | null> { 114 106 let startTime = new Date().getTime(); 115 107 this.scanStartTime = startTime; 116 108 ··· 123 115 return this.generateResults(posts, requestedDays, startTime); 124 116 } 125 117 126 - /** 127 - * @param {json[]} posts 128 - * @param {number} requestedDays 129 - * @param {number} startTime 130 - * @param {{ countFetchedDays?: boolean, users?: UserWithHandle[] }} options 131 - * @returns {PostingStatsResult?} 132 - */ 133 - 134 - generateResults(posts, requestedDays, startTime, options = {}) { 118 + generateResults(posts: json[], requestedDays: number, startTime: number, options: GenerateResultsOptions = {}) { 135 119 let last = posts.at(-1); 136 120 137 121 if (!last) { ··· 143 127 return null; 144 128 } 145 129 146 - let users = {}; 130 + let users: Record<string, PostingStatsResultRow> = {}; 147 131 148 132 let lastDate = feedPostTime(last); 149 133 let fetchedDays = (startTime - lastDate) / 86400 / 1000; 150 - let daysBack; 134 + let daysBack: number; 151 135 152 136 if (options.countFetchedDays !== false) { 153 137 daysBack = Math.min(requestedDays, fetchedDays); ··· 161 145 162 146 if (options.users) { 163 147 for (let user of options.users) { 164 - users[user.handle] = { handle: user.handle, own: 0, reposts: 0, avatar: user.avatar }; 148 + users[user.handle] = { handle: user.handle, own: 0, reposts: 0, avatar: user.avatar } as PostingStatsResultRow; 165 149 } 166 150 } 167 151 ··· 199 183 return { users: userRows, sums, fetchedDays, daysBack }; 200 184 } 201 185 202 - /** @param {json[]} dataPage, @param {number} startTime */ 203 - 204 - updateProgress(dataPage, startTime) { 186 + updateProgress(dataPage: json[], startTime: number) { 205 187 let last = dataPage.at(-1); 206 188 207 189 if (!last) { return } ··· 212 194 this.onProgress && this.onProgress(daysBack); 213 195 } 214 196 215 - /** @param {string[]} dids */ 216 - 217 - resetUserProgress(dids) { 197 + resetUserProgress(dids: string[]) { 218 198 this.userProgress = {}; 219 199 220 200 for (let did of dids) { ··· 222 202 } 223 203 } 224 204 225 - /** @param {string} did, @param {json[]} dataPage, @param {number} startTime, @param {number} requestedDays */ 226 - 227 - updateUserProgress(did, dataPage, startTime, requestedDays) { 205 + updateUserProgress(did: string, dataPage: json[], startTime: number, requestedDays: number) { 228 206 let last = dataPage.at(-1); 229 207 230 208 if (!last) { return }
+5 -18
src/services/timeline_search.js src/services/timeline_search.ts
··· 2 2 import { feedPostTime } from '../utils.js'; 3 3 4 4 export class TimelineSearch { 5 - 6 - /** @type {number | undefined} */ 7 - fetchStartTime; 8 - 9 - /** @type {json[]} */ 10 - timelinePosts; 5 + fetchStartTime: number | undefined; 6 + timelinePosts: json[]; 11 7 12 8 constructor() { 13 9 this.timelinePosts = []; 14 10 } 15 11 16 - /** 17 - * @param {number} requestedDays 18 - * @param {(progress: number) => void} onProgress 19 - */ 20 - 21 - async fetchTimeline(requestedDays, onProgress) { 12 + async fetchTimeline(requestedDays: number, onProgress: (progress: number) => void) { 22 13 let startTime = new Date().getTime(); 23 14 this.fetchStartTime = startTime; 24 15 ··· 43 34 this.fetchStartTime = undefined; 44 35 } 45 36 46 - /** @param {json[]} dataPage, @param {number} startTime, @returns {number?} */ 47 - 48 - calculateProgress(dataPage, startTime) { 37 + calculateProgress(dataPage: json[], startTime: number) { 49 38 let last = dataPage.at(-1); 50 39 51 40 if (!last) { return null; } ··· 55 44 return daysBack; 56 45 } 57 46 58 - /** @param {string} query, @returns {Post[]} */ 59 - 60 - searchPosts(query) { 47 + searchPosts(query: string): Post[] { 61 48 if (query.length == 0) { 62 49 return []; 63 50 }
+14 -9
src/types.d.ts
··· 1 1 interface Window { 2 2 dateLocale: string | undefined; 3 - root: AnyPost; 4 - subtreeRoot: AnyPost; 3 + root: import("./models/posts.js").AnyPost; 4 + subtreeRoot: import("./models/posts.js").AnyPost; 5 5 init: () => void; 6 - BlueskyAPI: BlueskyAPI; 6 + BlueskyAPI: { new(host: string | null, useAuthentication: boolean): import("./api/api.js").BlueskyAPI }; 7 7 } 8 8 9 - declare var accountAPI: BlueskyAPI; 10 - declare var blueAPI: BlueskyAPI; 11 - declare var appView: BlueskyAPI; 12 - declare var api: BlueskyAPI; 9 + declare var accountAPI: import("./api/api.js").BlueskyAPI; 10 + declare var blueAPI: import("./api/api.js").BlueskyAPI; 11 + declare var appView: import("./api/api.js").BlueskyAPI; 12 + declare var api: import("./api/api.js").BlueskyAPI; 13 13 declare var avatarPreloader: IntersectionObserver; 14 14 15 15 type json = Record<string, any>; 16 - type FetchAllOnPageLoad = (obj: json[]) => { cancel: true } | void; 17 - type AnyPost = Post | BlockedPost | MissingPost | DetachedQuotePost; 16 + type FetchAllOnPageLoad = (obj: json[]) => { cancel: true } | undefined | void; 17 + 18 + type AnyPost = import("./models/posts.js").Post 19 + | import("./models/posts.js").BlockedPost 20 + | import("./models/posts.js").MissingPost 21 + | import("./models/posts.js").DetachedQuotePost; 22 + 18 23 type PostContext = 'thread' | 'parent' | 'quote' | 'quotes' | 'feed';
+7 -6
src/utils/post_presenter.js src/utils/post_presenter.ts
··· 1 1 import { sameDay } from '../utils.js'; 2 + import { Post } from '../models/posts.js'; 2 3 3 4 export class PostPresenter { 5 + 4 6 /** 5 7 * Contexts: 6 8 * - thread - a post in the thread tree ··· 8 10 * - quote - a quote embed 9 11 * - quotes - a post on the quotes page 10 12 * - feed - a post on the hashtag feed page 11 - * 12 - * @param {AnyPost} post, @param {PostContext} context 13 13 */ 14 14 15 - constructor(post, context) { 15 + post: Post; 16 + context: PostContext; 17 + 18 + constructor(post: Post, context: PostContext) { 16 19 this.post = post; 17 20 this.context = context; 18 21 } 19 22 20 - /** @returns {json} */ 21 - 22 - get timeFormatForTimestamp() { 23 + get timeFormatForTimestamp(): Intl.DateTimeFormatOptions { 23 24 if (this.context == 'quotes' || this.context == 'feed') { 24 25 return { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' }; 25 26 } else if (this.post.isPageRoot || this.context != 'thread') {