Something went wrong. Try again.
Mirror of https://github.com/improsocial/improAn extensible Bluesky client for web
Something went wrong. Try again.
JavaScript
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288import { parseUri, createNotFoundPost, addFeedItemToFeed, pinPostInFeed, unpinPostInFeed, valueForPinnedItem,} from "/js/dataHelpers.js";import { getCurrentTimestamp } from "/js/utils.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); // update post in store this.dataStore.$posts.set(post.uri, { ...post, viewer: { ...post.viewer, like: like.uri }, likeCount: post.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) { 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); // update post in store this.dataStore.$posts.set(post.uri, { ...post, viewer: { ...post.viewer, like: null }, likeCount: post.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); this.dataStore.$posts.set(post.uri, { ...post, viewer: { ...post.viewer, repost: repost.uri }, repostCount: post.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) { 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); this.dataStore.$posts.set(post.uri, { ...post, viewer: { ...post.viewer, repost: null }, repostCount: post.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); // update post in store this.dataStore.$posts.set(post.uri, { ...post, viewer: { ...post.viewer, bookmarked: true }, bookmarkCount: post.bookmarkCount + 1, }); // If the bookmarks feed is loaded, add the post to it. const bookmarks = this.dataStore.$bookmarks.get(); if (bookmarks) { 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); // update post in store this.dataStore.$posts.set(post.uri, { ...post, viewer: { ...post.viewer, bookmarked: false }, bookmarkCount: post.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); this.dataStore.$profiles.set(profile.did, { ...profile, viewer: { ...profile.viewer, following: follow.uri }, }); const detailed = this.dataStore.$detailedProfiles.get(profile.did); if (detailed) { 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); this.dataStore.$profiles.set(profile.did, { ...profile, viewer: { ...profile.viewer, following: null }, }); const detailed = this.dataStore.$detailedProfiles.get(profile.did); if (detailed) { 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, feedContext, feedProxyUrl) { const showLessInteraction = { item: postURI, event: "app.bsky.feed.defs#requestLess", ...(feedContext ? { feedContext } : {}), }; this.dataStore.$showLessInteractions.set([ ...this.dataStore.$showLessInteractions.get(), showLessInteraction, ]); try { await this.api.sendInteractions([showLessInteraction], feedProxyUrl); } catch (error) { console.error(error); throw error; } }
async sendShowMoreInteraction(postURI, feedContext, feedProxyUrl) { const showMoreInteraction = { item: postURI, event: "app.bsky.feed.defs#requestMore", ...(feedContext ? { 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([ ...this.dataStore.$showMoreInteractions.get(), showMoreInteraction, ]); 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 = 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 = 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 = 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 = 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 = 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.$pinnedItems.set(next); } }
async hidePost(post) { const patchId = this.patchStore.addPostPatch(post.uri, { type: "hidePost", }); const preferences = 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 addMutedWord({ value, targets, actorTarget, expiresAt }) { const preferences = this.preferencesProvider.requirePreferences(); const newPreferences = preferences.addMutedWord({ value, targets, actorTarget, expiresAt, }); await this.preferencesProvider.updatePreferences(newPreferences); }
async removeMutedWord(wordId) { const preferences = this.preferencesProvider.requirePreferences(); const newPreferences = preferences.removeMutedWord(wordId); await this.preferencesProvider.updatePreferences(newPreferences); }
async updateMutedWord(wordId, updatedFields) { const preferences = this.preferencesProvider.requirePreferences(); const newPreferences = preferences.updateMutedWord(wordId, updatedFields); await this.preferencesProvider.updatePreferences(newPreferences); }
async subscribeLabeler(profile, labelerInfo) { const patchId = this.patchStore.addPreferencePatch({ type: "subscribeLabeler", did: profile.did, labelerInfo, }); const preferences = 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 = 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 = 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.dataStore.$profiles.set(profile.did, { ...profile, viewer: { ...profile.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, viewer: { ...profile.viewer, muted: true }, }, ...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.dataStore.$profiles.set(profile.did, { ...profile, viewer: { ...profile.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); this.dataStore.$profiles.set(profile.did, { ...profile, viewer: { ...profile.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, viewer: { ...profile.viewer, blocking: block.uri }, }, ...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.dataStore.$profiles.set(profile.did, { ...profile, viewer: { ...profile.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); this.dataStore.$profiles.set(profile.did, { ...profile, viewer: { ...profile.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; } 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);
const preferences = this.preferencesProvider.requirePreferences(); const labelers = preferences.getLabelerDids(); // Fetch full profile to get updated image urls const updatedProfile = await this.api.getProfile(profile.did, { labelers }); this.dataStore.$profiles.set(updatedProfile.did, updatedProfile); this.dataStore.$detailedProfiles.set(updatedProfile.did, updatedProfile); const currentUser = this.dataStore.$currentUser.get(); if (currentUser && currentUser.did === updatedProfile.did) { this.dataStore.$currentUser.set({ ...currentUser, ...updatedProfile, }); } }
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 createThread({ posts, replyTo, replyRoot, threadgateAllow, postgateEmbeddingRules, }) { const { uris, posts: hydratedPosts } = await this.postCreator.createThread({ posts, replyTo, replyRoot, threadgateAllow, postgateEmbeddingRules, }); 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 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 rejectConvo(convo) { await this.api.leaveConvo(convo.id); this.dataStore.$convos.set(convo.id, null); const convoList = this.dataStore.$convoList.get(); if (convoList) { this.dataStore.$convoList.set({ convos: convoList.convos.filter( (listConvo) => listConvo.id !== convo.id, ), cursor: convoList.cursor, }); } const convoRequestList = this.dataStore.$convoRequestList.get(); if (convoRequestList) { this.dataStore.$convoRequestList.set({ convos: convoRequestList.convos.filter( (listConvo) => listConvo.id !== convo.id, ), cursor: convoRequestList.cursor, }); } }
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, currentUserDid) { const patchId = this.patchStore.addMessagePatch(messageId, { type: "addReaction", reaction: { createdAt: getCurrentTimestamp(), sender: { did: currentUserDid }, 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, currentUserDid) { const patchId = this.patchStore.addMessagePatch(messageId, { type: "removeReaction", currentUserDid, 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)); } } }}