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.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839import { isInvalidSwapError, isRecordNotFoundError } from "/js/api.js";import { parseUri, buildUri, hasDisableEmbeddingRule, createNotFoundPost, createStatusView, addFeedItemToFeed, pinPostInFeed, unpinPostInFeed, valueForPinnedItem, buildCdnUrl,} from "/js/dataHelpers.js";import { batch, getCurrentTimestamp } from "/js/utils.js";import { fetchAndCompressImage } from "/js/embedHelpers.js";import { PostCreator } from "/js/postCreator.js";import { untrack } from "/js/signals.js";
// Handles mutations to the data, making optimistic updates if needed.export class Mutations { constructor( api, dataStore, patchStore, preferencesProvider, identityResolver, draftMediaStore, ) { this.api = api; this.dataStore = dataStore; this.patchStore = patchStore; this.preferencesProvider = preferencesProvider; this.draftMediaStore = draftMediaStore; this.postCreator = new PostCreator(api, identityResolver); }
async addLike(post) { // Optimistic update const patchId = this.patchStore.addPostPatch(post.uri, { type: "addLike", }); try { const 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, }); } // If the current user's author feed is loaded, remove the repost from it. const currentUser = this.dataStore.$currentUser.get(); if (currentUser) { const authorFeedURI = `${currentUser.did}-posts`; const authorFeed = this.dataStore.$authorFeeds.get(authorFeedURI); if (authorFeed) { this.dataStore.$authorFeeds.set(authorFeedURI, { feed: authorFeed.feed.filter((feedItem) => { if ( feedItem.reason?.$type === "app.bsky.feed.defs#reasonRepost" && feedItem.reason?.uri === post.viewer.repost ) { return false; } return true; }), cursor: authorFeed.cursor, }); } } } 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 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); } }
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); } }
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); } }
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); } }
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.dataStore.$selectedFeedUri.set(feedUri); }
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 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); const latestProfile = this.dataStore.$profiles.get(profile.did) ?? profile; this.dataStore.$profiles.set(profile.did, { ...latestProfile, viewer: { ...latestProfile.viewer, muted: true }, }); const detailed = this.dataStore.$detailedProfiles.get(profile.did); if (detailed) { this.dataStore.$detailedProfiles.set(profile.did, { ...detailed, viewer: { ...detailed.viewer, muted: true }, }); } this._updatePostsByAuthor(profile.did, (post) => { return { ...post, author: { ...post.author, viewer: { ...post.author.viewer, muted: true }, }, }; }); 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); const latestProfile = this.dataStore.$profiles.get(profile.did) ?? profile; this.dataStore.$profiles.set(profile.did, { ...latestProfile, viewer: { ...latestProfile.viewer, muted: false }, }); const detailed = this.dataStore.$detailedProfiles.get(profile.did); if (detailed) { this.dataStore.$detailedProfiles.set(profile.did, { ...detailed, viewer: { ...detailed.viewer, muted: false }, }); } this._updatePostsByAuthor(profile.did, (post) => { return { ...post, author: { ...post.author, viewer: { ...post.author.viewer, muted: 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 blockProfile(profile) { const patchId = this.patchStore.addProfilePatch(profile.did, { type: "blockProfile", }); try { const block = await this.api.blockActor(profile); const latestProfile = this.dataStore.$profiles.get(profile.did) ?? profile; this.dataStore.$profiles.set(profile.did, { ...latestProfile, viewer: { ...latestProfile.viewer, blocking: block.uri }, }); const detailed = this.dataStore.$detailedProfiles.get(profile.did); if (detailed) { this.dataStore.$detailedProfiles.set(profile.did, { ...detailed, viewer: { ...detailed.viewer, blocking: block.uri }, }); } this._updatePostsByAuthor(profile.did, (post) => { return { ...post, author: { ...post.author, viewer: { ...post.author.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); const latestProfile = this.dataStore.$profiles.get(profile.did) ?? profile; this.dataStore.$profiles.set(profile.did, { ...latestProfile, viewer: { ...latestProfile.viewer, activitySubscription }, }); const detailed = this.dataStore.$detailedProfiles.get(profile.did); if (detailed) { this.dataStore.$detailedProfiles.set(profile.did, { ...detailed, viewer: { ...detailed.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); const latestProfile = this.dataStore.$profiles.get(profile.did) ?? profile; this.dataStore.$profiles.set(profile.did, { ...latestProfile, viewer: { ...latestProfile.viewer, blocking: null }, }); const detailed = this.dataStore.$detailedProfiles.get(profile.did); if (detailed) { this.dataStore.$detailedProfiles.set(profile.did, { ...detailed, viewer: { ...detailed.viewer, blocking: null }, }); } this._updatePostsByAuthor(profile.did, (post) => { return { ...post, author: { ...post.author, viewer: { ...post.author.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 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 = ""; }
const existingProfile = this.dataStore.$profiles.get(profile.did); if (existingProfile) { this.dataStore.$profiles.set(profile.did, { ...existingProfile, ...patch, }); } const existingDetailed = this.dataStore.$detailedProfiles.get(profile.did); if (existingDetailed) { this.dataStore.$detailedProfiles.set(profile.did, { ...existingDetailed, ...patch, }); } const currentUser = this.dataStore.$currentUser.get(); if (currentUser && currentUser.did === profile.did) { this.dataStore.$currentUser.set({ ...currentUser, ...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 fetchAndCompressImage(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); }
async deleteList(list) { const { rkey } = parseUri(list.uri); const listItemUris = []; 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 === list.uri) { listItemUris.push(record.uri); } } cursor = res.cursor; if (!cursor) { hitCap = false; break; } } if (hitCap) { console.warn( `deleteList: stopped scanning listitems after ${MAX_PAGES} pages`, ); } const writes = [ ...listItemUris.map((uri) => ({ $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.listitem", rkey: parseUri(uri).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, }); } } const pinnedItems = untrack(() => this.dataStore.$pinnedItems.get()); if (pinnedItems?.some((item) => item.data?.uri === list.uri)) { this.dataStore.setPinnedItems( pinnedItems.filter((item) => item.data?.uri !== 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 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 const latestUser = this.dataStore.$currentUser.get(); if (latestUser) { this.dataStore.$currentUser.set({ ...latestUser, 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 const latestUser = this.dataStore.$currentUser.get(); if (latestUser) { const { pinnedPost: _, ...rest } = latestUser; this.dataStore.$currentUser.set(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 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, threadgateAllow, postgateEmbeddingRules, signal = null, }) { const { uris, posts: hydratedPosts } = await this.postCreator.createThread({ posts, replyTo, replyRoot, threadgateAllow, postgateEmbeddingRules, signal, }); if (hydratedPosts) { for (const post of hydratedPosts) { // NOTE: LEXICON DEVIATION post.viewer.priorityReply = true; } this.dataStore.setPosts(hydratedPosts); const rootPost = hydratedPosts[0]; // If it's a reply, update the reply post thread in the store if (replyTo) { const replyPostThread = this.dataStore.$postThreads.get(replyTo.uri); if (replyPostThread) { this.dataStore.$postThreads.set(replyTo.uri, { ...replyPostThread, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: rootPost, replies: [], }, ...replyPostThread.replies, ], }); } } 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); // 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 markConvoAsRead(convoId) { const convo = untrack(() => this.dataStore.$convos.get(convoId)); if (!convo?.unreadCount) return; 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), }); } }
_updatePostsByAuthor(profileDid, updateFunc) { for (const post of this.dataStore.$posts.values()) { if (post?.author?.did === profileDid) { this.dataStore.$posts.set(post.uri, updateFunc(post)); } } }}