diff --git a/api.js b/api.js index a54d8fe..da21e62 100644 --- a/api.js +++ b/api.js @@ -1,9 +1,20 @@ +/** + * Thrown when the passed URL is not a supported post URL on bsky.app. + */ + class URLError extends Error { + + /** @param {string} message */ constructor(message) { super(message); } } + +/** + * Caches the mapping of handles to DIDs to avoid unnecessary API calls to resolveHandle or getProfile. + */ + class HandleCache { prepareCache() { if (!this.cache) { @@ -15,17 +26,23 @@ class HandleCache { localStorage.setItem('handleCache', JSON.stringify(this.cache)); } + /** @param {string} handle, @returns {string | undefined} */ + getHandleDid(handle) { this.prepareCache(); return this.cache[handle]; } + /** @param {string} handle, @param {string} did */ + setHandleDid(handle, did) { this.prepareCache(); this.cache[handle] = did; this.saveCache(); } + /** @param {string} did, @returns {string | undefined} */ + findHandleByDid(did) { this.prepareCache(); let found = Object.entries(this.cache).find((e) => e[1] == did); @@ -33,6 +50,11 @@ class HandleCache { } } + +/** + * Stores user's access tokens and data in local storage after they log in. + */ + class LocalStorageConfig { constructor() { this.user = {}; @@ -49,6 +71,8 @@ class LocalStorageConfig { this.saveItem('avatar', this.user.avatar); } + /** @param {string} key, @param {string | undefined} value */ + saveItem(key, value) { if (value !== undefined) { localStorage.setItem(key, value); @@ -58,7 +82,14 @@ class LocalStorageConfig { } } + +/** + * API client for connecting to the Bluesky XRPC API (authenticated or not). + */ + class BlueskyAPI extends Minisky { + + /** @param {string} host, @param {boolean} useAuthentication */ constructor(host, useAuthentication) { super(host, useAuthentication ? new LocalStorageConfig() : undefined); @@ -66,16 +97,22 @@ class BlueskyAPI extends Minisky { this.profiles = {}; } + /** @param {object} author */ + cacheProfile(author) { this.profiles[author.did] = author; this.profiles[author.handle] = author; this.handleCache.setHandleDid(author.handle, author.did); } + /** @param {string} did, @returns {string | undefined} */ + findHandleByDid(did) { return this.handleCache.findHandleByDid(did); } + /** @param {string} string, @returns {[string, string]} */ + static parsePostURL(string) { let url; @@ -105,6 +142,8 @@ class BlueskyAPI extends Minisky { return [handle, postId]; } + /** @param {string} handle, @returns {Promise} */ + async resolveHandle(handle) { let did = this.handleCache.getHandleDid(handle); @@ -118,11 +157,15 @@ class BlueskyAPI extends Minisky { } } + /** @param {string} url, @returns {Promise} */ + async loadThreadByURL(url) { let [handle, postId] = BlueskyAPI.parsePostURL(url); return await this.loadThreadById(handle, postId); } + /** @param {string} author, @param {string} postId, @returns {Promise} */ + async loadThreadById(author, postId) { let did = author.startsWith('did:') ? author : await this.resolveHandle(author); let postURI = `at://${did}/app.bsky.feed.post/${postId}`; @@ -130,6 +173,8 @@ class BlueskyAPI extends Minisky { return threadJSON; } + /** @param {string} handle, @returns {Promise} */ + async loadUserProfile(handle) { if (this.profiles[handle]) { return this.profiles[handle]; @@ -140,6 +185,8 @@ class BlueskyAPI extends Minisky { } } + /** @returns {Promise} */ + async loadCurrentUserAvatar() { let json = await this.getRequest('com.atproto.repo.getRecord', { repo: this.user.did, @@ -150,11 +197,15 @@ class BlueskyAPI extends Minisky { return json.value.avatar; } + /** @param {string} uri, @returns {Promise} */ + async getQuoteCount(uri) { let json = await this.getRequest('eu.mackuba.private.getQuoteCount', { uri }); return json.quoteCount; } + /** @param {string} url, @returns {Promise} */ + async getQuotes(url) { let [handle, postId] = BlueskyAPI.parsePostURL(url); let did = handle.startsWith('did:') ? handle : await appView.resolveHandle(handle); @@ -164,21 +215,29 @@ class BlueskyAPI extends Minisky { return json; } + /** @param {string} hashtag, @returns {Promise} */ + async getHashtagFeed(hashtag) { let json = await this.getRequest('eu.mackuba.private.getHashtagFeed', { tag: hashtag }); return json.feed; } + /** @param {string} postURI, @returns {Promise} */ + async loadPost(postURI) { let posts = await this.loadPosts([postURI]); return posts[0]; } + /** @param {string[]} uris, @returns {Promise} */ + async loadPosts(uris) { let response = await this.getRequest('app.bsky.feed.getPosts', { uris }); return response.posts; } + /** @param {object} post, @returns {Promise} */ + async likePost(post) { return await this.postRequest('com.atproto.repo.createRecord', { repo: this.user.did, @@ -193,6 +252,8 @@ class BlueskyAPI extends Minisky { }); } + /** @param {string} uri, @returns {Promise} */ + async removeLike(uri) { let { rkey } = atURI(uri); diff --git a/embed_component.js b/embed_component.js index dd1395b..a79e90f 100644 --- a/embed_component.js +++ b/embed_component.js @@ -1,9 +1,17 @@ +/** + * Renders an embed (e.g. image or quoted post) inside the post view. + */ + class EmbedComponent { + + /** @param {Post} post, @param {object} embed */ constructor(post, embed) { this.post = post; this.embed = embed; } + /** @returns {AnyElement} */ + buildElement() { let wrapper, quoteView, mediaView; @@ -48,12 +56,16 @@ class EmbedComponent { } } + /** @returns {AnyElement} */ + quotedPostPlaceholder() { return $tag('div.quote-embed', { html: '

Loading quoted post...

' }); } + /** @returns {AnyElement} */ + buildQuotedPostElement() { let div = $tag('div.quote-embed'); @@ -68,6 +80,8 @@ class EmbedComponent { return div; } + /** @returns {AnyElement} */ + buildLinkComponent() { let a = $tag('a', { href: this.embed.url, text: this.embed.title || this.embed.url }); @@ -76,6 +90,8 @@ class EmbedComponent { return p; } + /** @returns {AnyElement} */ + buildImagesComponent() { let wrapper = $tag('div'); @@ -110,6 +126,8 @@ class EmbedComponent { return wrapper; } + /** @param {string} uri, @param {AnyElement} div, @returns Promise */ + async loadQuotedPost(uri, div) { let result = await api.loadPost(uri); let post = new Post(result, { isEmbed: true }); diff --git a/jsconfig.json b/jsconfig.json index af564b9..cd4b961 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,7 +1,9 @@ { "compilerOptions": { "target": "es2022", - "checkJs": true + "checkJs": true, + "strict": true, + "noImplicitAny": false }, "include": ["*.js", "*.d.ts"] } diff --git a/minisky.js b/minisky.js index 6ffebc5..04b5c99 100644 --- a/minisky.js +++ b/minisky.js @@ -1,4 +1,10 @@ +/** + * Thrown when status code of an API response is not "success". + */ + class APIError extends Error { + + /** @param {number} code, @param {object} json */ constructor(code, json) { super("APIError status " + code + "\n\n" + JSON.stringify(json)); this.code = code; @@ -6,10 +12,27 @@ class APIError extends Error { } } -class AuthError extends Error { -} + +/** + * Thrown when authentication is needed, but access token is invalid or missing. + */ + +class AuthError extends Error {} + + +/** + * Base API client for connecting to an ATProto XRPC API. + */ class Minisky { + + /** + * @typedef {object} MiniskyOptions + * @prop {boolean} [sendAuthHeaders] + * @prop {boolean} [autoManageTokens] + * + * @param {string} host, @param {object} config, @param {MiniskyOptions} [options] + */ constructor(host, config, options) { this.host = host; this.config = config; @@ -24,10 +47,21 @@ class Minisky { } } + /** @returns {boolean} */ + get isLoggedIn() { return !!(this.user && this.user.accessToken && this.user.refreshToken && this.user.did); } + /** + * @typedef {object} MiniskyRequestOptions + * @prop {string | boolean} [auth] + * @prop {Record} [headers] + * + * @param {string} method, @param {object} params, @param {MiniskyRequestOptions} [options] + * @returns {Promise} + */ + async getRequest(method, params, options) { let url = new URL(`${this.baseURL}/${method}`); let auth = options && ('auth' in options) ? options.auth : this.sendAuthHeaders; @@ -56,6 +90,11 @@ class Minisky { return await this.parseResponse(response); } + /** + * @param {string} method, @param {object} [data], @param {MiniskyRequestOptions} [options] + * @returns Promise + */ + async postRequest(method, data, options) { let url = `${this.baseURL}/${method}`; let auth = options && ('auth' in options) ? options.auth : this.sendAuthHeaders; @@ -79,10 +118,8 @@ class Minisky { return await this.parseResponse(response); } - /** - * @param {string | boolean} auth - * @returns {Record} - */ + /** @param {string | boolean} auth, @returns {Record} */ + authHeaders(auth) { if (typeof auth == 'string') { return { 'Authorization': `Bearer ${auth}` }; @@ -97,6 +134,8 @@ class Minisky { } } + /** @param {string} token, @returns {number} */ + tokenExpirationTimestamp(token) { let parts = token.split('.'); if (parts.length != 3) { @@ -113,10 +152,14 @@ class Minisky { return exp * 1000; } + /** @param {Response} response, @param {object} json, @returns {boolean} */ + isInvalidToken(response, json) { return (response.status == 400) && json && ['InvalidToken', 'ExpiredToken'].includes(json.error); } + /** @param {Response} response, @returns {Promise} */ + async parseResponse(response) { let text = await response.text(); let json = text.trim().length > 0 ? JSON.parse(text) : undefined; @@ -128,6 +171,8 @@ class Minisky { } } + /** @returns {Promise} */ + async checkAccess() { if (!this.isLoggedIn) { throw new AuthError("Not logged in"); @@ -140,6 +185,8 @@ class Minisky { } } + /** @param {string} handle, @param {string} password, @returns {Promise} */ + async logIn(handle, password) { let params = { identifier: handle, password: password }; let json = await this.postRequest('com.atproto.server.createSession', params, { auth: false }); @@ -148,6 +195,8 @@ class Minisky { return json; } + /** @returns {Promise} */ + async performTokenRefresh() { console.log('Refreshing access token…'); let json = await this.postRequest('com.atproto.server.refreshSession', null, { auth: this.user.refreshToken }); @@ -155,6 +204,8 @@ class Minisky { return json; } + /** @param {object} json */ + saveTokens(json) { this.user.accessToken = json['accessJwt']; this.user.refreshToken = json['refreshJwt']; diff --git a/models.js b/models.js index 9b4058c..8632774 100644 --- a/models.js +++ b/models.js @@ -1,4 +1,10 @@ +/** + * Generic record type, base class for all records. + */ + class ATProtoRecord { + + /** @param {object} data, @param {object} [extra] */ constructor(data, extra) { this.data = data; @@ -7,27 +13,40 @@ class ATProtoRecord { } } + /** @returns {string} */ get uri() { return this.data.uri; } + /** @returns {string} */ get cid() { return this.data.cid; } + /** @returns {string} */ get rkey() { return atURI(this.uri).rkey; } + /** @returns {string} */ get type() { return this.data.$type; } } + +/** + * Standard Bluesky post record. + */ + class Post extends ATProtoRecord { + /** @type {ATProtoRecord | undefined} */ parent; + + /** @type {object | undefined} */ reason; + /** @param {object} json, @returns {ATProtoRecord} */ static parse(json) { let post; @@ -75,6 +94,7 @@ class Post extends ATProtoRecord { } } + /** @param {object} data, @param {object} [extra] */ constructor(data, extra) { super(data, extra); @@ -98,60 +118,81 @@ class Post extends ATProtoRecord { } } + /** @returns {string} */ get text() { return this.record.text; } + /** @returns {object} */ get facets() { return this.record.facets; } + /** @returns {Date} */ get createdAt() { return new Date(this.record.createdAt); } + /** @returns {number} */ get likeCount() { return this.data.likeCount; } + /** @returns {number} */ get replyCount() { return this.data.replyCount; } + /** @returns {boolean} */ get hasMoreReplies() { return this.replyCount !== undefined && this.replyCount !== this.replies.length; } + /** @returns {number} */ get repostCount() { return this.data.repostCount; } + /** @returns {boolean} */ get liked() { return (this.viewerLike !== undefined); } + /** @returns {boolean | undefined} */ get muted() { return this.author.viewer?.muted; } + /** @returns {string | undefined} */ get muteList() { return this.author.viewer?.mutedByList?.name; } + /** @returns {boolean} */ get hasViewerInfo() { return (this.viewerData !== undefined); } + /** @returns {ATProtoRecord | undefined} */ get parentReference() { return this.record.reply?.parent && new ATProtoRecord(this.record.reply?.parent); } + /** @returns {ATProtoRecord | undefined} */ get rootReference() { return this.record.reply?.root && new ATProtoRecord(this.record.reply?.root); } } + +/** + * Post which is blocked for some reason (the author is blocked, the author has blocked you, or there is a block + * between the post author and the parent author). It only includes a reference, but no post content. + */ + class BlockedPost extends ATProtoRecord { + + /** @param {object} data */ constructor(data) { super(data); @@ -160,16 +201,25 @@ class BlockedPost extends ATProtoRecord { this.author = data.author; } + /** @returns {boolean} */ get blocksUser() { return !!this.author.viewer?.blocking; } + /** @returns {boolean} */ get blockedByUser() { return this.author.viewer?.blockedBy; } } + +/** + * Base class for embed objects. + */ + class Embed { + + /** @param {object} json, @returns {Embed} */ static parse(json) { switch (json.$type) { case 'app.bsky.embed.record': @@ -201,16 +251,20 @@ class Embed { } } + /** @param {object} json */ constructor(json) { this.json = json; } + /** @returns {string} */ get type() { return this.json.$type; } } class ImageEmbed extends Embed { + + /** @param {object} json */ constructor(json) { super(json); this.images = json.images; @@ -218,6 +272,8 @@ class ImageEmbed extends Embed { } class LinkEmbed extends Embed { + + /** @param {object} json */ constructor(json) { super(json); @@ -227,6 +283,8 @@ class LinkEmbed extends Embed { } class RecordEmbed extends Embed { + + /** @param {object} json */ constructor(json) { super(json); this.record = new ATProtoRecord(json.record); @@ -234,6 +292,8 @@ class RecordEmbed extends Embed { } class RecordWithMediaEmbed extends Embed { + + /** @param {object} json */ constructor(json) { super(json); this.record = new ATProtoRecord(json.record.record); @@ -242,6 +302,8 @@ class RecordWithMediaEmbed extends Embed { } class InlineRecordEmbed extends Embed { + + /** @param {object} json */ constructor(json) { super(json); this.post = Post.parse(json.record); @@ -249,6 +311,8 @@ class InlineRecordEmbed extends Embed { } class InlineRecordWithMediaEmbed extends Embed { + + /** @param {object} json */ constructor(json) { super(json); this.post = Post.parse(json.record.record); @@ -257,6 +321,8 @@ class InlineRecordWithMediaEmbed extends Embed { } class InlineLinkEmbed extends Embed { + + /** @param {object} json */ constructor(json) { super(json); @@ -266,12 +332,17 @@ class InlineLinkEmbed extends Embed { } class InlineImageEmbed extends Embed { + + /** @param {object} json */ constructor(json) { super(json); this.images = json.images; } } +// TODO +/** @param {object} a, @param {object} b, @param {Post} parent, @returns {-1 | 0 | 1} */ + function sortReplies(a, b, parent) { if (a.missing && b.missing) { return 0; diff --git a/post_component.js b/post_component.js index 2fe704f..dcd2e31 100644 --- a/post_component.js +++ b/post_component.js @@ -1,28 +1,38 @@ +/** + * Renders a post/thread view and its subviews. + */ + class PostComponent { + /** @param {Post} post, @param {Post} [root] */ constructor(post, root) { this.post = post; this.root = root ?? post; this.isRoot = (this.post === this.root); } + /** @returns {string} */ get linkToAuthor() { return 'https://bsky.app/profile/' + this.post.author.handle; } + /** @returns {string} */ get linkToPost() { return this.linkToAuthor + '/post/' + this.post.rkey; } + /** @returns {string} */ get didLinkToAuthor() { let { repo } = atURI(this.post.uri); return `https://bsky.app/profile/${repo}`; } + /** @returns {string} */ get didLinkToPost() { let { repo, rkey } = atURI(this.post.uri); return `https://bsky.app/profile/${repo}/post/${rkey}`; } + /** @returns {string} */ get authorName() { if (this.post.author.displayName) { return this.post.author.displayName; @@ -33,6 +43,7 @@ class PostComponent { } } + /** @returns {object} */ get timeFormatForTimestamp() { if (this.isRoot) { return { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' }; @@ -50,7 +61,12 @@ class PostComponent { - quote - a quote embed - quotes - a post on the quotes page - feed - a post on the hashtag feed page + + @typedef {'thread' | 'parent' | 'quote' | 'quotes' | 'feed'} PostContext + @param {PostContext} context + @returns {AnyElement} */ + buildElement(context) { let div = $tag('div.post'); @@ -137,6 +153,8 @@ class PostComponent { return div; } + /** @param {PostContext} context, @returns {AnyElement} */ + buildPostHeader(context) { let timeFormat = this.timeFormatForTimestamp; let formattedTime = this.post.createdAt.toLocaleString(window.dateLocale, timeFormat); @@ -167,6 +185,8 @@ class PostComponent { return h; } + /** @param {string} url, @returns {HTMLImageElement} */ + buildUserAvatar(url) { let avatar = $tag('img.avatar', { src: url }); let tries = 0; @@ -184,6 +204,8 @@ class PostComponent { return avatar; } + /** @returns {AnyElement} */ + buildPostBody() { let p = $tag('p.body'); let richText = new RichText({ text: this.post.text, facets: this.post.facets }); @@ -205,6 +227,8 @@ class PostComponent { return p; } + /** @returns {AnyElement} */ + buildStatsFooter() { let stats = $tag('p.stats'); @@ -223,6 +247,8 @@ class PostComponent { return stats; } + /** @returns {AnyElement} */ + buildLoadMoreLink() { let loadMore = $tag('p'); @@ -241,6 +267,8 @@ class PostComponent { return loadMore; } + /** @param {AnyElement} div, @returns {AnyElement} */ + buildBlockedPostElement(div) { let p = $tag('p.blocked-header'); p.innerHTML = ` Blocked post ` + @@ -285,6 +313,8 @@ class PostComponent { return div; } + /** @param {string} uri, @param {AnyElement} div, @returns Promise */ + async loadBlockedPost(uri, div) { let record = await appView.loadPost(this.post.uri); this.post = new Post(record); @@ -306,6 +336,8 @@ class PostComponent { div.appendChild(p); } + /** @param {AnyElement} div */ + toggleSectionFold(div) { let plus = div.querySelector('.plus'); @@ -318,6 +350,8 @@ class PostComponent { } } + /** @param {AnyElement} heart */ + onHeartClick(heart) { if (!this.post.hasViewerInfo) { showLogin(); diff --git a/rich_text_lite.js b/rich_text_lite.js index 62c8131..3af55f2 100644 --- a/rich_text_lite.js +++ b/rich_text_lite.js @@ -26,25 +26,30 @@ // packages/api/src/rich-text/rich-text.ts class RichTextSegment { + /** @param {string} text, @param {object} [facet] */ constructor(text, facet) { this.text = text; this.facet = facet; } + /** @returns {object | undefined} */ get link() { return this.facet?.features?.find(v => v.$type === 'app.bsky.richtext.facet#link'); } + /** @returns {object | undefined} */ get mention() { return this.facet?.features?.find(v => v.$type === 'app.bsky.richtext.facet#mention'); } + /** @returns {object | undefined} */ get tag() { return this.facet?.features?.find(v => v.$type === 'app.bsky.richtext.facet#tag'); } } class RichText { + /** @params {object} props */ constructor(props) { this.unicodeText = new UnicodeString(props.text); this.facets = props.facets; @@ -54,14 +59,17 @@ class RichText { } } + /** @returns {string} */ get text() { return this.unicodeText.toString(); } + /** @returns {number} */ get length() { return this.unicodeText.length; } + /** @returns {number} */ get graphemeLength() { return this.unicodeText.graphemeLength; } @@ -125,23 +133,28 @@ class UnicodeString { static decoder = new TextDecoder(); static segmenter = window.Intl && Intl.Segmenter && new Intl.Segmenter(); + /** @param {string} utf16 */ constructor(utf16) { this.utf16 = utf16; this.utf8 = UnicodeString.encoder.encode(utf16); } + /** @returns number */ get length() { return this.utf8.byteLength; } + /** @returns number */ get graphemeLength() { return Array.from(UnicodeString.segmenter.segment(this.utf16)).length; } + /** @param {number} start, @param {number} end, @returns string */ slice(start, end) { return UnicodeString.decoder.decode(this.utf8.slice(start, end)); } + /** @returns string */ toString() { return this.utf16; } diff --git a/skythread.js b/skythread.js index 42c6f8a..f67a56a 100644 --- a/skythread.js +++ b/skythread.js @@ -88,6 +88,8 @@ function parseQueryParams() { } } +/** @param {Post} post, @returns {AnyElement} */ + function buildParentLink(post) { let p = $tag('p.back'); @@ -154,6 +156,8 @@ function toggleAccount() { menu.style.visibility = (menu.style.visibility == 'visible') ? 'hidden' : 'visible'; } +/** @param {string} [avatar] */ + function showLoggedInStatus(avatar) { let account = $id('account'); @@ -258,10 +262,14 @@ function submitSearch() { } } +/** @param {Post} post */ + function setPageTitle(post) { document.title = `${post.author.displayName}: "${post.text}" - Skythread`; } +/** @param {string} hashtag */ + function loadHashtagPage(hashtag) { hashtag = hashtag.replace(/^\#/, ''); document.title = `#${hashtag} - Skythread`; @@ -306,6 +314,8 @@ function loadHashtagPage(hashtag) { }); } +/** @param {string} url */ + function loadQuotesPage(url) { blue.getQuotes(url).then(data => { let uris = data.posts; @@ -363,6 +373,8 @@ function loadQuotesPage(url) { }); } +/** @param {string[]} uris, @param {(p: object[]) => void} callback, @returns Promise */ + async function loadPostsInBatches(uris, callback) { if (uris.length > 0) { for (let i = 0; i < uris.length; i += 25) { @@ -374,6 +386,8 @@ async function loadPostsInBatches(uris, callback) { } } +/** @param {string} url, @param {string} [postId], @param {AnyElement} [nodeToUpdate] */ + function loadThread(url, postId, nodeToUpdate) { let load = postId ? api.loadThreadById(url, postId) : api.loadThreadByURL(url); diff --git a/types.d.ts b/types.d.ts index 5ee69a3..12665b4 100644 --- a/types.d.ts +++ b/types.d.ts @@ -8,3 +8,24 @@ declare var api: BlueskyAPI; declare var blue: BlueskyAPI; declare var appView: BlueskyAPI; +type SomeElement = Element | HTMLElement | AnyElement; + +interface AnyElement { + classList: CSSClassList; + className: string; + innerText: string; + nextElementSibling: AnyElement; + parentNode: AnyElement; + src: string; + + addEventListener( + type: K, listener: EventListenerOrEventListenerObject + ): void; + + append(e: string | SomeElement): void; + appendChild(e: SomeElement): void; + querySelector(q: string): AnyElement; + remove(): void; + replaceChildren(e: SomeElement): void; + replaceWith(e: SomeElement): void; +} diff --git a/utils.js b/utils.js index f268d2f..bd4f5f7 100644 --- a/utils.js +++ b/utils.js @@ -1,3 +1,24 @@ +class AtURI { + /** @param {string} uri */ + constructor(uri) { + if (!uri.startsWith('at://')) { + throw new URLError(`Not an at:// URI: ${uri}`); + } + + let parts = uri.split('/'); + + if (parts.length != 5) { + throw new URLError(`Invalid at:// URI: ${uri}`); + } + + this.repo = parts[2]; + this.collection = parts[3]; + this.rkey = parts[4]; + } +} + +/** @param {string} tag, @param {string | object} [params], @returns {any} */ + function $tag(tag, params) { let element; @@ -27,35 +48,19 @@ function $tag(tag, params) { return element; } -/** - * @param {string} name - * @returns {any} - */ +/** @param {string} name, @returns {any} */ + function $id(name) { return document.getElementById(name); } +/** @param {string} uri, @returns {AtURI} */ + function atURI(uri) { return new AtURI(uri); } -class AtURI { - constructor(uri) { - if (!uri.startsWith('at://')) { - throw new URLError(`Not an at:// URI: ${uri}`); - } - - let parts = uri.split('/'); - - if (parts.length != 5) { - throw new URLError(`Invalid at:// URI: ${uri}`); - } - - this.repo = parts[2]; - this.collection = parts[3]; - this.rkey = parts[4]; - } -} +/** @param {string} html, @returns {string} */ function escapeHTML(html) { return html.replace(/&/g, '&') @@ -63,10 +68,14 @@ function escapeHTML(html) { .replace(/>/g,'>'); } +/** @returns {string} */ + function getLocation() { return location.origin + location.pathname; } +/** @param {Date} date1, @param {Date} date2, @returns {boolean} */ + function sameDay(date1, date2) { return ( date1.getDate() == date2.getDate() && @@ -75,10 +84,14 @@ function sameDay(date1, date2) { ); } +/** @param {Post} post, @returns {string} */ + function linkToPostThread(post) { return linkToPostById(post.author.handle, post.rkey); } +/** @param {string} handle, @param {string} postId, @returns {string} */ + function linkToPostById(handle, postId) { let url = new URL(getLocation()); url.searchParams.set('author', handle);