Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655import { flattenParents, replaceTopParent, getQuotedPost, getBlockedQuote, isBlockingUser, isBlockedByViewer, createUnavailablePost, getPostUrisFromNotifications, buildUri, parseUri, isGroupConvo, getGroupConvoDetails, getJoinLinkCodesFromPosts, getJoinLinkCodesFromMessages, getPostsFromPostThread, getPostsFromFeed, getRepostersFromFeed, buildProfileFromRecord,} from "/js/dataHelpers.js";import { getLocalRefsFromDraft } from "/js/dataHelpers.js";import { unique } from "/js/utils.js";import { SignalMap, ComputedMap, ReactiveStore } from "/js/signals.js";import { ApiError, isRecordNotFoundError } from "/js/api.js";import { FOLLOWING_FEED_URI, BSKY_FEED_OWNER_DIDS } 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, draftMediaStore, events, constellation, ) { this.api = api; this.events = events; this.dataStore = dataStore; this.preferencesProvider = preferencesProvider; this.draftMediaStore = draftMediaStore; this.constellation = 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.loadSidebarSearchTypeahead, "loadSidebarSearchTypeahead", ); this.enableStatus( this.loadPostSearchTop, (query) => "loadPostSearchTop-" + query, ); this.enableStatus( this.loadPostSearchLatest, (query) => "loadPostSearchLatest-" + query, ); this.enableStatus( this.loadFeedSearch, (query) => "loadFeedSearch-" + query, ); this.enableStatus( this.loadStarterPackSearch, (query) => "loadStarterPackSearch-" + query, ); this.enableStatus(this.loadTrends, "loadTrends"); this.enableStatus(this.loadNotifications, "loadNotifications"); this.enableStatus( this.loadMentionNotifications, "loadMentionNotifications", ); this.enableStatus(this.loadChatActorStatus, "loadChatActorStatus"); 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.loadGifs, (query) => "loadGifs-" + query); this.enableStatus(this.loadDrafts, "loadDrafts"); this.enableStatus( this.loadStarterPack, (starterPackUri) => "loadStarterPack-" + starterPackUri, ); this.enableStatus( this.loadStarterPackUriForList, (list) => "loadStarterPackUriForList-" + list.uri, ); this.enableStatus( this.loadActorStarterPacks, (did) => "loadActorStarterPacks-" + did, ); this.enableStatus(this.loadPopularFeeds, "loadPopularFeeds"); this.enableStatus( this.loadAllListMembers, (listUri) => "loadAllListMembers-" + listUri, ); this.enableStatus(this.loadBlockedProfiles, "loadBlockedProfiles"); this.enableStatus(this.loadMutedProfiles, "loadMutedProfiles"); }
async loadCurrentUser() { const session = await this.api.getSession(); let profile; try { profile = await this.api.getProfile(session.did); } catch (error) { console.warn( "getProfile failed, falling back to the profile record", error, ); profile = await this.loadCurrentUserFromRecord(session); } this.dataStore.setCurrentUser(profile); }
async loadCurrentUserFromRecord(session) { let record = null; try { record = await this.api.getProfileRecord(); } catch (error) { if (!isRecordNotFoundError(error)) { throw error; } } return buildProfileFromRecord({ did: session.did, handle: session.handle, record, }); }
async loadPostThread(postURI, { depth = 6 } = {}) { let [postThread, postThreadOther] = await Promise.all([ this.api.getPostThread(postURI, { depth }), this.api.getPostThreadOther(postURI), ]); // 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, { 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); }
// Save post thread this.dataStore.setPostThread(postURI, postThread); this.dataStore.$postThreadOthers.set(postURI, postThreadOther); // Note - this return value is used by loadParentChain return postThread; }
async loadPost(postURI) { const post = await this.api.getPost(postURI); await this._loadPostDependencies([post]); this.dataStore.setPosts([post]); }
async loadPosts(postURIs) { if (postURIs.length === 0) return; const posts = await this.api.getPosts(postURIs); await this._loadPostDependencies(posts); this.dataStore.setPosts(posts); }
async _loadParentChain(blockedParent, { rootUri } = {}) { if ( !rootUri || isBlockingUser(blockedParent) || isBlockedByViewer(blockedParent) ) { return await this.loadPostThread(blockedParent.uri, { depth: 0, }); }
let backlinks; try { backlinks = await this._getPostsInThreadFromBacklinks(rootUri); } catch (error) { if (error.name === "AbortError") { return await this.loadPostThread(blockedParent.uri, { depth: 0, }); } 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); 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, }); }
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) { 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); 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 cursor = reload ? "" : readCollectionCursor(this.dataStore.$feeds, { key: uri }); let feed; switch (type) { case "timeline": feed = await this.api.getFollowingFeed({ limit, cursor }); break; case "list": feed = await this.api.getListFeed(uri, { limit, cursor }); break; case "feed": { const preferences = await this.preferencesProvider.requirePreferences(); const shouldSendInterests = this.api.isAuthenticated && BSKY_FEED_OWNER_DIDS.includes(parseUri(uri).repo); feed = await this.api.getFeed(uri, { limit, cursor, interests: shouldSendInterests ? preferences.getInterests() : null, }); break; } default: throw new Error(`Unknown pinned item type: ${type}`); } // Apply thread mutes on load so muting in the UI doesn't hide threads from feeds feed = { ...feed, feed: feed.feed.filter((item) => item.post.viewer?.threadMuted !== true), }; const postsToSave = getPostsFromFeed(feed); await this._loadPostDependencies(postsToSave); this.dataStore.setPosts(postsToSave); this.dataStore.setProfiles(getRepostersFromFeed(feed)); this.dataStore.setPostNumberingForFeed(feed); await this.events.emitAsync("feedLoaded", { feedURI: uri, feed, reload }); writePageToCollection(this.dataStore.$feeds, "feed", feed, { key: uri, requestCursor: cursor, overwrite: 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 fetchedBlockedPosts = await this.api.getPosts(blockedPostUris); 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 (isRecordNotFoundError(error)) { const uri = missingPostUris[index]; this.dataStore.$unavailablePosts.set(uri, createUnavailablePost(uri)); } }); }
async loadDetailedProfile(did) { const profile = await this.api.getProfile(did); this.dataStore.setDetailedProfile(profile); }
async loadDetailedProfiles(dids) { if (dids.length === 0) return; const profiles = await this.api.getProfiles(dids); for (const profile of profiles) { this.dataStore.setDetailedProfile(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 requestTime = Date.now(); this.dataStore.$latestProfileSearchRequestTime.set(requestTime); const searchData = await this.api.searchProfiles(query, { limit, cursor, }); 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 _loadProfileTypeahead(query, { limit, $results, $latestRequestTime }) { if (!query) { // Invalidate in-flight searches so they can't repopulate cleared results $latestRequestTime.set(null); $results.set(null); return; } const requestTime = Date.now(); $latestRequestTime.set(requestTime); const searchData = await this.api.searchProfilesTypeahead(query, { limit, }); if (requestTime !== $latestRequestTime.get()) { return; } this.dataStore.setProfiles(searchData.actors); $results.set({ ...searchData, query }); }
async loadChatRecipientSearch(query, { limit = 12 } = {}) { await this._loadProfileTypeahead(query, { limit, $results: this.dataStore.$chatRecipientSearchResults, $latestRequestTime: this.dataStore.$latestChatRecipientSearchRequestTime, }); }
async loadSearchTypeahead(query, { limit = 8 } = {}) { await this._loadProfileTypeahead(query, { limit, $results: this.dataStore.$searchTypeaheadResults, $latestRequestTime: this.dataStore.$latestSearchTypeaheadRequestTime, }); }
async loadSidebarSearchTypeahead(query, { limit = 8 } = {}) { await this._loadProfileTypeahead(query, { limit, $results: this.dataStore.$sidebarSearchTypeaheadResults, $latestRequestTime: this.dataStore.$latestSidebarSearchTypeaheadRequestTime, }); }
async loadMentionTypeahead(query, { limit = 8 } = {}) { await this._loadProfileTypeahead(query, { limit, $results: this.dataStore.$mentionTypeaheadResults, $latestRequestTime: this.dataStore.$latestMentionTypeaheadRequestTime, }); }
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 requestTime = Date.now(); $latestRequestTime.set(requestTime); const searchData = await this.api.searchPosts(query, { limit, sort, cursor, }); 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) : []; 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 || []; this.dataStore.setFeedGenerators(feeds); 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 loadStarterPackSearch(query, { limit = 25, cursor = "" } = {}) { if (!query) { this.dataStore.$latestStarterPackSearchRequestTime.set(null); this.dataStore.$starterPackSearchResults.set(null); return; } if (!cursor) { this.dataStore.$starterPackSearchResults.set(null); } const requestTime = Date.now(); this.dataStore.$latestStarterPackSearchRequestTime.set(requestTime); const searchData = await this.api.searchStarterPacks(query, { limit, cursor, }); if ( requestTime !== this.dataStore.$latestStarterPackSearchRequestTime.get() ) { return; } const existingResults = this.dataStore.$starterPackSearchResults.get(); const existingUris = new Set( existingResults && cursor ? existingResults.starterPacks.map((starterPack) => starterPack.uri) : [], ); const starterPacks = []; for (const starterPack of searchData.starterPacks || []) { if (existingUris.has(starterPack.uri)) continue; existingUris.add(starterPack.uri); starterPacks.push(starterPack); } this.dataStore.setStarterPacks(starterPacks); if (existingResults && cursor) { this.dataStore.$starterPackSearchResults.set({ starterPacks: [...existingResults.starterPacks, ...starterPacks], cursor: searchData.cursor, }); } else { this.dataStore.$starterPackSearchResults.set({ starterPacks, cursor: searchData.cursor, }); } }
async loadGifs(query, { limit = 30, cursor = "" } = {}) { if (!cursor) { this.dataStore.$gifResults.set(null); } const requestTime = Date.now(); this.dataStore.$latestGifRequestTime.set(requestTime); // Empty query loads featured gifs const gifData = query ? await this.api.searchGifs(query, { limit, cursor }) : await this.api.getFeaturedGifs({ limit, cursor }); if (requestTime !== this.dataStore.$latestGifRequestTime.get()) { return; } const results = gifData.results ?? []; // The provider repeats ids across pages; a page of nothing-but-repeats // should end pagination const existingGifs = this.dataStore.$gifResults.get()?.gifs ?? []; const existingIds = new Set(existingGifs.map((gifItem) => gifItem.id)); const hasFreshResults = results.filter((gifItem) => !existingIds.has(gifItem.id)).length > 0; // KLIPY's `next` is a positional offset; falsy (or a "0" reset sentinel) // means end of list const nextCursor = gifData.next && String(gifData.next) !== "0" ? String(gifData.next) : ""; writePageToCollection( this.dataStore.$gifResults, "gifs", { gifs: results, cursor: cursor && !hasFreshResults ? "" : nextCursor, }, { requestCursor: cursor, dedupeBy: "id" }, ); }
async loadNextAuthorFeedPage( did, feedType, { reload = false, limit = 31 } = {}, ) { const feedURI = `${did}-${feedType}`; const cursor = reload ? "" : readCollectionCursor(this.dataStore.$authorFeeds, { key: feedURI }); const params = { limit, cursor };
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; case "videos": params.filter = "posts_with_video"; 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); this.dataStore.setProfiles(getRepostersFromFeed(feed)); this.dataStore.setPostNumberingForFeed(feed); // 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 res = await this.api.getNotifications({ cursor, limit }); if (cursor === "") { this.dataStore.$notificationsLastSeenAt.set(res.seenAt ?? null); } this.dataStore.setProfiles( res.notifications.map((notification) => notification.author), ); // Get associated posts const postUris = getPostUrisFromNotifications(res.notifications); if (postUris.length > 0) { const fetchedPosts = await this.api.getPosts(postUris); 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 res = await this.api.getNotifications({ cursor, limit, reasons: MENTION_REASONS, }); this.dataStore.setProfiles( res.notifications.map((notification) => notification.author), ); const postUris = getPostUrisFromNotifications(res.notifications); if (postUris.length > 0) { const fetchedPosts = await this.api.getPosts(postUris); 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 res = await this.api.listConvos({ cursor, limit }); // 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 res = await this.api.listConvos({ cursor, limit, status: "request", }); // 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 res = await this.api.getConvo(convoId); this.dataStore.setConvo(res.convo); }
async loadConvoMembers(convoId, { reload = false } = {}) { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$convoMemberLists, { key: convoId, }); const res = await this.api.getConvoMembers(convoId, { cursor }); 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 res = await this.api.getConvoForMembers([profileDid]); this.dataStore.setConvo(res.convo); }
async loadConvoMessages(convoId, { reload = false, limit = 50 } = {}) { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$convoMessages, { key: convoId }); const res = await this.api.getMessages(convoId, { cursor, limit, }); // 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 res = await this.api.getChatLogs({ cursor }); 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 res = await this.api.getLikes(postUri, { cursor }); 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 res = await this.api.getQuotes(postUri, { cursor });
// 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) : []; // 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 res = await this.api.getRepostedBy(postUri, { cursor }); 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); this.dataStore.setProfiles([feedGeneratorData.creator]); }
async loadList(listUri) { const data = await this.api.getList(listUri, { limit: 1 }); this.dataStore.$lists.set(listUri, data.list); this.dataStore.setProfiles([data.list.creator]); }
async loadStarterPackUriForList(list) { const backlinks = await this.constellation.getLinks({ subject: list.uri, source: "app.bsky.graph.starterpack:list", timeout: 2000, }); const backlink = backlinks.find( (candidate) => candidate.did === list.creator.did, ); this.dataStore.$starterPackUrisByList.set( list.uri, backlink ? buildUri({ repo: backlink.did, collection: backlink.collection, rkey: backlink.rkey, }) : null, ); }
async loadStarterPack(starterPackUri) { const starterPack = await this.api.getStarterPack(starterPackUri); this.dataStore.$starterPacks.set(starterPackUri, starterPack); this.dataStore.setProfiles([starterPack.creator]); }
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 loadAllListMembers(listUri) { const items = await this.api.getAllListItems(listUri); this.dataStore.setProfiles(items.map((item) => item.subject)); writePageToCollection( this.dataStore.$listMembers, "items", { items, cursor: null }, { key: listUri, overwrite: true }, ); }
async loadTrends({ limit = 5 } = {}) { const data = await this.api.getTrends({ limit }); this.dataStore.$trends.set(unique(data.trends ?? [], { by: "link" })); }
async loadPinnedItems() { const preferences = await 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);
this.dataStore.setFeedGenerators(feedGenerators); this.dataStore.setLists(listViews); 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.setPinnedItems(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 }); this.dataStore.setFeedGenerators(data.feeds); 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 }); this.dataStore.setLists(data.lists); writePageToCollection(this.dataStore.$actorLists, "lists", data, { key: did, requestCursor: cursor, overwrite: reload, }); }
async loadActorStarterPacks(did, { reload = false, limit = 50 } = {}) { const existing = this.dataStore.$actorStarterPacks.get(did); if (existing && !existing.cursor && !reload) { return; } const cursor = reload ? "" : readCollectionCursor(this.dataStore.$actorStarterPacks, { key: did }); const data = await this.api.getActorStarterPacks(did, { limit, cursor }); this.dataStore.setStarterPacks( data.starterPacks.filter( (starterPack) => !this.dataStore.$starterPacks.get(starterPack.uri)?.list, ), ); writePageToCollection( this.dataStore.$actorStarterPacks, "starterPacks", data, { key: did, requestCursor: cursor, overwrite: reload, }, ); }
async loadPopularFeeds({ limit = 30 } = {}) { const data = await this.api.getPopularFeedGenerators({ limit }); const feeds = data.feeds ?? []; this.dataStore.setFeedGenerators(feeds); this.dataStore.$popularFeeds.set(feeds); }
async loadCurrentUserLists({ reload = false } = {}) { if (!this.api.isAuthenticated) return; await this.loadActorLists(this.api.session.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 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, });
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) : []; 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 res = await this.api.getBookmarks({ limit, cursor });
// 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) : []; 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 res = await this.api.getFollowers(profileDid, { cursor }); this.dataStore.setProfiles(res.followers);
writePageToCollection(this.dataStore.$profileFollowers, "followers", res, { key: profileDid, requestCursor: cursor ?? "", overwrite: !cursor, }); }
async loadKnownFollowers(profileDid, { cursor } = {}) { const res = await this.api.getKnownFollowers(profileDid, { cursor, }); this.dataStore.setProfiles(res.followers);
writePageToCollection(this.dataStore.$knownFollowers, "followers", res, { key: profileDid, requestCursor: cursor ?? "", overwrite: !cursor, }); }
async loadProfileFollows(profileDid, { cursor } = {}) { const res = await this.api.getFollows(profileDid, { cursor }); this.dataStore.setProfiles(res.follows);
writePageToCollection(this.dataStore.$profileFollows, "follows", res, { key: profileDid, requestCursor: cursor ?? "", overwrite: !cursor, }); }
async loadBlockedProfiles({ cursor } = {}) { const res = await this.api.getBlocks({ cursor }); this.dataStore.setProfiles(res.blocks);
writePageToCollection(this.dataStore.$blockedProfiles, "blocks", res, { requestCursor: cursor ?? "", overwrite: !cursor, }); }
async loadMutedProfiles({ cursor } = {}) { const res = await this.api.getMutes({ cursor }); this.dataStore.setProfiles(res.mutes);
writePageToCollection(this.dataStore.$mutedProfiles, "mutes", res, { requestCursor: cursor ?? "", overwrite: !cursor, }); }
async loadChatActorStatus() { const res = await this.api.getChatActorStatus(); this.dataStore.$chatActorStatus.set(res); }
async loadProfileChatStatus(profileDid) { const res = await this.api.getConvoAvailability([profileDid]); this.dataStore.$profileChatStatus.set(profileDid, res); }
async loadLabelerInfo(labelerDid) { const labelerInfo = await this.api.getLabeler(labelerDid); this.dataStore.$labelerInfo.set(labelerDid, labelerInfo); }}