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.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569import { isInvalidSwapError, isRecordNotFoundError, isRetryableError,} from "/js/api.js";import { parseUri, buildUri, hasDisableEmbeddingRule, createNotFoundPost, createStatusView, createNestedThreadViewPost, addFeedItemToFeed, pinPostInFeed, unpinPostInFeed, valueForPinnedItem, buildCdnUrl, getReplyRootFromPost, insertReplyIntoThread, MAX_DETACHED_EMBEDDING_URIS, createDetachedQuote, createEmbedFromPost, replaceQuotedPost, MAX_HIDDEN_REPLIES,} from "/js/dataHelpers.js";import { addPostLanguagesToHistory, batch, isNil, getCurrentTimestamp, truncateGraphemes, wait,} from "/js/utils.js";import { fetchAndCompressLinkCardImage } from "/js/embedHelpers.js";import { getFacetsFromText } from "/js/facetHelpers.js";import { PostCreator } from "/js/postCreator.js";import { untrack } from "/js/signals.js";import { generateTid } from "/js/atproto.js";
export class DetachedQuoteLimitError extends Error { constructor() { super( `Posts can have at most ${MAX_DETACHED_EMBEDDING_URIS} detached quotes`, ); this.name = "DetachedQuoteLimitError"; }}
export class HiddenReplyLimitError extends Error { constructor() { super(`Threads can have at most ${MAX_HIDDEN_REPLIES} hidden replies`); this.name = "HiddenReplyLimitError"; }}
// Handles mutations to the data, making optimistic updates if needed.export class Mutations { constructor( api, dataStore, sessionState, patchStore, preferencesProvider, identityResolver, draftMediaStore, ) { this.api = api; this.dataStore = dataStore; this.sessionState = sessionState; this.patchStore = patchStore; this.preferencesProvider = preferencesProvider; this.draftMediaStore = draftMediaStore; this.identityResolver = identityResolver; this.postCreator = new PostCreator(api, identityResolver); }
async addLike(post) { // Optimistic update const patchId = this.patchStore.addPostPatch(post.uri, { type: "addLike", }); try { let like; try { like = await this.api.createLikeRecord(post); } catch (error) { // Retry once on failure if (!isRetryableError(error)) throw error; like = await this.api.createLikeRecord(post); } const latestPost = this.dataStore.$posts.get(post.uri) ?? post; if (!latestPost.viewer?.like) { this.dataStore.$posts.set(post.uri, { ...latestPost, viewer: { ...latestPost.viewer, like: like.uri }, likeCount: latestPost.likeCount + 1, }); } // If the "likes" feed is loaded, add the post to it. const currentUser = this.dataStore.$currentUser.get(); if (currentUser) { const feedURI = `${currentUser.did}-likes`; const likedFeed = this.dataStore.$authorFeeds.get(feedURI); if ( likedFeed && !likedFeed.feed.some((feedItem) => feedItem.post?.uri === post.uri) ) { this.dataStore.$authorFeeds.set(feedURI, { feed: [{ post: post }, ...likedFeed.feed], cursor: likedFeed.cursor, }); } } } catch (error) { console.error(error); throw error; } finally { // clear patch this.patchStore.removePostPatch(post.uri, patchId); } }
async removeLike(post) { // Optimistic update const patchId = this.patchStore.addPostPatch(post.uri, { type: "removeLike", }); try { await this.api.deleteLikeRecord(post); const latestPost = this.dataStore.$posts.get(post.uri) ?? post; if (latestPost.viewer?.like) { this.dataStore.$posts.set(post.uri, { ...latestPost, viewer: { ...latestPost.viewer, like: null }, likeCount: latestPost.likeCount - 1, }); } } catch (error) { console.error(error); throw error; } finally { // clear patch this.patchStore.removePostPatch(post.uri, patchId); } }
async createRepost(post) { const patchId = this.patchStore.addPostPatch(post.uri, { type: "createRepost", }); try { const repost = await this.api.createRepostRecord(post); const latestPost = this.dataStore.$posts.get(post.uri) ?? post; if (!latestPost.viewer?.repost) { this.dataStore.$posts.set(post.uri, { ...latestPost, viewer: { ...latestPost.viewer, repost: repost.uri }, repostCount: latestPost.repostCount + 1, }); } // If the current user's author feed is loaded, add the repost to it. const currentUser = this.dataStore.$currentUser.get(); if (currentUser) { const authorFeedURI = `${currentUser.did}-posts`; const authorFeed = this.dataStore.$authorFeeds.get(authorFeedURI); if ( authorFeed && !authorFeed.feed.some( (feedItem) => feedItem.post?.uri === post.uri && feedItem.reason?.$type === "app.bsky.feed.defs#reasonRepost" && feedItem.reason?.by?.did === currentUser.did, ) ) { const newFeedItem = { post: post, reason: { $type: "app.bsky.feed.defs#reasonRepost", by: currentUser, uri: repost.uri, cid: repost.cid, indexedAt: new Date().toISOString(), }, }; this.dataStore.$authorFeeds.set(authorFeedURI, { feed: addFeedItemToFeed(newFeedItem, authorFeed.feed), cursor: authorFeed.cursor, }); } } } catch (error) { console.error(error); throw error; } finally { // clear patch this.patchStore.removePostPatch(post.uri, patchId); } }
async deleteRepost(post) { const patchId = this.patchStore.addPostPatch(post.uri, { type: "deleteRepost", }); try { await this.api.deleteRepostRecord(post); const latestPost = this.dataStore.$posts.get(post.uri) ?? post; if (latestPost.viewer?.repost) { this.dataStore.$posts.set(post.uri, { ...latestPost, viewer: { ...latestPost.viewer, repost: null }, repostCount: latestPost.repostCount - 1, }); } } catch (error) { console.error(error); throw error; } finally { // clear patch this.patchStore.removePostPatch(post.uri, patchId); } }
async addBookmark(post) { // Optimistic update const patchId = this.patchStore.addPostPatch(post.uri, { type: "addBookmark", }); try { await this.api.createBookmark(post); const latestPost = this.dataStore.$posts.get(post.uri) ?? post; if (!latestPost.viewer?.bookmarked) { this.dataStore.$posts.set(post.uri, { ...latestPost, viewer: { ...latestPost.viewer, bookmarked: true }, bookmarkCount: latestPost.bookmarkCount + 1, }); } // If the bookmarks feed is loaded, add the post to it. const bookmarks = this.dataStore.$bookmarks.get(); if ( bookmarks && !bookmarks.bookmarks.some((bookmark) => bookmark.item?.uri === post.uri) ) { this.dataStore.$bookmarks.set({ bookmarks: [{ item: { ...post } }, ...bookmarks.bookmarks], cursor: bookmarks.cursor, }); } } catch (error) { console.error(error); throw error; } finally { // clear patch this.patchStore.removePostPatch(post.uri, patchId); } }
async removeBookmark(post) { // Optimistic update const patchId = this.patchStore.addPostPatch(post.uri, { type: "removeBookmark", }); try { await this.api.deleteBookmark(post); const latestPost = this.dataStore.$posts.get(post.uri) ?? post; if (latestPost.viewer?.bookmarked) { this.dataStore.$posts.set(post.uri, { ...latestPost, viewer: { ...latestPost.viewer, bookmarked: false }, bookmarkCount: latestPost.bookmarkCount - 1, }); } // If the bookmarks feed is loaded, remove the post from it. const bookmarks = this.dataStore.$bookmarks.get(); if (bookmarks) { this.dataStore.$bookmarks.set({ bookmarks: bookmarks.bookmarks.filter( (bookmark) => bookmark.item?.uri !== post.uri, ), cursor: bookmarks.cursor, }); } } catch (error) { console.error(error); throw error; } finally { // clear patch this.patchStore.removePostPatch(post.uri, patchId); } }
async followProfile(profile) { const patchId = this.patchStore.addProfilePatch(profile.did, { type: "followProfile", }); try { const follow = await this.api.createFollowRecord(profile); const latestProfile = this.dataStore.$profiles.get(profile.did) ?? profile; if (!latestProfile.viewer?.following) { this.dataStore.$profiles.set(profile.did, { ...latestProfile, viewer: { ...latestProfile.viewer, following: follow.uri }, }); } const detailed = this.dataStore.$detailedProfiles.get(profile.did); if (detailed && !detailed.viewer?.following) { this.dataStore.$detailedProfiles.set(profile.did, { ...detailed, followersCount: detailed.followersCount + 1, viewer: { ...detailed.viewer, following: follow.uri }, }); } } catch (error) { console.error(error); throw error; } finally { // clear patch this.patchStore.removeProfilePatch(profile.did, patchId); } }
async followAllStarterPackMembers(starterPack) { const currentUserDid = this.api.session.did; const listItems = await this.api.getAllListItems(starterPack.list.uri); const dids = listItems .map((item) => item.subject) .filter( (profile) => profile.did !== currentUserDid && !profile.viewer?.blocking && !profile.viewer?.blockedBy && !profile.viewer?.muted && !profile.viewer?.mutedByList && !profile.viewer?.following, ) .map((profile) => profile.did); const writes = dids.map((did) => ({ $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.graph.follow", rkey: generateTid(), value: { $type: "app.bsky.graph.follow", subject: did, createdAt: getCurrentTimestamp(), via: { uri: starterPack.uri, cid: starterPack.cid }, }, })); for (const chunk of batch(writes, 50)) { await this.api.applyWrites(chunk); } for (const write of writes) { const did = write.value.subject; const followUri = `at://${currentUserDid}/app.bsky.graph.follow/${write.rkey}`; const profile = this.dataStore.$profiles.get(did); if (profile && !profile.viewer?.following) { this.dataStore.$profiles.set(did, { ...profile, viewer: { ...profile.viewer, following: followUri }, }); } const detailed = this.dataStore.$detailedProfiles.get(did); if (detailed && !detailed.viewer?.following) { this.dataStore.$detailedProfiles.set(did, { ...detailed, followersCount: detailed.followersCount + 1, viewer: { ...detailed.viewer, following: followUri }, }); } } return dids.length; }
async addProfileToList(profile, list) { const result = await this.api.createListItemRecord(list.uri, profile.did); this._patchListMembershipForActor(profile.did, list.uri, { uri: result.uri, subject: profile.did, }); // Add to cached list members const cachedMembers = this.dataStore.$listMembers.get(list.uri); if ( cachedMembers && !cachedMembers.items.some((item) => item.subject.did === profile.did) ) { this.dataStore.$listMembers.set(list.uri, { ...cachedMembers, items: [{ uri: result.uri, subject: profile }, ...cachedMembers.items], }); } }
async removeProfileFromList(profile, list, membershipUri) { await this.api.deleteListItemRecord(membershipUri); this._patchListMembershipForActor(profile.did, list.uri, null); // Remove from cached list members const cachedMembers = this.dataStore.$listMembers.get(list.uri); if (cachedMembers) { this.dataStore.$listMembers.set(list.uri, { ...cachedMembers, items: cachedMembers.items.filter( (item) => item.subject.did !== profile.did, ), }); } }
_patchListMembershipForActor(actorDid, listUri, listItem) { const existing = this.dataStore.$listsWithMembershipByActor.get(actorDid); if (!existing) return; this.dataStore.$listsWithMembershipByActor.set(actorDid, { ...existing, listsWithMembership: existing.listsWithMembership.map((entry) => entry.list.uri === listUri ? { ...entry, listItem: listItem || undefined } : entry, ), }); }
async unfollowProfile(profile) { const patchId = this.patchStore.addProfilePatch(profile.did, { type: "unfollowProfile", }); try { await this.api.deleteFollowRecord(profile); const latestProfile = this.dataStore.$profiles.get(profile.did) ?? profile; if (latestProfile.viewer?.following) { this.dataStore.$profiles.set(profile.did, { ...latestProfile, viewer: { ...latestProfile.viewer, following: null }, }); } const detailed = this.dataStore.$detailedProfiles.get(profile.did); if (detailed?.viewer?.following) { this.dataStore.$detailedProfiles.set(profile.did, { ...detailed, followersCount: detailed.followersCount - 1, viewer: { ...detailed.viewer, following: null }, }); } } catch (error) { console.error(error); throw error; } finally { // clear patch this.patchStore.removeProfilePatch(profile.did, patchId); } }
async sendShowLessInteraction(postURI, feedUri, feedContext, feedProxyUrl) { const showLessInteraction = { item: postURI, event: "app.bsky.feed.defs#requestLess", ...(feedContext != null ? { feedContext } : {}), }; this.dataStore.$showLessInteractions.set(feedUri, [ ...(this.dataStore.$showLessInteractions.get(feedUri) ?? []), showLessInteraction, ]); if (feedProxyUrl == null) { return; } try { await this.api.sendInteractions([showLessInteraction], feedProxyUrl); } catch (error) { console.error(error); throw error; } }
async sendShowMoreInteraction(postURI, feedUri, feedContext, feedProxyUrl) { const showMoreInteraction = { item: postURI, event: "app.bsky.feed.defs#requestMore", ...(feedContext != null ? { feedContext } : {}), }; // Note, we don't really need to store this interaction because we don't use it in the UI (yet). // But, let's do it anyway for consistency. this.dataStore.$showMoreInteractions.set(feedUri, [ ...(this.dataStore.$showMoreInteractions.get(feedUri) ?? []), showMoreInteraction, ]); if (feedProxyUrl == null) { return; } try { await this.api.sendInteractions([showMoreInteraction], feedProxyUrl); } catch (error) { console.error(error); throw error; } }
async pinFeed(feedUri) { const patchId = this.patchStore.addPreferencePatch({ type: "pinFeed", feedUri, entryType: "feed", }); const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.pinFeed(feedUri, "feed"); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removePreferencePatch(patchId); } this._addPinnedItem("feed", feedUri); }
async pinList(listUri) { const patchId = this.patchStore.addPreferencePatch({ type: "pinFeed", feedUri: listUri, entryType: "list", }); const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.pinFeed(listUri, "list"); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removePreferencePatch(patchId); } this._addPinnedItem("list", listUri); }
async unpinFeed(feedUri) { const patchId = this.patchStore.addPreferencePatch({ type: "unpinFeed", feedUri, }); const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.unpinFeed(feedUri); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removePreferencePatch(patchId); } this._removePinnedItem(feedUri); }
async unpinList(listUri) { const patchId = this.patchStore.addPreferencePatch({ type: "unpinFeed", feedUri: listUri, }); const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.unpinFeed(listUri); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removePreferencePatch(patchId); } this._removePinnedItem(listUri); }
_addPinnedItem(type, uri) { const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); if (!pinnedItems || pinnedItems.some((item) => item.data.uri === uri)) { return; } const data = untrack(() => type === "list" ? this.dataStore.$lists.get(uri) : this.dataStore.$feedGenerators.get(uri), ); if (!data) { return; } this.dataStore.setPinnedItems([...pinnedItems, { type, data }]); }
_removePinnedItem(uri) { const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); if (!pinnedItems?.some((item) => item.data.uri === uri)) { return; } this.dataStore.setPinnedItems( pinnedItems.filter((item) => item.data.uri !== uri), ); }
async setPinnedItems(values) { const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.setPinnedItems(values); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); throw error; }
// Update pinned items in memory const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); if (pinnedItems) { const byValue = new Map( pinnedItems.map((item) => [valueForPinnedItem(item), item]), ); const next = values.map((value) => byValue.get(value)).filter(Boolean); this.dataStore.setPinnedItems(next); } }
setSelectedFeedUri(feedUri) { this.sessionState.$selectedFeedUri.set(feedUri); }
setTrendingHidden(hidden) { this.sessionState.$trendingHidden.set(hidden); }
updatePostLanguageHistory(languages) { const history = untrack(() => this.sessionState.$postLanguageHistory.get()); this.sessionState.$postLanguageHistory.set( addPostLanguagesToHistory(history, languages), ); }
async hidePost(post) { const patchId = this.patchStore.addPostPatch(post.uri, { type: "hidePost", }); const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.hidePost(post.uri); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); throw error; } finally { // clear patch this.patchStore.removePostPatch(post.uri, patchId); } }
async addRecentSearch(q) { const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.addRecentSearch(q); await this.preferencesProvider.updatePreferences(newPreferences); }
async removeRecentSearch(q) { const patchId = this.patchStore.addPreferencePatch({ type: "removeRecentSearch", q, }); const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.removeRecentSearch(q); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removePreferencePatch(patchId); } }
async setThreadView(view) { const patchId = this.patchStore.addPreferencePatch({ type: "setThreadView", view, }); const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.setThreadView(view); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removePreferencePatch(patchId); } }
async addRecentGif(gif) { const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.addRecentGif(gif); await this.preferencesProvider.updatePreferences(newPreferences); }
async addRecentSearchProfile(did) { const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.addRecentSearchProfile(did); await this.preferencesProvider.updatePreferences(newPreferences); }
async removeRecentSearchProfile(did) { const patchId = this.patchStore.addPreferencePatch({ type: "removeRecentSearchProfile", did, }); const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.removeRecentSearchProfile(did); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removePreferencePatch(patchId); } }
async removeRecentSearchProfiles(dids) { const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.removeRecentSearchProfiles(dids); await this.preferencesProvider.updatePreferences(newPreferences); }
async addMutedWord({ value, targets, actorTarget, expiresAt }) { const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.addMutedWord({ value, targets, actorTarget, expiresAt, }); await this.preferencesProvider.updatePreferences(newPreferences); }
async removeMutedWord(wordId) { const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.removeMutedWord(wordId); await this.preferencesProvider.updatePreferences(newPreferences); }
async updateMutedWord(wordId, updatedFields) { const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.updateMutedWord(wordId, updatedFields); await this.preferencesProvider.updatePreferences(newPreferences); }
async updatePostInteractionSettings({ threadgateAllowRules, postgateEmbeddingRules, }) { const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.setPostInteractionSettings({ threadgateAllowRules, postgateEmbeddingRules, }); await this.preferencesProvider.updatePreferences(newPreferences); }
async subscribeLabeler(profile, labelerInfo) { const patchId = this.patchStore.addPreferencePatch({ type: "subscribeLabeler", did: profile.did, labelerInfo, }); const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.subscribeLabeler( profile.did, labelerInfo, );
try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removePreferencePatch(patchId); } }
async unsubscribeLabeler(profile) { const patchId = this.patchStore.addPreferencePatch({ type: "unsubscribeLabeler", did: profile.did, }); const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.unsubscribeLabeler(profile.did); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removePreferencePatch(patchId); } }
async updateLabelerSetting({ labelerDid, label, visibility }) { const patchId = this.patchStore.addPreferencePatch({ type: "setContentLabelPref", label, visibility, labelerDid, }); const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.setContentLabelPref({ label, visibility, labelerDid, }); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removePreferencePatch(patchId); } }
async muteProfile(profile) { const patchId = this.patchStore.addProfilePatch(profile.did, { type: "muteProfile", }); try { await this.api.muteActor(profile.did); this._updateStoredProfile(profile, (stored) => ({ ...stored, viewer: { ...stored.viewer, muted: true, mutedOnlyReposts: false }, })); const mutedProfiles = this.dataStore.$mutedProfiles.get(); if (mutedProfiles) { const alreadyListed = mutedProfiles.mutes.some( (muted) => muted.did === profile.did, ); if (!alreadyListed) { this.dataStore.$mutedProfiles.set({ ...mutedProfiles, mutes: [profile, ...mutedProfiles.mutes], }); } } } catch (error) { console.error(error); throw error; } finally { this.patchStore.removeProfilePatch(profile.did, patchId); } }
async unmuteProfile(profile) { const patchId = this.patchStore.addProfilePatch(profile.did, { type: "unmuteProfile", }); try { await this.api.unmuteActor(profile.did); this._updateStoredProfile(profile, (stored) => ({ ...stored, viewer: { ...stored.viewer, muted: false, mutedOnlyReposts: false }, })); const mutedProfiles = this.dataStore.$mutedProfiles.get(); if (mutedProfiles) { this.dataStore.$mutedProfiles.set({ ...mutedProfiles, mutes: mutedProfiles.mutes.filter( (muted) => muted.did !== profile.did, ), }); } } catch (error) { console.error(error); throw error; } finally { this.patchStore.removeProfilePatch(profile.did, patchId); } }
async muteProfileReposts(profile) { const patchId = this.patchStore.addProfilePatch(profile.did, { type: "muteProfileReposts", }); try { await this.api.muteActorReposts(profile.did); this._updateStoredProfile(profile, (stored) => ({ ...stored, viewer: { ...stored.viewer, mutedOnlyReposts: true }, })); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removeProfilePatch(profile.did, patchId); } }
async unmuteProfileReposts(profile) { const patchId = this.patchStore.addProfilePatch(profile.did, { type: "unmuteProfileReposts", }); try { await this.api.unmuteActor(profile.did); this._updateStoredProfile(profile, (stored) => ({ ...stored, viewer: { ...stored.viewer, mutedOnlyReposts: false }, })); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removeProfilePatch(profile.did, patchId); } }
async blockProfile(profile) { const patchId = this.patchStore.addProfilePatch(profile.did, { type: "blockProfile", }); try { const block = await this.api.blockActor(profile); this._updateStoredProfile(profile, (stored) => ({ ...stored, viewer: { ...stored.viewer, blocking: block.uri }, })); const blockedProfiles = this.dataStore.$blockedProfiles.get(); if (blockedProfiles) { const alreadyListed = blockedProfiles.blocks.some( (blocked) => blocked.did === profile.did, ); if (!alreadyListed) { this.dataStore.$blockedProfiles.set({ ...blockedProfiles, blocks: [profile, ...blockedProfiles.blocks], }); } } } catch (error) { console.error(error); throw error; } finally { this.patchStore.removeProfilePatch(profile.did, patchId); } }
async updatePostNotificationSubscription(profile, activitySubscription) { const patchId = this.patchStore.addProfilePatch(profile.did, { type: "updatePostNotificationSubscription", activitySubscription, }); try { await this.api.putActivitySubscription(profile.did, activitySubscription); this._updateStoredProfile(profile, (stored) => ({ ...stored, viewer: { ...stored.viewer, activitySubscription }, })); } catch (error) { console.error(error); throw error; } finally { this.patchStore.removeProfilePatch(profile.did, patchId); } }
async unblockProfile(profile) { const patchId = this.patchStore.addProfilePatch(profile.did, { type: "unblockProfile", }); try { await this.api.unblockActor(profile); this._updateStoredProfile(profile, (stored) => ({ ...stored, viewer: { ...stored.viewer, blocking: null }, })); const blockedProfiles = this.dataStore.$blockedProfiles.get(); if (blockedProfiles) { this.dataStore.$blockedProfiles.set({ ...blockedProfiles, blocks: blockedProfiles.blocks.filter( (blocked) => blocked.did !== profile.did, ), }); } } catch (error) { console.error(error); throw error; } finally { this.patchStore.removeProfilePatch(profile.did, patchId); } }
async muteModList(list) { try { await this.api.muteModList(list.uri); this.dataStore.$lists.set(list.uri, { ...list, viewer: { ...list.viewer, muted: true }, }); } catch (error) { console.error(error); throw error; } }
async unmuteModList(list) { try { await this.api.unmuteModList(list.uri); this.dataStore.$lists.set(list.uri, { ...list, viewer: { ...list.viewer, muted: false }, }); } catch (error) { console.error(error); throw error; } }
async blockModList(list) { try { const block = await this.api.blockModList(list.uri); this.dataStore.$lists.set(list.uri, { ...list, viewer: { ...list.viewer, blocked: block.uri }, }); } catch (error) { console.error(error); throw error; } }
async unblockModList(list) { const blockUri = list.viewer?.blocked; if (!blockUri) return; try { await this.api.unblockModList(blockUri); this.dataStore.$lists.set(list.uri, { ...list, viewer: { ...list.viewer, blocked: null }, }); } catch (error) { console.error(error); throw error; } }
async optOutOfReferenceList(list) { try { const created = await this.api.createReferenceListOptOutRecord(list.uri); await this._pollReferenceListOptOut(list.uri, true); this.dataStore.$referenceListOptOuts.set(list.uri, created.uri); } catch (error) { console.error(error); throw error; } }
async undoReferenceListOptOut(list) { const optOutUri = list.viewer?.referenceListOptOut; if (!optOutUri) return; try { try { await this.api.deleteReferenceListOptOutRecord(optOutUri); } catch (error) { if (!isRecordNotFoundError(error)) throw error; } await this._pollReferenceListOptOut(list.uri, false); this.dataStore.$referenceListOptOuts.set(list.uri, null); } catch (error) { console.error(error); throw error; } }
// Check that opted out state has reached the appview async _pollReferenceListOptOut(listUri, expectOptedOut) { const maxTries = 5; for (let tries = 0; tries < maxTries; tries++) { try { const data = await this.api.getList(listUri, { limit: 1 }); const isOptedOut = !!data.list?.viewer?.referenceListOptOut; if (isOptedOut === expectOptedOut) return true; } catch (error) { console.warn(error); } if (tries < maxTries - 1) { await wait(1000); } } return false; }
async updateProfile( profile, { displayName, description, avatarBlob, bannerBlob, removeAvatar, removeBanner, }, ) { const [avatarRef, bannerRef] = await Promise.all([ avatarBlob ? this.api.uploadBlob(avatarBlob) : null, bannerBlob ? this.api.uploadBlob(bannerBlob) : null, ]);
let existingRecord = {}; let swapCid = null; try { const recordData = await this.api.getProfileRecord(); existingRecord = recordData.value || {}; swapCid = recordData.cid; } catch (error) { if (error.status === 400) { // No existing record is ok } else { throw error; } }
const updatedRecord = { ...existingRecord }; if (displayName !== undefined) { updatedRecord.displayName = displayName; } if (description !== undefined) { updatedRecord.description = description; delete updatedRecord.descriptionFacets; } if (avatarRef) { updatedRecord.avatar = avatarRef; } else if (removeAvatar) { delete updatedRecord.avatar; } if (bannerRef) { updatedRecord.banner = bannerRef; } else if (removeBanner) { delete updatedRecord.banner; }
await this.api.putProfileRecord(updatedRecord, swapCid);
// Update in memory const patch = { displayName, description }; if (avatarRef) { patch.avatar = buildCdnUrl("avatar", profile.did, avatarRef.ref.$link); } else if (removeAvatar) { patch.avatar = ""; } if (bannerRef) { patch.banner = buildCdnUrl("banner", profile.did, bannerRef.ref.$link); } else if (removeBanner) { patch.banner = ""; }
this._updateCurrentUserProfile((existing) => ({ ...existing, ...patch })); }
async setLiveStatus({ linkMeta, durationMinutes, createdAt: passedCreatedAt = null, }) { const currentUser = untrack(() => this.dataStore.$currentUser.get()); if (!currentUser) throw new Error("No current user"); const createdAt = passedCreatedAt ?? getCurrentTimestamp();
// Upload thumbnail (best-effort — failure here doesn't block the publish) let thumbBlob = null; if (linkMeta.image) { try { const compressed = await fetchAndCompressLinkCardImage(linkMeta.image); const uploaded = await this.api.uploadBlob(compressed.blob); thumbBlob = { $type: "blob", mimeType: uploaded.mimeType, ref: { $link: uploaded.ref.$link }, size: uploaded.size, }; } catch (error) { if (error?.name === "AbortError") throw error; console.warn("Error uploading live status thumb", error); } }
const record = { $type: "app.bsky.actor.status", status: "app.bsky.actor.status#live", createdAt, durationMinutes, embed: { $type: "app.bsky.embed.external", external: { $type: "app.bsky.embed.external#external", uri: linkMeta.url, title: linkMeta.title, description: linkMeta.description, ...(thumbBlob ? { thumb: thumbBlob } : {}), }, }, };
let attempts = 0; let putResult; while (true) { let priorCid = null; try { const prior = await this.api.getStatusRecord(); priorCid = prior.cid ?? null; } catch (error) { if (!isRecordNotFoundError(error)) throw error; } try { putResult = await this.api.putStatusRecord(record, priorCid); break; } catch (error) { if ( isInvalidSwapError(error) && attempts < 5 // retry up to 5 times ) { attempts += 1; continue; } throw error; } } const statusView = createStatusView({ did: currentUser.did, cid: putResult?.cid ?? null, record, }); this.dataStore.$profileStatuses.set(currentUser.did, statusView); }
async clearLiveStatus() { const currentUser = untrack(() => this.dataStore.$currentUser.get()); if (!currentUser) throw new Error("No current user"); try { await this.api.deleteStatusRecord(); } catch (error) { if (!isRecordNotFoundError(error)) throw error; } this.dataStore.$profileStatuses.set(currentUser.did, null); }
async createList({ purpose, name, description, avatarBlob }) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) throw new Error("No current user"); const avatarRef = avatarBlob ? await this.api.uploadBlob(avatarBlob) : null; const record = { purpose, name, description, createdAt: getCurrentTimestamp(), }; if (avatarRef) record.avatar = avatarRef;
const res = await this.api.createListRecord(record);
const creator = { did: currentUser.did, handle: currentUser.handle, displayName: currentUser.displayName, avatar: currentUser.avatar, }; const listView = { $type: "app.bsky.graph.defs#listView", uri: res.uri, cid: res.cid, name, purpose, description, descriptionFacets: [], avatar: avatarRef?.ref?.$link ? buildCdnUrl("avatar", creator.did, avatarRef.ref.$link) : undefined, creator, indexedAt: record.createdAt, listItemCount: 0, viewer: {}, }; this.dataStore.$lists.set(res.uri, listView); const actorLists = untrack(() => this.dataStore.$actorLists.get(creator.did), ); if (actorLists) { this.dataStore.$actorLists.set(creator.did, { ...actorLists, lists: [listView, ...actorLists.lists], }); } return listView; }
async updateList(list, { name, description, avatarBlob, removeAvatar }) { const rkey = list.uri.split("/").pop(); const avatarRef = avatarBlob ? await this.api.uploadBlob(avatarBlob) : null;
const recordData = await this.api.getListRecord(rkey); const existingRecord = recordData.value || {}; const swapCid = recordData.cid;
const updatedRecord = { ...existingRecord }; if (name !== undefined) { updatedRecord.name = name; } if (description !== undefined) { updatedRecord.description = description; delete updatedRecord.descriptionFacets; } if (avatarRef) { updatedRecord.avatar = avatarRef; } else if (removeAvatar) { delete updatedRecord.avatar; }
await this.api.putListRecord(rkey, updatedRecord, swapCid);
// Update in memory const current = this.dataStore.$lists.get(list.uri) ?? list; const patched = { ...current }; if (name !== undefined) patched.name = name; if (description !== undefined) { patched.description = description; patched.descriptionFacets = []; } if (avatarRef?.ref?.$link && list.creator?.did) { patched.avatar = buildCdnUrl( "avatar", list.creator.did, avatarRef.ref.$link, ); } else if (removeAvatar) { patched.avatar = ""; } this.dataStore.$lists.set(list.uri, patched); }
// Scans the current user's listitem records on the PDS for the ones // belonging to the given list. Returns [{ rkey, subjectDid }]. async _findListItemRecords(listUri) { const records = []; let cursor = ""; const MAX_PAGES = 100; let hitCap = true; for (let i = 0; i < MAX_PAGES; i++) { const res = await this.api.getListItems({ cursor, limit: 100 }); for (const record of res.records) { if (record.value?.list === listUri) { records.push({ rkey: parseUri(record.uri).rkey, subjectDid: record.value.subject, }); } } cursor = res.cursor; if (!cursor) { hitCap = false; break; } } if (hitCap) { console.warn( `_findListItemRecords: stopped scanning listitems after ${MAX_PAGES} pages`, ); } return records; }
async deleteList(list) { const { rkey } = parseUri(list.uri); const listItemRecords = await this._findListItemRecords(list.uri); const writes = [ ...listItemRecords.map((item) => ({ $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.listitem", rkey: item.rkey, })), { $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.list", rkey, }, ]; for (const chunk of batch(writes, 10)) { await this.api.applyWrites(chunk); } this.dataStore.$lists.set(list.uri, null); this.dataStore.$listMembers.set(list.uri, null); if (list.creator?.did) { const actorLists = this.dataStore.$actorLists.get(list.creator.did); if (actorLists) { this.dataStore.$actorLists.set(list.creator.did, { ...actorLists, lists: actorLists.lists.filter((entry) => entry.uri !== list.uri), }); } } for (const [ actorDid, entry, ] of this.dataStore.$listsWithMembershipByActor.entries()) { if (!entry?.listsWithMembership) continue; const filtered = entry.listsWithMembership.filter( (item) => item.list.uri !== list.uri, ); if (filtered.length !== entry.listsWithMembership.length) { this.dataStore.$listsWithMembershipByActor.set(actorDid, { ...entry, listsWithMembership: filtered, }); } } this._removePinnedItem(list.uri); const preferences = await this.preferencesProvider.requirePreferences(); if (preferences.isFeedPinned(list.uri)) { const newPreferences = preferences.unpinFeed(list.uri); try { await this.preferencesProvider.updatePreferences(newPreferences); } catch (error) { console.error(error); } } }
async _buildStarterPackDescription(description) { const trimmed = (description ?? "").trim(); if (!trimmed) return { description: null, descriptionFacets: null }; const facets = await getFacetsFromText(trimmed, this.identityResolver); return { description: trimmed, descriptionFacets: facets.length > 0 ? facets : null, }; }
_buildStarterPackName(name, currentUser) { const trimmed = (name ?? "").trim(); if (trimmed) return trimmed; const fallback = `${currentUser.displayName || currentUser.handle}'s Starter Pack`; return truncateGraphemes(fallback, 50); }
_listItemCreateWrite({ listUri, did }) { return { $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.graph.listitem", rkey: generateTid(), value: { $type: "app.bsky.graph.listitem", subject: did, list: listUri, createdAt: getCurrentTimestamp(), }, }; }
_listItemUri(rkey) { return `at://${this.api.session.did}/app.bsky.graph.listitem/${rkey}`; }
async createStarterPack({ name, description, profiles, feeds }) { const currentUser = untrack(() => this.dataStore.$currentUser.get()); if (!currentUser) throw new Error("No current user"); const resolvedName = this._buildStarterPackName(name, currentUser); const resolvedDescription = await this._buildStarterPackDescription(description); const createdAt = getCurrentTimestamp(); const did = currentUser.did;
const listRkey = generateTid(); const listUri = `at://${did}/app.bsky.graph.list/${listRkey}`; const listRecord = { $type: "app.bsky.graph.list", purpose: "app.bsky.graph.defs#referencelist", name: resolvedName, createdAt, }; if (resolvedDescription.description) { listRecord.description = resolvedDescription.description; if (resolvedDescription.descriptionFacets) { listRecord.descriptionFacets = resolvedDescription.descriptionFacets; } }
const listItemWrites = profiles.map((profile) => this._listItemCreateWrite({ listUri, did: profile.did }), );
const packRkey = generateTid(); const packUri = `at://${did}/app.bsky.graph.starterpack/${packRkey}`; const packRecord = { $type: "app.bsky.graph.starterpack", name: resolvedName, list: listUri, createdAt, }; if (resolvedDescription.description) { packRecord.description = resolvedDescription.description; if (resolvedDescription.descriptionFacets) { packRecord.descriptionFacets = resolvedDescription.descriptionFacets; } } if (feeds.length > 0) { packRecord.feeds = feeds.map((feed) => ({ uri: feed.uri })); }
const writes = [ { $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.graph.list", rkey: listRkey, value: listRecord, }, ...listItemWrites, { $type: "com.atproto.repo.applyWrites#create", collection: "app.bsky.graph.starterpack", rkey: packRkey, value: packRecord, }, ]; const res = await this.api.applyWrites(writes); const results = res?.results ?? []; const listCid = results[0]?.cid ?? null; const packCid = results[writes.length - 1]?.cid ?? null;
const creator = { did: currentUser.did, handle: currentUser.handle, displayName: currentUser.displayName, avatar: currentUser.avatar, }; const listView = { $type: "app.bsky.graph.defs#listViewBasic", uri: listUri, cid: listCid, name: resolvedName, purpose: "app.bsky.graph.defs#referencelist", listItemCount: profiles.length, indexedAt: createdAt, viewer: {}, }; const items = listItemWrites.map((write, i) => ({ uri: this._listItemUri(write.rkey), subject: profiles[i], })); const starterPackView = { $type: "app.bsky.graph.defs#starterPackView", uri: packUri, cid: packCid, record: packRecord, creator, list: listView, listItemsSample: items.slice(0, 12), feeds, joinedWeekCount: 0, joinedAllTimeCount: 0, indexedAt: createdAt, };
this.dataStore.$starterPacks.set(packUri, starterPackView); this.dataStore.$starterPackUrisByList.set(listUri, packUri); this.dataStore.$listMembers.set(listUri, { items, cursor: null }); const actorStarterPacks = untrack(() => this.dataStore.$actorStarterPacks.get(did), ); if (actorStarterPacks) { this.dataStore.$actorStarterPacks.set(did, { ...actorStarterPacks, starterPacks: [starterPackView, ...actorStarterPacks.starterPacks], }); } this._updateCurrentUserProfile((profile) => profile.associated ? { ...profile, associated: { ...profile.associated, starterPacks: (profile.associated.starterPacks ?? 0) + 1, lists: (profile.associated.lists ?? 0) + 1, }, } : profile, ); return starterPackView; }
async updateStarterPack(starterPack, { name, description, profiles, feeds }) { const currentUser = untrack(() => this.dataStore.$currentUser.get()); if (!currentUser) throw new Error("No current user"); if (starterPack.creator.did !== currentUser.did) { throw new Error("Cannot edit a starter pack owned by another account"); } const listUri = starterPack.list?.uri; if (!listUri) throw new Error("Starter pack has no list"); const existingMembers = untrack(() => this.dataStore.$listMembers.get(listUri), ); const optedOutItems = (existingMembers?.items ?? []).filter( (item) => item.subjectOptedOut, ); const optedOutDids = new Set(optedOutItems.map((item) => item.subject.did));
const resolvedName = this._buildStarterPackName(name, currentUser); const resolvedDescription = await this._buildStarterPackDescription(description);
const selectedDids = new Set(profiles.map((profile) => profile.did)); const existingRecords = await this._findListItemRecords(listUri); const rkeysByDid = new Map(); for (const record of existingRecords) { const rkeys = rkeysByDid.get(record.subjectDid) ?? []; rkeys.push(record.rkey); rkeysByDid.set(record.subjectDid, rkeys); }
const deleteWrites = []; for (const [did, rkeys] of rkeysByDid) { const keep = selectedDids.has(did) || did === currentUser.did || optedOutDids.has(did); const surplus = keep ? rkeys.slice(1) : rkeys; for (const rkey of surplus) { deleteWrites.push({ $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.listitem", rkey, }); } } const createWrites = profiles .filter((profile) => !rkeysByDid.has(profile.did)) .map((profile) => this._listItemCreateWrite({ listUri, did: profile.did }), );
for (const chunk of batch(deleteWrites, 50)) { await this.api.applyWrites(chunk); } for (const chunk of batch(createWrites, 50)) { await this.api.applyWrites(chunk); }
const listRkey = parseUri(listUri).rkey; const listRecordData = await this.api.getListRecord(listRkey); const listRecord = { ...listRecordData.value, name: resolvedName }; delete listRecord.description; delete listRecord.descriptionFacets; if (resolvedDescription.description) { listRecord.description = resolvedDescription.description; if (resolvedDescription.descriptionFacets) { listRecord.descriptionFacets = resolvedDescription.descriptionFacets; } } await this.api.putListRecord(listRkey, listRecord, listRecordData.cid);
const packRkey = parseUri(starterPack.uri).rkey; const packRecordData = await this.api.getStarterPackRecord(packRkey); const packRecord = { ...packRecordData.value, name: resolvedName, updatedAt: getCurrentTimestamp(), }; delete packRecord.description; delete packRecord.descriptionFacets; delete packRecord.feeds; if (resolvedDescription.description) { packRecord.description = resolvedDescription.description; if (resolvedDescription.descriptionFacets) { packRecord.descriptionFacets = resolvedDescription.descriptionFacets; } } if (feeds.length > 0) { packRecord.feeds = feeds.map((feed) => ({ uri: feed.uri })); } const putRes = await this.api.putStarterPackRecord( packRkey, packRecord, packRecordData.cid, );
const createdRkeyByDid = new Map( createWrites.map((write) => [write.value.subject, write.rkey]), ); const items = [ ...profiles.map((profile) => ({ uri: this._listItemUri( rkeysByDid.get(profile.did)?.[0] ?? createdRkeyByDid.get(profile.did), ), subject: profile, })), ...optedOutItems, ]; const current = untrack(() => this.dataStore.$starterPacks.get(starterPack.uri)) ?? starterPack; const patchedList = { ...current.list, name: resolvedName, listItemCount: items.length, }; this.dataStore.$starterPacks.set(starterPack.uri, { ...current, cid: putRes?.cid ?? current.cid, record: packRecord, list: patchedList, listItemsSample: items.slice(0, 12), feeds, }); this.dataStore.$listMembers.set(listUri, { items, cursor: null }); const list = untrack(() => this.dataStore.$lists.get(listUri)); if (list) { this.dataStore.$lists.set(listUri, { ...list, name: resolvedName, description: resolvedDescription.description ?? "", descriptionFacets: resolvedDescription.descriptionFacets ?? [], listItemCount: items.length, }); } const actorStarterPacks = untrack(() => this.dataStore.$actorStarterPacks.get(currentUser.did), ); if (actorStarterPacks) { this.dataStore.$actorStarterPacks.set(currentUser.did, { ...actorStarterPacks, starterPacks: actorStarterPacks.starterPacks.map((entry) => entry.uri === starterPack.uri ? { ...entry, record: packRecord, listItemCount: items.length } : entry, ), }); } }
async deleteStarterPack(starterPack) { const { rkey } = parseUri(starterPack.uri); const listUri = starterPack.list?.uri ?? null; const writes = []; if (listUri) { const listItemRecords = await this._findListItemRecords(listUri); writes.push( ...listItemRecords.map((item) => ({ $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.listitem", rkey: item.rkey, })), { $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.list", rkey: parseUri(listUri).rkey, }, ); } writes.push({ $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.starterpack", rkey, }); for (const chunk of batch(writes, 50)) { await this.api.applyWrites(chunk); }
this.dataStore.$starterPacks.set(starterPack.uri, null); if (listUri) { this.dataStore.$starterPackUrisByList.set(listUri, null); this.dataStore.$lists.set(listUri, null); this.dataStore.$listMembers.set(listUri, null); } const creatorDid = starterPack.creator?.did ?? null; if (creatorDid) { const actorStarterPacks = untrack(() => this.dataStore.$actorStarterPacks.get(creatorDid), ); if (actorStarterPacks) { this.dataStore.$actorStarterPacks.set(creatorDid, { ...actorStarterPacks, starterPacks: actorStarterPacks.starterPacks.filter( (entry) => entry.uri !== starterPack.uri, ), }); } if (listUri) { const actorLists = untrack(() => this.dataStore.$actorLists.get(creatorDid), ); if (actorLists) { this.dataStore.$actorLists.set(creatorDid, { ...actorLists, lists: actorLists.lists.filter((entry) => entry.uri !== listUri), }); } } } const searchResults = untrack(() => this.dataStore.$starterPackSearchResults.get(), ); if (searchResults) { this.dataStore.$starterPackSearchResults.set({ ...searchResults, starterPacks: searchResults.starterPacks.filter( (entry) => entry.uri !== starterPack.uri, ), }); } const listDelta = listUri ? 1 : 0; this._updateCurrentUserProfile((profile) => profile.associated ? { ...profile, associated: { ...profile.associated, starterPacks: Math.max( 0, (profile.associated.starterPacks ?? 0) - 1, ), lists: Math.max(0, (profile.associated.lists ?? 0) - listDelta), }, } : profile, ); }
// A profile lives in $profiles and, once opened, $detailedProfiles; the // current user's is also mirrored in $currentUser. Apply a change to each _updateStoredProfile(profile, update) { const { did } = profile; const stored = untrack(() => this.dataStore.$profiles.get(did)) ?? profile; this.dataStore.$profiles.set(did, update(stored)); const detailed = untrack(() => this.dataStore.$detailedProfiles.get(did)); if (detailed) { this.dataStore.$detailedProfiles.set(did, update(detailed)); } const currentUser = untrack(() => this.dataStore.$currentUser.get()); if (currentUser?.did === did) { this.dataStore.$currentUser.set(update(currentUser)); } }
_updateCurrentUserProfile(update) { const currentUser = untrack(() => this.dataStore.$currentUser.get()); if (!currentUser) return; this._updateStoredProfile(currentUser, update); }
async pinPost(post) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) throw new Error("No current user"); const authorFeedURI = `${currentUser.did}-posts`; const pinnedRef = { uri: post.uri, cid: post.cid };
// Optimistic update via patches on currentUser and author feed const userPatchId = this.patchStore.addCurrentUserPatch({ type: "setPinnedPost", pinnedPost: pinnedRef, }); const feedPatchId = this.patchStore.addAuthorFeedPatch(authorFeedURI, { type: "pinPost", post, });
try { const recordData = await this.api.getProfileRecord(); const existingRecord = recordData.value || {}; const swapCid = recordData.cid; await this.api.putProfileRecord( { ...existingRecord, pinnedPost: pinnedRef }, swapCid, ); // Commit to dataStore this._updateCurrentUserProfile((user) => ({ ...user, pinnedPost: pinnedRef, })); const existingFeed = this.dataStore.$authorFeeds.get(authorFeedURI); if (existingFeed) { this.dataStore.$authorFeeds.set(authorFeedURI, { feed: pinPostInFeed(existingFeed.feed, post), cursor: existingFeed.cursor, }); } } finally { this.patchStore.removeCurrentUserPatch(userPatchId); this.patchStore.removeAuthorFeedPatch(authorFeedURI, feedPatchId); } }
async unpinPost(post) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) throw new Error("No current user"); if (currentUser.pinnedPost?.uri !== post.uri) { // Already unpinned (or a different post is pinned); nothing to do. return; } const authorFeedURI = `${currentUser.did}-posts`;
const userPatchId = this.patchStore.addCurrentUserPatch({ type: "clearPinnedPost", }); const feedPatchId = this.patchStore.addAuthorFeedPatch(authorFeedURI, { type: "unpinPost", post, });
try { const recordData = await this.api.getProfileRecord(); const existingRecord = recordData.value || {}; const swapCid = recordData.cid; const { pinnedPost: _, ...updatedRecord } = existingRecord; await this.api.putProfileRecord(updatedRecord, swapCid); // Commit to dataStore this._updateCurrentUserProfile(({ pinnedPost: _, ...rest }) => rest); const existingFeed = this.dataStore.$authorFeeds.get(authorFeedURI); if (existingFeed) { this.dataStore.$authorFeeds.set(authorFeedURI, { feed: unpinPostInFeed(existingFeed.feed, post), cursor: existingFeed.cursor, }); } } finally { this.patchStore.removeCurrentUserPatch(userPatchId); this.patchStore.removeAuthorFeedPatch(authorFeedURI, feedPatchId); } }
async updatePostgateEmbeddingRules(post, embeddingRules) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) throw new Error("No current user"); const { repo, rkey } = parseUri(post.uri); if (currentUser.did !== repo) { throw new Error("Only the post author can edit the postgate"); } const embeddingDisabled = hasDisableEmbeddingRule(embeddingRules); const hasEmbeddingRules = embeddingRules?.length > 0;
const patchId = this.patchStore.addPostPatch(post.uri, { type: "setEmbeddingDisabled", embeddingDisabled, }); try { let priorPostgateRecord = null; try { priorPostgateRecord = await this.api.getPostgateRecord(rkey); } catch (error) { if (!isRecordNotFoundError(error)) throw error; } const doUpdate = priorPostgateRecord || hasEmbeddingRules; if (doUpdate) { const record = { ...priorPostgateRecord?.value, $type: "app.bsky.feed.postgate", post: post.uri, createdAt: getCurrentTimestamp(), }; if (hasEmbeddingRules) { record.embeddingRules = embeddingRules; } else { delete record.embeddingRules; } await this.api.putPostgateRecord( rkey, record, priorPostgateRecord?.cid ?? null, ); }
const latestPost = this.dataStore.$posts.get(post.uri); if (latestPost) { this.dataStore.$posts.set(post.uri, { ...latestPost, viewer: { ...latestPost.viewer, embeddingDisabled }, }); } } finally { this.patchStore.removePostPatch(post.uri, patchId); } }
async toggleQuoteDetachment(quotingPost, quotedPostUri, detach) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) throw new Error("No current user"); const { repo, rkey } = parseUri(quotedPostUri); if (currentUser.did !== repo) { throw new Error("Only the quoted post's author can detach quotes"); }
let quotedPost = detach ? createDetachedQuote(quotedPostUri) : this._getCachedQuoteEmbed(quotedPostUri); const patchId = quotedPost ? this.patchStore.addPostPatch(quotingPost.uri, { type: "setQuotedPost", quotedPost, }) : null; try { let priorPostgateRecord = null; try { priorPostgateRecord = await this.api.getPostgateRecord(rkey); } catch (error) { if (!isRecordNotFoundError(error)) throw error; } const priorUris = priorPostgateRecord?.value?.detachedEmbeddingUris ?? []; if (priorPostgateRecord || detach) { let detachedEmbeddingUris; if (detach) { if ( !priorUris.includes(quotingPost.uri) && priorUris.length >= MAX_DETACHED_EMBEDDING_URIS ) { throw new DetachedQuoteLimitError(); } detachedEmbeddingUris = [ ...priorUris.filter((uri) => uri !== quotingPost.uri), quotingPost.uri, ]; } else { detachedEmbeddingUris = priorUris.filter( (uri) => uri !== quotingPost.uri, ); } await this.api.putPostgateRecord( rkey, { ...priorPostgateRecord?.value, $type: "app.bsky.feed.postgate", post: quotedPostUri, createdAt: getCurrentTimestamp(), detachedEmbeddingUris, }, priorPostgateRecord?.cid ?? null, ); }
if (!quotedPost) { try { quotedPost = createEmbedFromPost( await this.api.getPost(quotedPostUri), ); } catch (error) { console.warn("Failed to load re-attached quote", error); } } const latestPost = this.dataStore.$posts.get(quotingPost.uri); if (latestPost && quotedPost) { this.dataStore.$posts.set( quotingPost.uri, replaceQuotedPost(latestPost, quotedPost), ); } } finally { if (patchId !== null) { this.patchStore.removePostPatch(quotingPost.uri, patchId); } } }
_getCachedQuoteEmbed(postUri) { const post = untrack( () => this.dataStore.$posts.get(postUri) ?? this.dataStore.$embeddedPosts.get(postUri), ); return post ? createEmbedFromPost(post) : null; }
async updateThreadgateHiddenReply(reply, hide) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) throw new Error("No current user"); const rootUri = reply.record?.reply?.root?.uri; if (!rootUri) throw new Error("Post is not a reply"); const { repo, rkey } = parseUri(rootUri); if (currentUser.did !== repo) { throw new Error("Only the thread author can hide replies"); } if (reply.author?.did === currentUser.did) { throw new Error("Can't hide your own reply"); }
const patchId = this.patchStore.addPostPatch(rootUri, { type: "setThreadgateHiddenReply", replyUri: reply.uri, hidden: hide, }); try { let priorThreadgateRecord = null; try { priorThreadgateRecord = await this.api.getThreadgateRecord(rkey); } catch (error) { if (!isRecordNotFoundError(error)) throw error; } if (!priorThreadgateRecord && !hide) return; const priorUris = priorThreadgateRecord?.value?.hiddenReplies ?? []; const otherUris = priorUris.filter((uri) => uri !== reply.uri); if (hide && otherUris.length >= MAX_HIDDEN_REPLIES) { throw new HiddenReplyLimitError(); } const record = { ...priorThreadgateRecord?.value, $type: "app.bsky.feed.threadgate", post: rootUri, createdAt: getCurrentTimestamp(), hiddenReplies: hide ? [...otherUris, reply.uri] : otherUris, }; const result = await this.api.putThreadgateRecord( rkey, record, priorThreadgateRecord?.cid ?? null, );
const latestRootPost = this.dataStore.$posts.get(rootUri); if (latestRootPost) { this.dataStore.$posts.set(rootUri, { ...latestRootPost, threadgate: { uri: buildUri({ repo, collection: "app.bsky.feed.threadgate", rkey, }), lists: [], ...latestRootPost.threadgate, cid: result.cid, record, }, }); } } finally { this.patchStore.removePostPatch(rootUri, patchId); } }
async updateThreadgateAllow(post, threadgateAllow) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) throw new Error("No current user"); const { repo, rkey } = parseUri(post.uri); if (currentUser.did !== repo) { throw new Error("Only the thread author can edit the threadgate"); }
const patchId = this.patchStore.addPostPatch(post.uri, { type: "setThreadgateAllow", allow: threadgateAllow, }); try { let priorThreadgateRecord = null; try { priorThreadgateRecord = await this.api.getThreadgateRecord(rkey); } catch (error) { if (!isRecordNotFoundError(error)) throw error; } const record = { ...priorThreadgateRecord?.value, $type: "app.bsky.feed.threadgate", post: post.uri, createdAt: getCurrentTimestamp(), }; if (threadgateAllow === null) { delete record.allow; } else { record.allow = threadgateAllow; } const result = await this.api.putThreadgateRecord( rkey, record, priorThreadgateRecord?.cid ?? null, ); const written = { record, cid: result.cid };
const latestPost = this.dataStore.$posts.get(post.uri); if (latestPost) { this.dataStore.$posts.set(post.uri, { ...latestPost, threadgate: { uri: buildUri({ repo, collection: "app.bsky.feed.threadgate", rkey, }), lists: [], ...latestPost.threadgate, cid: written.cid, record: written.record, }, }); } } finally { this.patchStore.removePostPatch(post.uri, patchId); } }
async createThread({ posts, replyTo, replyRoot, langs, threadgateAllow, postgateEmbeddingRules, signal = null, }) { const { uris, posts: hydratedPosts } = await this.postCreator.createThread({ posts, replyTo, replyRoot, langs, threadgateAllow, postgateEmbeddingRules, signal, }); if (hydratedPosts) { for (const post of hydratedPosts) { // NOTE: LEXICON DEVIATION post.viewer.priorityReply = true; } this.dataStore.setPosts(hydratedPosts); const replyToPost = replyTo ? this.dataStore.$posts.get(replyTo.uri) : null; if (!isNil(replyToPost?.replyCount)) { this.dataStore.$posts.set(replyTo.uri, { ...replyToPost, replyCount: replyToPost.replyCount + 1, }); } const rootPost = hydratedPosts[0]; // If it's a reply, add it under the replied-to post in every cached thread that contains it if (replyTo) { const newReply = createNestedThreadViewPost(hydratedPosts); for (const [uri, postThread] of [ ...this.dataStore.$postThreads.entries(), ]) { const updatedPostThread = insertReplyIntoThread( postThread, replyTo.uri, newReply, ); if (updatedPostThread !== postThread) { this.dataStore.setPostThread(uri, updatedPostThread); } } } const { repo: did } = parseUri(rootPost.uri); const rootFeedURI = replyTo ? `${did}-replies` : `${did}-posts`; const rootFeed = this.dataStore.$authorFeeds.get(rootFeedURI); if (rootFeed) { this.dataStore.$authorFeeds.set(rootFeedURI, { feed: addFeedItemToFeed({ post: rootPost }, rootFeed.feed), cursor: rootFeed.cursor, }); } // Later thread posts are self-replies, so they go in the replies tab const repliesFeedURI = `${did}-replies`; for (const post of hydratedPosts.slice(1)) { const repliesFeed = this.dataStore.$authorFeeds.get(repliesFeedURI); if (repliesFeed) { this.dataStore.$authorFeeds.set(repliesFeedURI, { feed: addFeedItemToFeed({ post }, repliesFeed.feed), cursor: repliesFeed.cursor, }); } } } return { uris, posts: hydratedPosts }; }
async deletePost(post) { // no optimistic update await this.api.deletePost(post); const parentUri = post.record.reply?.parent.uri; const parentPost = parentUri ? this.dataStore.$posts.get(parentUri) : null; if (!isNil(parentPost?.replyCount)) { this.dataStore.$posts.set(parentUri, { ...parentPost, replyCount: Math.max(0, parentPost.replyCount - 1), }); } // Replace the post with a not found post. // This *should* remove the post from all relevant places in the UI. this.dataStore.$posts.set(post.uri, createNotFoundPost(post.uri)); }
async createMessage(convoId, { text, facets, replyTo, embed }) { // no optimistic update const res = await this.api.sendMessage(convoId, { text, facets, replyTo, embed, }); this.dataStore.$messages.set(res.id, res); // Add the new message to the chat messages array in the dataStore const convoMessages = this.dataStore.$convoMessages.get(convoId); if (convoMessages) { this.dataStore.$convoMessages.set(convoId, { messages: [res, ...convoMessages.messages], cursor: convoMessages.cursor, }); } // Update the last message in the convo const convo = this.dataStore.$convos.get(convoId); if (convo) { this.dataStore.$convos.set(convoId, { ...convo, lastMessage: { $type: "chat.bsky.convo.defs#messageView", ...res, }, }); } return res; }
async createGroupChat(name, memberDids) { const res = await this.api.createGroupChat(name, memberDids); this.dataStore.setConvo(res.convo); return res.convo; }
async requestJoinGroupChat(code) { const res = await this.api.requestJoinGroupChat(code); const preview = this.dataStore.$joinLinkPreviewsByCode.get(code); if ( preview?.$type === "chat.bsky.group.defs#joinLinkPreviewView" && preview.code === code ) { const updatedPreview = { ...preview }; if (res.status === "joined" && res.convo) { updatedPreview.convo = res.convo; } else { updatedPreview.viewer = { ...(preview.viewer ?? {}), requestedAt: getCurrentTimestamp(), }; } this.dataStore.$joinLinkPreviewsByCode.set(code, updatedPreview); } if (res.status === "joined" && res.convo) { this.dataStore.setConvo(res.convo); } return res; }
async acceptConvo(convo) { await this.api.acceptConvo(convo.id);
// Create updated convo with accepted status const updatedConvo = { ...convo, status: "accepted", };
this.dataStore.setConvo(updatedConvo);
return updatedConvo; }
async leaveConvo(convo) { const convoId = convo.id; await this.api.leaveConvo(convoId); this.dataStore.$convos.set(convoId, null); const list = this.dataStore.$convoList.get(); if (list) { this.dataStore.$convoList.set({ convos: list.convos.filter((listConvo) => listConvo.id !== convoId), cursor: list.cursor, }); } }
async rejectConvo(convo) { const convoId = convo.id; await this.api.leaveConvo(convoId); this.dataStore.$convos.set(convoId, null); const requestList = this.dataStore.$convoRequestList.get(); if (requestList) { this.dataStore.$convoRequestList.set({ convos: requestList.convos.filter( (listConvo) => listConvo.id !== convoId, ), cursor: requestList.cursor, }); } }
async setConvoMuted(convo, muted) { const convoId = convo.id; const patchId = this.patchStore.addConvoPatch(convoId, { type: "setConvoMuted", muted, }); try { if (muted) { await this.api.muteConvo(convoId); } else { await this.api.unmuteConvo(convoId); } const latest = this.dataStore.$convos.get(convoId); if (latest) { this.dataStore.$convos.set(convoId, { ...latest, muted }); } } finally { this.patchStore.removeConvoPatch(convoId, patchId); } }
async setThreadMuted(post, muted) { const rootUri = getReplyRootFromPost(post).uri; const patchId = this.patchStore.addThreadMutePatch(rootUri, { type: "setThreadMuted", muted, }); try { if (muted) { await this.api.muteThread(rootUri); } else { await this.api.unmuteThread(rootUri); } this._updatePostsByThreadRoot(rootUri, (stored) => ({ ...stored, viewer: { ...stored.viewer, threadMuted: muted }, })); } finally { this.patchStore.removeThreadMutePatch(rootUri, patchId); } }
async markConvoAsRead(convoId) { await this.api.markConvoAsRead(convoId); const latest = this.dataStore.$convos.get(convoId); if (latest) { this.dataStore.$convos.set(convoId, { ...latest, unreadCount: 0, }); } }
async addMessageReaction(convoId, messageId, emoji) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) throw new Error("No current user"); const patchId = this.patchStore.addMessagePatch(messageId, { type: "addReaction", reaction: { createdAt: getCurrentTimestamp(), sender: { did: currentUser.did }, value: emoji, }, }); try { const message = await this.api.addMessageReaction( convoId, messageId, emoji, ); this.dataStore.$messages.set(messageId, message); // Update the last reaction in the convo const convo = this.dataStore.$convos.get(convoId); if (convo) { this.dataStore.$convos.set(convoId, { ...convo, lastReaction: { $type: "chat.bsky.convo.defs#messageAndReactionView", message: message, reaction: message.reactions[0], }, }); } } catch (error) { console.error(error); throw error; } finally { this.patchStore.removeMessagePatch(messageId, patchId); } }
async removeMessageReaction(convoId, messageId, emoji) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) throw new Error("No current user"); const patchId = this.patchStore.addMessagePatch(messageId, { type: "removeReaction", currentUserDid: currentUser.did, value: emoji, }); try { const message = await this.api.removeMessageReaction( convoId, messageId, emoji, ); this.dataStore.$messages.set(messageId, message); // Update the last reaction in the convo const convo = this.dataStore.$convos.get(convoId); if (convo) { this.dataStore.$convos.set(convoId, { ...convo, lastReaction: null, }); } } catch (error) { console.error(error); throw error; } finally { this.patchStore.removeMessagePatch(messageId, patchId); } }
async createDraft({ draft, media }) { const res = await this.api.createDraft(draft); await this._saveDraftMedia(media); this._invalidateCachedDrafts(); return res.id; }
async updateDraft({ draftId, draft, media, pruneLocalRefs }) { await this.api.updateDraft(draftId, draft); await this._saveDraftMedia(media); await this._deleteDraftMedia(pruneLocalRefs); this._invalidateCachedDrafts(); }
async _saveDraftMedia(media) { const storedMedia = this.draftMediaStore.$media.get(); for (const { path, source } of media) { if (storedMedia[path]) continue; try { await this.draftMediaStore.save(path, source); } catch (error) { console.error("Failed to save draft media locally", error); } } }
async _deleteDraftMedia(localRefs) { for (const key of localRefs) { try { await this.draftMediaStore.delete(key); } catch (error) { console.error("Failed to delete draft media", error); } } }
// Delete the cached drafts list so the next dialog open refetches it _invalidateCachedDrafts() { if (untrack(() => this.dataStore.$drafts.get()) !== null) { this.dataStore.$drafts.set(null); } }
async deleteDraft({ draftId, localRefs }) { await this.api.deleteDraft(draftId); await this._deleteDraftMedia(localRefs); const data = untrack(() => this.dataStore.$drafts.get()); if (data) { this.dataStore.$drafts.set({ ...data, drafts: data.drafts.filter((draftView) => draftView.id !== draftId), }); } }
_updatePostsByThreadRoot(rootUri, updateFunc) { for (const post of this.dataStore.$posts.values()) { if (post?.viewer && getReplyRootFromPost(post).uri === rootUri) { this.dataStore.$posts.set(post.uri, updateFunc(post)); } } }}