Something went wrong. Try again.
Mirror of https://github.com/improsocial/improAn extensible Bluesky client for web
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482import { flattenParents, replaceTopParent, getQuotedPost, getBlockedQuote, isBlockingUser, isBlockedByViewer, createUnavailablePost, getPostUrisFromNotifications, buildUri, parseUri, isGroupConvo, getGroupConvoDetails, getJoinLinkCodesFromPosts, getJoinLinkCodesFromMessages, getPostsFromPostThread, getPostsFromFeed,} from "/js/dataHelpers.js";import { Constellation } from "/js/constellation.js";import { getLocalRefsFromDraft } from "/js/dataHelpers.js";import { unique } from "/js/utils.js";import { SignalMap, ComputedMap, ReactiveStore } from "/js/signals.js";import { ApiError } from "/js/api.js";import { FOLLOWING_FEED_URI } from "/js/config.js";
const CONVO_LOG_SYSTEM_MESSAGE_TYPES = new Set([ "chat.bsky.convo.defs#logAddMember", "chat.bsky.convo.defs#logRemoveMember", "chat.bsky.convo.defs#logMemberJoin", "chat.bsky.convo.defs#logMemberLeave", "chat.bsky.convo.defs#logLockConvo", "chat.bsky.convo.defs#logUnlockConvo", "chat.bsky.convo.defs#logLockConvoPermanently", "chat.bsky.convo.defs#logEditGroup", "chat.bsky.convo.defs#logCreateJoinLink", "chat.bsky.convo.defs#logEditJoinLink", "chat.bsky.convo.defs#logEnableJoinLink", "chat.bsky.convo.defs#logDisableJoinLink",]);
function readCollectionCursor(signal, { key } = {}) { const current = key === undefined ? signal.get() : signal.get(key); return current?.cursor ?? "";}
// Write a page response to a stored collection — an object shaped { [itemsKey]: [...], cursor }// Re-reads the collection and compares its cursor to the request cursor;// a mismatch means a reload or competing page load landed mid-flight.function writePageToCollection( signal, itemsKey, page, { key, requestCursor, overwrite = false, dedupeBy },) { const current = key === undefined ? signal.get() : signal.get(key); const currentCursor = current?.cursor ?? ""; if (!overwrite && currentCursor !== (requestCursor ?? "")) { console.warn("Cursor mismatch, discarding page", { itemsKey, key, requestCursor, currentCursor, }); return false; } const append = !overwrite && Boolean(currentCursor); let items = page[itemsKey] ?? []; if (append && dedupeBy) { const existingIds = new Set( current[itemsKey].map((item) => item[dedupeBy]), ); items = items.filter((item) => !existingIds.has(item[dedupeBy])); } const next = { [itemsKey]: append ? [...current[itemsKey], ...items] : items, cursor: page.cursor || null, }; if (key === undefined) { signal.set(next); } else { signal.set(key, next); } return true;}
// Get URIs of blocked posts and blocked quotes referenced by the given postsfunction getBlockedPostUris(posts) { // Blocked "top-level" posts const blockedPosts = posts.filter( (post) => post.$type === "app.bsky.feed.defs#blockedPost", ); // Blocked quoted posts const blockedQuotes = posts .map((post) => getBlockedQuote(post)) .filter(Boolean); // Blocked nested quotes // Note - this won't load blocked quotes of blocked quotes (edge case) const blockedNestedQuotes = posts .map((post) => getQuotedPost(post)) .filter(Boolean) .map((quotedPost) => getBlockedQuote(quotedPost)) .filter(Boolean);
return unique([...blockedPosts, ...blockedQuotes, ...blockedNestedQuotes], { by: "uri", }).map((blockedPost) => blockedPost.uri);}
function updateGroupConvoForSystemMessage(convo, log) { const details = getGroupConvoDetails(convo); const patch = {}; switch (log.$type) { case "chat.bsky.convo.defs#logLockConvo": patch.lockStatus = "locked"; break; case "chat.bsky.convo.defs#logUnlockConvo": patch.lockStatus = "unlocked"; break; case "chat.bsky.convo.defs#logLockConvoPermanently": patch.lockStatus = "locked-permanently"; break; case "chat.bsky.convo.defs#logEditGroup": if (log.message.data.newName) { patch.name = log.message.data.newName; } break; case "chat.bsky.convo.defs#logAddMember": case "chat.bsky.convo.defs#logMemberJoin": patch.memberCount = details.memberCount + 1; break; case "chat.bsky.convo.defs#logRemoveMember": case "chat.bsky.convo.defs#logMemberLeave": patch.memberCount = details.memberCount - 1; break; } return { ...convo, kind: { ...details, ...patch }, };}
class StatusStore extends ReactiveStore { constructor() { super("statusStore"); this.$loading = new SignalMap(); this.$errors = new SignalMap(); this.$statuses = new ComputedMap((requestId) => ({ loading: this.$loading.get(requestId) ?? false, error: this.$errors.get(requestId) ?? null, })); }
setLoading(requestId, loading) { this.$loading.set(requestId, loading); }
setError(requestId, error) { this.$errors.set(requestId, error); }
getLoading(requestId) { return this.$loading.get(requestId) ?? false; }
getError(requestId) { return this.$errors.get(requestId) ?? null; }}
// Handles making requests to the API and storing the data in the data store.export class Requests { constructor( api, dataStore, preferencesProvider, pluginService, draftMediaStore, { constellation } = {}, ) { this.api = api; this.pluginService = pluginService; this.dataStore = dataStore; this.preferencesProvider = preferencesProvider; this.draftMediaStore = draftMediaStore; this.constellation = constellation ?? new Constellation(); this.statusStore = new StatusStore(); // Enable status tracking this.enableStatus( this.loadPostThread, (postUri) => "loadPostThread-" + postUri, ); this.enableStatus( this.loadNextFeedPage, ({ uri }) => "loadNextFeedPage-" + uri, ); this.enableStatus( this.loadDetailedProfile, (did) => "loadDetailedProfile-" + did, ); this.enableStatus( this.loadProfileSearch, (query) => "loadProfileSearch-" + query, ); this.enableStatus(this.loadChatRecipientSearch, "loadChatRecipientSearch"); this.enableStatus(this.loadSearchTypeahead, "loadSearchTypeahead"); this.enableStatus( this.loadPostSearchTop, (query) => "loadPostSearchTop-" + query, ); this.enableStatus( this.loadPostSearchLatest, (query) => "loadPostSearchLatest-" + query, ); this.enableStatus( this.loadFeedSearch, (query) => "loadFeedSearch-" + query, ); this.enableStatus(this.loadNotifications, "loadNotifications"); this.enableStatus( this.loadMentionNotifications, "loadMentionNotifications", ); this.enableStatus(this.loadConvoList, "loadConvoList"); this.enableStatus(this.loadConvoRequestList, "loadConvoRequestList"); this.enableStatus(this.loadConvo, (convoId) => "loadConvo-" + convoId); this.enableStatus( this.loadConvoMembers, (convoId) => "loadConvoMembers-" + convoId, ); this.enableStatus( this.loadConvoMessages, (convoId) => "loadConvoMessages-" + convoId, ); this.enableStatus( this.loadPostLikes, (postUri) => "loadPostLikes-" + postUri, ); this.enableStatus( this.loadPostQuotes, (postUri) => "loadPostQuotes-" + postUri, ); this.enableStatus( this.loadPostReposts, (postUri) => "loadPostReposts-" + postUri, ); this.enableStatus( this.loadProfileFollowers, (profileDid) => "loadProfileFollowers-" + profileDid, ); this.enableStatus( this.loadProfileFollows, (profileDid) => "loadProfileFollows-" + profileDid, ); this.enableStatus( this.loadKnownFollowers, (profileDid) => "loadKnownFollowers-" + profileDid, ); this.enableStatus(this.loadDrafts, "loadDrafts"); this.enableStatus(this.loadBlockedProfiles, "loadBlockedProfiles"); this.enableStatus(this.loadMutedProfiles, "loadMutedProfiles"); }
requireLabelers() { const preferences = this.preferencesProvider.requirePreferences(); return preferences.getLabelerDids(); }
async loadCurrentUser() { const session = await this.api.getSession(); const profile = await this.api.getProfile(session.did); this.dataStore.$currentUser.set(profile); }
async loadPostThread(postURI, { depth = 6 } = {}) { const labelers = this.requireLabelers(); let [postThread, postThreadOther] = await Promise.all([ this.api.getPostThread(postURI, { labelers, depth, }), this.api.getPostThreadOther(postURI, { labelers, }), ]); // Save posts const postsToSave = getPostsFromPostThread(postThread); await this._loadPostDependencies(postsToSave); this.dataStore.setPosts(postsToSave); const parent = postThread.parent; if (parent) { const topParent = flattenParents(postThread)[0]; // Special case for post thread: if a parent is blocked or missing, we need to load the parent chain ourselves if (topParent.$type === "app.bsky.feed.defs#blockedPost") { const rootUri = postThread.post?.record?.reply?.root?.uri ?? postThread.post?.uri; const loadedParent = await this._loadParentChain(topParent, { labelers, rootUri, }); postThread = replaceTopParent(postThread, loadedParent); } } const totalNumReplies = postThread.post?.replyCount ?? 0; const numAttachedReplies = postThread.replies?.length ?? 0; if (numAttachedReplies !== totalNumReplies) { postThread.replies = await this._loadBlockedReplies(postThread, { labelers, }); }
// Save post thread this.dataStore.$postThreads.set(postURI, postThread); this.dataStore.$postThreadOthers.set(postURI, postThreadOther); // Note - this return value is used by loadParentChain return postThread; }
async loadPost(postURI) { const labelers = this.requireLabelers(); const post = await this.api.getPost(postURI, { labelers }); await this._loadPostDependencies([post]); this.dataStore.setPosts([post]); }
async loadPosts(postURIs) { if (postURIs.length === 0) return; const labelers = this.requireLabelers(); const posts = await this.api.getPosts(postURIs, { labelers }); await this._loadPostDependencies(posts); this.dataStore.setPosts(posts); }
async _loadParentChain(blockedParent, { labelers = [], rootUri } = {}) { if ( !rootUri || isBlockingUser(blockedParent) || isBlockedByViewer(blockedParent) ) { return await this.loadPostThread(blockedParent.uri, { depth: 0, labelers, }); }
let backlinks; try { backlinks = await this._getPostsInThreadFromBacklinks(rootUri); } catch (error) { if (error.name === "AbortError") { return await this.loadPostThread(blockedParent.uri, { depth: 0, labelers, }); } throw error; }
const loadedPostsByUri = new Map(); const loadedAuthorDids = new Set(); let currentBlocked = blockedParent;
while ( currentBlocked?.$type === "app.bsky.feed.defs#blockedPost" && !isBlockingUser(currentBlocked) && !isBlockedByViewer(currentBlocked) ) { const authorDid = currentBlocked.author?.did; if (!authorDid || loadedAuthorDids.has(authorDid)) break; loadedAuthorDids.add(authorDid);
const authorUris = backlinks .filter((backlink) => backlink.did === authorDid) .map(({ did, collection, rkey }) => buildUri({ repo: did, collection, rkey }), );
if (authorUris.length === 0) break;
const posts = await this.api.getPosts(authorUris, { labelers }); for (const post of posts) { loadedPostsByUri.set(post.uri, post); } await this._loadPostDependencies(posts); this.dataStore.setPosts(posts);
// Walk up from the current blocked post to find the next unresolved parent let uri = currentBlocked.uri; currentBlocked = null; while (uri) { const post = loadedPostsByUri.get(uri); if (!post) break; const parentUri = post.record?.reply?.parent?.uri; if (!parentUri) break; if (loadedPostsByUri.has(parentUri)) { uri = parentUri; continue; } // Parent not loaded — might be by another blocked author const parentDid = parseUri(parentUri).repo; if (parentDid && !loadedAuthorDids.has(parentDid)) { currentBlocked = { $type: "app.bsky.feed.defs#blockedPost", uri: parentUri, author: { did: parentDid }, }; } break; } }
if (loadedPostsByUri.size === 0) { return await this.loadPostThread(blockedParent.uri, { depth: 0, labelers, }); }
return this._buildThreadChain(blockedParent.uri, loadedPostsByUri); }
_buildThreadChain(startUri, postsByUri) { const post = postsByUri.get(startUri); if (!post) return null;
const parentUri = post.record?.reply?.parent?.uri; let parent = null; if (parentUri && postsByUri.has(parentUri)) { parent = this._buildThreadChain(parentUri, postsByUri); }
return { $type: "app.bsky.feed.defs#threadViewPost", post, parent, replies: [], }; }
async _loadBlockedReplies(postThread, { labelers = [] } = {}) { const post = postThread.post; if (!post) { // note, I'm not sure if this ever happens return []; } const loadedReplies = postThread.replies ?? []; let allReplyUris = null; try { allReplyUris = await this._getReplyUrisForPostFromBacklinks(post); } catch (error) { if (error.name === "AbortError") { console.warn("Timed out getting backlinks for replies"); return loadedReplies; } throw error; } const missingReplyUris = allReplyUris.filter( (uri) => !loadedReplies.some((reply) => reply.post?.uri === uri), ); if (missingReplyUris.length > 0) { // Load up to 100 blocked replies. // Larger numbers can happen when a post has a lot of replies and they aren't all included in the initial load. // The v2 endpoint solves this (I think) but it's still unspec'd. const urisToLoad = missingReplyUris.slice(0, 100); const missingReplies = await this.api.getPosts(urisToLoad, { labelers, }); let repliesToAdd = missingReplies.filter((post) => !isBlockingUser(post)); // Add an attribute indicating that this was a blocked reply // we use this to put in the hidden section on the post thread view repliesToAdd = repliesToAdd.map((post) => { return { ...post, // NOTE: LEXICON DEVIATION isBlockedReply: true, }; }); await this._loadPostDependencies(repliesToAdd); this.dataStore.setPosts(repliesToAdd); loadedReplies.push( ...repliesToAdd.map((post) => { return { $type: "app.bsky.feed.defs#threadViewPost", post: post, replies: [], // don't bother loading replies, if people want to see them they can click the post detail }; }), ); } return loadedReplies; }
async loadNextFeedPage({ type, uri }, { reload = false, limit = 31 } = {}) { const labelers = this.requireLabelers(); const cursor = reload ? "" : readCollectionCursor(this.dataStore.$feeds, { key: uri }); let feed; switch (type) { case "timeline": feed = await this.api.getFollowingFeed({ limit, cursor, labelers }); break; case "list": feed = await this.api.getListFeed(uri, { limit, cursor, labelers }); break; case "feed": feed = await this.api.getFeed(uri, { limit, cursor, labelers }); break; default: throw new Error(`Unknown pinned item type: ${type}`); } const postsToSave = getPostsFromFeed(feed); await this._loadPostDependencies(postsToSave); this.dataStore.setPosts(postsToSave); await this.pluginService.refreshFiltersForFeed(uri, feed, { reload }); writePageToCollection(this.dataStore.$feeds, "feed", feed, { key: uri, requestCursor: cursor, overwrite: reload, }); }
async loadPluginFilteredFeedItems(feedURI, { reload = false } = {}) { const feed = this.dataStore.$feeds.get(feedURI); if (!feed) { return; } await this.pluginService.refreshFiltersForFeed(feedURI, feed, { reload }); }
async _getReplyUrisForPostFromBacklinks(post) { const backlinks = await this.constellation.getLinks({ subject: post.uri, source: "app.bsky.feed.post:reply.parent.uri", timeout: 2000, }); return backlinks.map(({ did, collection, rkey }) => buildUri({ repo: did, collection, rkey }), ); }
async _getPostsInThreadFromBacklinks(rootUri) { const backlinks = await this.constellation.getLinks({ subject: rootUri, source: "app.bsky.feed.post:reply.root.uri", timeout: 2000, }); // Also add the root itself const { repo, collection, rkey } = parseUri(rootUri); backlinks.push({ did: repo, collection, rkey }); return backlinks; }
async _loadBlockedPosts(blockedPostUris) { if (blockedPostUris.length === 0) return; const labelers = this.requireLabelers(); const fetchedBlockedPosts = await this.api.getPosts(blockedPostUris, { labelers, }); this.dataStore.setPosts(fetchedBlockedPosts); // The appview omits posts from getPosts when a block exists in either // direction, so a missing post may still exist. Probe the raw record // (which block filtering doesn't apply to) and only mark posts as // unavailable when the record is confirmed gone. const missingPostUris = blockedPostUris.filter( (uri) => !fetchedBlockedPosts.some((post) => post.uri === uri), ); const results = await Promise.allSettled( missingPostUris.map((uri) => this.api.getRecord(uri)), ); results.forEach((result, index) => { if (result.status === "fulfilled") return; const error = result.reason; if (error instanceof ApiError && error.data?.error === "RecordNotFound") { const uri = missingPostUris[index]; this.dataStore.$unavailablePosts.set(uri, createUnavailablePost(uri)); } }); }
async loadDetailedProfile(did) { const labelers = this.requireLabelers(); const profile = await this.api.getProfile(did, { labelers }); this.dataStore.$profiles.set(did, profile); this.dataStore.$detailedProfiles.set(did, profile); }
async loadDetailedProfiles(dids) { if (dids.length === 0) return; const labelers = this.requireLabelers(); const profiles = await this.api.getProfiles(dids, { labelers }); for (const profile of profiles) { this.dataStore.$profiles.set(profile.did, profile); this.dataStore.$detailedProfiles.set(profile.did, profile); } }
async loadProfileSearch(query, { limit = 10, cursor = "" } = {}) { if (!query) { // Invalidate in-flight searches so they can't repopulate cleared results this.dataStore.$latestProfileSearchRequestTime.set(null); this.dataStore.$profileSearchResults.set(null); return; } if (!cursor) { this.dataStore.$profileSearchResults.set(null); } const labelers = this.requireLabelers(); const requestTime = Date.now(); this.dataStore.$latestProfileSearchRequestTime.set(requestTime); const searchData = await this.api.searchProfiles(query, { limit, cursor, labelers, }); if (requestTime !== this.dataStore.$latestProfileSearchRequestTime.get()) { return; } this.dataStore.setProfiles(searchData.actors); const existingResults = this.dataStore.$profileSearchResults.get(); if (existingResults && cursor) { this.dataStore.$profileSearchResults.set({ actors: [...existingResults.actors, ...searchData.actors], cursor: searchData.cursor, }); } else { this.dataStore.$profileSearchResults.set(searchData); } }
async loadChatRecipientSearch(query, { limit = 12 } = {}) { if (!query) { // Invalidate in-flight searches so they can't repopulate cleared results this.dataStore.$latestChatRecipientSearchRequestTime.set(null); this.dataStore.$chatRecipientSearchResults.set(null); return; } const labelers = this.requireLabelers(); const requestTime = Date.now(); this.dataStore.$latestChatRecipientSearchRequestTime.set(requestTime); const searchData = await this.api.searchProfilesTypeahead(query, { limit, labelers, }); if ( requestTime !== this.dataStore.$latestChatRecipientSearchRequestTime.get() ) { return; } this.dataStore.setProfiles(searchData.actors); this.dataStore.$chatRecipientSearchResults.set(searchData); }
async loadSearchTypeahead(query, { limit = 8 } = {}) { if (!query) { // Invalidate in-flight searches so they can't repopulate cleared results this.dataStore.$latestSearchTypeaheadRequestTime.set(null); this.dataStore.$searchTypeaheadResults.set(null); return; } const labelers = this.requireLabelers(); const requestTime = Date.now(); this.dataStore.$latestSearchTypeaheadRequestTime.set(requestTime); const searchData = await this.api.searchProfilesTypeahead(query, { limit, labelers, }); if ( requestTime !== this.dataStore.$latestSearchTypeaheadRequestTime.get() ) { return; } this.dataStore.setProfiles(searchData.actors); this.dataStore.$searchTypeaheadResults.set(searchData); }
async loadPostSearchTop(query, { limit = 25, cursor = "" } = {}) { await this._loadPostSearch(query, { limit, cursor, sort: "top", $results: this.dataStore.$postSearchResultsTop, $latestRequestTime: this.dataStore.$latestPostSearchRequestTimeTop, }); }
async loadPostSearchLatest(query, { limit = 25, cursor = "" } = {}) { await this._loadPostSearch(query, { limit, cursor, sort: "latest", $results: this.dataStore.$postSearchResultsLatest, $latestRequestTime: this.dataStore.$latestPostSearchRequestTimeLatest, }); }
async _loadPostSearch( query, { limit, cursor, sort, $results, $latestRequestTime }, ) { if (!query) { // Invalidate in-flight searches so they can't repopulate cleared results $latestRequestTime.set(null); $results.set(null); return; } if (!cursor) { $results.set(null); } const labelers = this.requireLabelers(); const requestTime = Date.now(); $latestRequestTime.set(requestTime); const searchData = await this.api.searchPosts(query, { limit, sort, cursor, labelers, }); if (requestTime !== $latestRequestTime.get()) { return; } const searchResults = searchData.posts || []; if (searchResults.length > 0) { // If there are posts that are replies, load the parents const replyPosts = searchResults.filter((post) => post.record?.reply); const replyParentUris = replyPosts .map((post) => post.record?.reply?.parent?.uri) .filter(Boolean); const parentPosts = replyParentUris.length > 0 ? await this.api.getPosts(replyParentUris, { labelers }) : []; await this._loadPostDependencies(searchResults); this.dataStore.setPosts([...searchResults, ...parentPosts]); } // Re-check relevance after loading dependencies if (requestTime !== $latestRequestTime.get()) { return; } const existingResults = $results.get(); if (existingResults && cursor) { $results.set({ posts: [...existingResults.posts, ...searchResults], cursor: searchData.cursor, }); } else { $results.set({ posts: searchResults, cursor: searchData.cursor, }); } }
async loadFeedSearch(query, { limit = 15, cursor = "" } = {}) { if (!query) { // Invalidate in-flight searches so they can't repopulate cleared results this.dataStore.$latestFeedSearchRequestTime.set(null); this.dataStore.$feedSearchResults.set(null); return; } if (!cursor) { this.dataStore.$feedSearchResults.set(null); } const requestTime = Date.now(); this.dataStore.$latestFeedSearchRequestTime.set(requestTime); const searchData = await this.api.searchFeedGenerators(query, { limit, cursor, }); if (requestTime !== this.dataStore.$latestFeedSearchRequestTime.get()) { return; } const feeds = searchData.feeds || []; for (const feed of feeds) { this.dataStore.$feedGenerators.set(feed.uri, feed); } const existingResults = this.dataStore.$feedSearchResults.get(); if (existingResults && cursor) { this.dataStore.$feedSearchResults.set({ feeds: [...existingResults.feeds, ...feeds], cursor: searchData.cursor, }); } else { this.dataStore.$feedSearchResults.set({ feeds, cursor: searchData.cursor, }); } }
async loadNextAuthorFeedPage( did, feedType, { reload = false, limit = 31 } = {}, ) { const feedURI = `${did}-${feedType}`; const cursor = reload ? "" : readCollectionCursor(this.dataStore.$authorFeeds, { key: feedURI }); const labelers = this.requireLabelers(); const params = { limit, cursor, labelers };
let feed;
// Handle likes feed separately since it uses a different API endpoint if (feedType === "likes") { feed = await this.api.getActorLikes(did, params); } else { // set params based on feed type switch (feedType) { case "posts": params.filter = "posts_and_author_threads"; params.includePins = true; break; case "replies": params.filter = "posts_with_replies"; params.includePins = false; break; case "media": params.filter = "posts_with_media"; params.includePins = false; break; default: throw new Error(`Unknown feed type: ${feedType}`); } feed = await this.api.getAuthorFeed(did, params); }
// Save posts const postsToSave = getPostsFromFeed(feed); await this._loadPostDependencies(postsToSave); this.dataStore.setPosts(postsToSave); // Save feed writePageToCollection(this.dataStore.$authorFeeds, "feed", feed, { key: feedURI, requestCursor: cursor, overwrite: reload, }); }
async loadNotifications({ reload = false, limit = 31 } = {}) { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$notifications); const labelers = this.requireLabelers(); const res = await this.api.getNotifications({ cursor, limit, labelers }); // Get associated posts const postUris = getPostUrisFromNotifications(res.notifications); if (postUris.length > 0) { const fetchedPosts = await this.api.getPosts(postUris, { labelers }); await this._loadPostDependencies(fetchedPosts); this.dataStore.setPosts(fetchedPosts); } writePageToCollection(this.dataStore.$notifications, "notifications", res, { requestCursor: cursor, overwrite: reload, }); }
async loadMentionNotifications({ reload = false, limit = 31 } = {}) { const MENTION_REASONS = ["mention", "reply", "quote"]; const cursor = reload ? "" : readCollectionCursor(this.dataStore.$mentionNotifications); const labelers = this.requireLabelers(); const res = await this.api.getNotifications({ cursor, limit, reasons: MENTION_REASONS, labelers, }); const postUris = getPostUrisFromNotifications(res.notifications); if (postUris.length > 0) { const fetchedPosts = await this.api.getPosts(postUris, { labelers }); await this._loadPostDependencies(fetchedPosts); this.dataStore.setPosts(fetchedPosts); } writePageToCollection( this.dataStore.$mentionNotifications, "notifications", res, { requestCursor: cursor, overwrite: reload, }, ); }
async loadConvoList({ reload = false, limit = 30 } = {}) { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$convoList); const labelers = this.requireLabelers(); const res = await this.api.listConvos({ cursor, limit, labelers }); // Store individual convos for (const convo of res.convos) { this.dataStore.$convos.set(convo.id, convo); } writePageToCollection(this.dataStore.$convoList, "convos", res, { requestCursor: cursor, overwrite: reload, dedupeBy: "id", // skip convos that were bumped locally }); }
async loadConvoRequestList({ reload = false, limit = 30 } = {}) { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$convoRequestList); const labelers = this.requireLabelers(); const res = await this.api.listConvos({ cursor, limit, status: "request", labelers, }); // Store individual convos for (const convo of res.convos) { this.dataStore.$convos.set(convo.id, convo); } writePageToCollection(this.dataStore.$convoRequestList, "convos", res, { requestCursor: cursor, overwrite: reload, dedupeBy: "id", // skip convos that were bumped locally }); }
async loadConvo(convoId) { const labelers = this.requireLabelers(); const res = await this.api.getConvo(convoId, { labelers }); this.dataStore.setConvo(res.convo); }
async loadConvoMembers(convoId, { reload = false } = {}) { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$convoMemberLists, { key: convoId, }); const labelers = this.requireLabelers(); const res = await this.api.getConvoMembers(convoId, { cursor, labelers }); writePageToCollection(this.dataStore.$convoMemberLists, "members", res, { key: convoId, requestCursor: cursor, overwrite: reload, }); }
async _loadPostDependencies(posts) { const results = await Promise.allSettled([ this._loadBlockedPosts(getBlockedPostUris(posts)), this._loadJoinLinkPreviews(getJoinLinkCodesFromPosts(posts)), ]); for (const result of results) { if (result.status === "rejected") { console.error("Failed to load post dependency", result.reason); } } }
async _loadJoinLinkPreviews(codes) { const distinct = unique((codes ?? []).filter(Boolean)); if (distinct.length === 0) return; if (!this.api.isAuthenticated) return; try { const res = await this.api.getJoinLinkPreviews(distinct); for (const preview of res.joinLinkPreviews ?? []) { if (preview?.code) { this.dataStore.$joinLinkPreviewsByCode.set(preview.code, preview); } } } catch (error) { console.error("Failed to load join link previews", error); } }
async loadConvoForProfile(profileDid) { const labelers = this.requireLabelers(); const res = await this.api.getConvoForMembers([profileDid], { labelers }); this.dataStore.setConvo(res.convo); }
async loadConvoMessages(convoId, { reload = false, limit = 50 } = {}) { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$convoMessages, { key: convoId }); const labelers = this.requireLabelers(); const res = await this.api.getMessages(convoId, { cursor, limit, labelers, }); // For group convos, convo.members is partial; relatedProfiles carries // the authors and system-message subjects for the returned page. if (res.relatedProfiles) { this.dataStore.setProfiles(res.relatedProfiles); } await this._loadJoinLinkPreviews( getJoinLinkCodesFromMessages(res.messages), ); // Save individual messages for (const message of res.messages) { this.dataStore.$messages.set(message.id, message); } writePageToCollection(this.dataStore.$convoMessages, "messages", res, { key: convoId, requestCursor: cursor, overwrite: reload, }); }
async pollConvoMessages(convoId, { cursor = "" } = {}) { const labelers = this.requireLabelers(); const res = await this.api.getChatLogs({ cursor, labelers }); const logsForConvo = res.logs.filter((log) => log.convoId === convoId); const newMessages = []; for (const log of logsForConvo) { const isReactionLog = log.$type === "chat.bsky.convo.defs#logAddReaction" || log.$type === "chat.bsky.convo.defs#logRemoveReaction"; if (isReactionLog && log.message?.id) { if (log.relatedProfiles) { this.dataStore.setProfiles(log.relatedProfiles); } this.dataStore.$messages.set(log.message.id, log.message); const convoMessages = this.dataStore.$convoMessages.get(convoId); if (convoMessages) { this.dataStore.$convoMessages.set(convoId, { messages: convoMessages.messages.map((message) => message.id === log.message.id ? log.message : message, ), cursor: convoMessages.cursor, }); } continue; } if ( log.$type === "chat.bsky.convo.defs#logDeleteMessage" && log.message?.id ) { const messageId = log.message.id; const convoMessages = this.dataStore.$convoMessages.get(convoId); if (convoMessages) { this.dataStore.$convoMessages.set(convoId, { messages: convoMessages.messages.filter( (message) => message.id !== messageId, ), cursor: convoMessages.cursor, }); } this.dataStore.$messages.delete(messageId); continue; } const isUserMessage = log.$type === "chat.bsky.convo.defs#logCreateMessage"; const isSystemMessage = CONVO_LOG_SYSTEM_MESSAGE_TYPES.has(log.$type); if (!isUserMessage && !isSystemMessage) continue; // Group chats include profile info here if (log.relatedProfiles) { this.dataStore.setProfiles(log.relatedProfiles); } const convoMessages = this.dataStore.$convoMessages.get(convoId); if (!convoMessages) { console.warn("No messages data found for convoId", convoId); return res.cursor; } const alreadyIngested = convoMessages.messages.some( (message) => message.id === log.message.id, ); if (alreadyIngested) continue; if (isSystemMessage) { // Update convo for system message const convo = this.dataStore.$convos.get(convoId); if (convo && isGroupConvo(convo)) { this.dataStore.$convos.set( convoId, updateGroupConvoForSystemMessage(convo, log), ); } } this.dataStore.$messages.set(log.message.id, log.message); this.dataStore.$convoMessages.set(convoId, { messages: [log.message, ...convoMessages.messages], cursor: convoMessages.cursor, }); newMessages.push(log.message); } await this._loadJoinLinkPreviews(getJoinLinkCodesFromMessages(newMessages)); return res.cursor; }
async loadPostLikes(postUri, { cursor } = {}) { const labelers = this.requireLabelers(); const res = await this.api.getLikes(postUri, { cursor, labelers }); this.dataStore.setProfiles(res.likes.map((like) => like.actor));
writePageToCollection(this.dataStore.$postLikes, "likes", res, { key: postUri, requestCursor: cursor ?? "", overwrite: !cursor, }); }
async loadPostQuotes(postUri, { cursor } = {}) { const labelers = this.requireLabelers(); const res = await this.api.getQuotes(postUri, { cursor, labelers });
// if there are posts that are replies, load the parents const replyPosts = res.posts.filter((post) => post.record?.reply); const replyParentUris = replyPosts .map((post) => post.record?.reply?.parent?.uri) .filter(Boolean); const parentPosts = replyParentUris.length > 0 ? await this.api.getPosts(replyParentUris, { labelers }) : []; // Save posts and parents await this._loadPostDependencies(res.posts); this.dataStore.setPosts([...res.posts, ...parentPosts]); writePageToCollection(this.dataStore.$postQuotes, "posts", res, { key: postUri, requestCursor: cursor ?? "", overwrite: !cursor, }); }
async loadPostReposts(postUri, { cursor } = {}) { const labelers = this.requireLabelers(); const res = await this.api.getRepostedBy(postUri, { cursor, labelers }); this.dataStore.setProfiles(res.repostedBy);
writePageToCollection(this.dataStore.$postReposts, "repostedBy", res, { key: postUri, requestCursor: cursor ?? "", overwrite: !cursor, }); }
// Decorate a request method with status tracking enableStatus(requestMethod, requestIdOrFn) { async function wrappedRequestMethod(...args) { const requestId = typeof requestIdOrFn === "function" ? requestIdOrFn(...args) : requestIdOrFn; this.statusStore.setLoading(requestId, true); try { const result = await requestMethod.apply(this, args); // Clear any errors from previous requests this.statusStore.setError(requestId, null); return result; } catch (error) { // Record every failure so views can render error states, but only // swallow ApiErrors this.statusStore.setError(requestId, error); if (!(error instanceof ApiError)) { throw error; } } finally { this.statusStore.setLoading(requestId, false); } } this[requestMethod.name] = wrappedRequestMethod.bind(this); }
getStatus(requestId) { const loading = this.statusStore.getLoading(requestId); const error = this.statusStore.getError(requestId); return { loading, error }; }
async loadFeedGenerator(feedUri) { const feedGeneratorData = await this.api.getFeedGenerator(feedUri); this.dataStore.$feedGenerators.set(feedUri, feedGeneratorData); }
async loadList(listUri) { const data = await this.api.getList(listUri, { limit: 1 }); this.dataStore.$lists.set(listUri, data.list); }
async loadStarterPack(starterPackUri) { const starterPack = await this.api.getStarterPack(starterPackUri); this.dataStore.$starterPacks.set(starterPackUri, starterPack); }
async loadListMembers(listUri, { reload = false, limit = 50 } = {}) { const existing = this.dataStore.$listMembers.get(listUri); if (existing && !existing.cursor && !reload) { return; } const cursor = reload ? "" : readCollectionCursor(this.dataStore.$listMembers, { key: listUri }); const data = await this.api.getList(listUri, { limit, cursor }); this.dataStore.setProfiles((data.items ?? []).map((item) => item.subject)); writePageToCollection(this.dataStore.$listMembers, "items", data, { key: listUri, requestCursor: cursor, overwrite: reload, }); }
async loadPinnedItems() { const preferences = this.preferencesProvider.requirePreferences(); const pinnedFeeds = preferences.getPinnedFeeds();
const feedUris = pinnedFeeds .filter((item) => item.type === "feed") .map((item) => item.value); const listUris = pinnedFeeds .filter((item) => item.type === "list") .map((item) => item.value);
const [feedGenerators, listResults] = await Promise.all([ feedUris.length ? this.api.getFeedGenerators(feedUris) : Promise.resolve([]), Promise.allSettled(listUris.map((uri) => this.api.getList(uri))), ]); const listViews = listResults .filter((result) => result.status === "fulfilled") .map((result) => result.value.list);
for (const feedGenerator of feedGenerators) { this.dataStore.$feedGenerators.set(feedGenerator.uri, feedGenerator); } for (const listView of listViews) { this.dataStore.$lists.set(listView.uri, listView); } const feedGeneratorMap = new Map(feedGenerators.map((fg) => [fg.uri, fg])); const listViewMap = new Map(listViews.map((lv) => [lv.uri, lv]));
const orderedItems = []; for (const item of pinnedFeeds) { if (item.type === "timeline") { orderedItems.push({ type: "timeline", data: { uri: FOLLOWING_FEED_URI, displayName: "Following" }, }); } else if (item.type === "feed") { const fg = feedGeneratorMap.get(item.value); if (fg) orderedItems.push({ type: "feed", data: fg }); } else if (item.type === "list") { const lv = listViewMap.get(item.value); if (lv) orderedItems.push({ type: "list", data: lv }); } }
this.dataStore.$pinnedItems.set(orderedItems); }
async loadActorFeeds(did, { reload = false, limit = 50 } = {}) { const existing = this.dataStore.$actorFeeds.get(did); if (existing && !existing.cursor && !reload) { return; } const cursor = reload ? "" : readCollectionCursor(this.dataStore.$actorFeeds, { key: did }); const data = await this.api.getActorFeeds(did, { limit, cursor }); for (const feed of data.feeds) { this.dataStore.$feedGenerators.set(feed.uri, feed); } writePageToCollection(this.dataStore.$actorFeeds, "feeds", data, { key: did, requestCursor: cursor, overwrite: reload, }); }
async loadActorLists(did, { reload = false, limit = 50 } = {}) { const existing = this.dataStore.$actorLists.get(did); if (existing && !existing.cursor && !reload) { return; } const cursor = reload ? "" : readCollectionCursor(this.dataStore.$actorLists, { key: did }); const data = await this.api.getActorLists(did, { limit, cursor }); for (const list of data.lists) { this.dataStore.$lists.set(list.uri, list); } writePageToCollection(this.dataStore.$actorLists, "lists", data, { key: did, requestCursor: cursor, overwrite: reload, }); }
async loadCurrentUserLists({ reload = false } = {}) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) return; await this.loadActorLists(currentUser.did, { reload }); }
async loadListsWithMembershipForActor( actorDid, { reload = false, limit = 50 } = {}, ) { const existing = this.dataStore.$listsWithMembershipByActor.get(actorDid); if (existing && !reload && !existing.cursor) { return; } const cursor = reload ? "" : readCollectionCursor(this.dataStore.$listsWithMembershipByActor, { key: actorDid, }); const data = await this.api.getListsWithMembership(actorDid, { limit, cursor, }); writePageToCollection( this.dataStore.$listsWithMembershipByActor, "listsWithMembership", data, { key: actorDid, requestCursor: cursor, overwrite: reload, }, ); }
async loadHashtagFeed(hashtag, sort, { reload = false, limit = 25 } = {}) { const hashtagKey = `${hashtag}-${sort}`; const labelers = this.requireLabelers();
const cursor = reload ? "" : readCollectionCursor(this.dataStore.$hashtagFeeds, { key: hashtagKey });
// Search posts with the hashtag const query = `#${hashtag}`; const searchData = await this.api.searchPosts(query, { limit, sort, cursor, labelers, });
const searchResults = searchData.posts || []; if (searchResults.length > 0) { // If there are posts that are replies, load the parents const replyPosts = searchResults.filter((post) => post.record?.reply); const replyParentUris = replyPosts .map((post) => post.record?.reply?.parent?.uri) .filter(Boolean); const parentPosts = replyParentUris.length > 0 ? await this.api.getPosts(replyParentUris, { labelers }) : []; await this._loadPostDependencies(searchResults); this.dataStore.setPosts([...searchResults, ...parentPosts]); }
writePageToCollection(this.dataStore.$hashtagFeeds, "posts", searchData, { key: hashtagKey, requestCursor: cursor, overwrite: reload, }); }
async loadBookmarks({ reload = false, limit = 31 } = {}) { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$bookmarks);
const labelers = this.requireLabelers(); const res = await this.api.getBookmarks({ limit, cursor, labelers });
// Extract posts from bookmarks array: [{item: post, ...}] const posts = res.bookmarks.map((bookmark) => bookmark.item);
// Save posts to the store if (posts.length > 0) { // If there are posts that are replies, load the parents const replyPosts = posts.filter((post) => post.record?.reply); const replyParentUris = replyPosts .map((post) => post.record?.reply?.parent?.uri) .filter(Boolean); const parentPosts = replyParentUris.length > 0 ? await this.api.getPosts(replyParentUris, { labelers }) : []; await this._loadPostDependencies(posts); this.dataStore.setPosts([...posts, ...parentPosts]); }
writePageToCollection(this.dataStore.$bookmarks, "bookmarks", res, { requestCursor: cursor, overwrite: reload, }); }
async loadDrafts({ reload = false } = {}) { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$drafts); const res = await this.api.getDrafts({ cursor }); writePageToCollection(this.dataStore.$drafts, "drafts", res, { requestCursor: cursor, overwrite: reload, }); // Load media refs const localRefs = res.drafts.flatMap((draftView) => getLocalRefsFromDraft(draftView.draft), ); await this.draftMediaStore.load(localRefs); }
async loadProfileFollowers(profileDid, { cursor } = {}) { const labelers = this.requireLabelers(); const res = await this.api.getFollowers(profileDid, { cursor, labelers }); this.dataStore.setProfiles(res.followers);
writePageToCollection(this.dataStore.$profileFollowers, "followers", res, { key: profileDid, requestCursor: cursor ?? "", overwrite: !cursor, }); }
async loadKnownFollowers(profileDid, { cursor } = {}) { const labelers = this.requireLabelers(); const res = await this.api.getKnownFollowers(profileDid, { cursor, labelers, }); this.dataStore.setProfiles(res.followers);
writePageToCollection(this.dataStore.$knownFollowers, "followers", res, { key: profileDid, requestCursor: cursor ?? "", overwrite: !cursor, }); }
async loadProfileFollows(profileDid, { cursor } = {}) { const labelers = this.requireLabelers(); const res = await this.api.getFollows(profileDid, { cursor, labelers }); this.dataStore.setProfiles(res.follows);
writePageToCollection(this.dataStore.$profileFollows, "follows", res, { key: profileDid, requestCursor: cursor ?? "", overwrite: !cursor, }); }
async loadBlockedProfiles({ cursor } = {}) { const labelers = this.requireLabelers(); const res = await this.api.getBlocks({ cursor, labelers });
writePageToCollection(this.dataStore.$blockedProfiles, "blocks", res, { requestCursor: cursor ?? "", overwrite: !cursor, }); }
async loadMutedProfiles({ cursor } = {}) { const labelers = this.requireLabelers(); const res = await this.api.getMutes({ cursor, labelers });
writePageToCollection(this.dataStore.$mutedProfiles, "mutes", res, { requestCursor: cursor ?? "", overwrite: !cursor, }); }
async loadProfileChatStatus(profileDid) { const labelers = this.requireLabelers(); const res = await this.api.getConvoAvailability([profileDid], { labelers, }); this.dataStore.$profileChatStatus.set(profileDid, res); }
async loadLabelerInfo(labelerDid) { const labelerInfo = await this.api.getLabeler(labelerDid); this.dataStore.$labelerInfo.set(labelerDid, labelerInfo); }}