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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047import { filterFollowingFeed, filterAlgorithmicFeed, filterAuthorFeed, filterBookmarksFeed,} from "/js/feedFilters.js";import { createBlockedPost, createUnavailablePost, createEmbedFromPost, getBlockedQuote, getPostUriFromRepost, getQuotedPost, isBlockedPost, isBlockingUser, isEmptyPost, isPostView, getInteractionProfileDids, getLastInteractionTimestamp, isBlockedByViewer, isGroupConvo, markBlockedQuoteNotFound, replaceBlockedQuote, transformNestedQuotes, attachJoinLinkPreviewToEmbed, getJoinLinkCodeFromEmbed, isFollowingFeedUri, isStatusValid,} from "/js/dataHelpers.js";import { sortBy, KeyedScheduler } from "/js/utils.js";import { FOLLOWING_FEED_URI } from "/js/config.js";import { effect, Signal, SignalMap, ComputedMap, ReactiveStore, untrack,} from "/js/signals.js";
function applyMutedWords(post, preferences) { let result = post; if (preferences.postHasMutedWord(post)) { result = { ...result, viewer: { ...(result.viewer ?? {}), hasMutedWord: true }, }; } return transformNestedQuotes(result, (quotedPost) => { if (!preferences.quotedPostHasMutedWord(quotedPost)) return quotedPost; return { ...quotedPost, hasMutedWord: true }; });}
function applyIsHidden(post, preferences) { let result = post; if (preferences.isPostHidden(post.uri)) { result = { ...result, viewer: { ...(result.viewer ?? {}), isHidden: true }, }; } return transformNestedQuotes(result, (quotedPost) => { if (!preferences.isPostHidden(quotedPost.uri)) return quotedPost; return { ...quotedPost, isHidden: true }; });}
function applyLabelsToPost(post, preferences) { let result = post; const badgeLabels = preferences.getBadgeLabelsForPost(post); if (badgeLabels.length > 0) { result = { ...result, badgeLabels }; } const contentLabel = preferences.getContentLabel(post); if (contentLabel) { result = { ...result, contentLabel }; } const mediaLabel = preferences.getMediaLabel(post); if (mediaLabel) { result = { ...result, mediaLabel }; } const authorBlurLabel = preferences.getProfileBlurLabel(result.author); if (authorBlurLabel) { result = { ...result, author: { ...result.author, blurLabel: authorBlurLabel }, }; } return result;}
function applyLabels(post, preferences) { const result = applyLabelsToPost(post, preferences); return transformNestedQuotes(result, (quotedPost) => applyLabelsToPost(quotedPost, preferences), );}
// Match social-app: hide notifications from blocked / muted// users unless the viewer follows them (a follow overrides a mute).function shouldHideNotification(notification) { const viewer = notification.author?.viewer; if (!viewer) return false; if (viewer.blocking) return true; if (viewer.muted && !viewer.following) return true; return false;}
function filterBlockedReactions(reactions, memberProfiles) { return (reactions || []).filter((reaction) => { const profile = memberProfiles.find( (member) => member.did === reaction.sender.did, ); return !profile?.viewer?.blocking && !profile?.viewer?.blockedBy; });}
export class Derived extends ReactiveStore { constructor( dataStore, patchStore, preferencesProvider, hiddenFeedItemsStore, isAuthenticated, draftMediaStore, liveStatusScheduler = new KeyedScheduler(), ) { super("derived"); this.dataStore = dataStore; this.patchStore = patchStore; this.preferencesProvider = preferencesProvider; this.hiddenFeedItemsStore = hiddenFeedItemsStore; this.isAuthenticated = isAuthenticated; this.draftMediaStore = draftMediaStore; this.liveStatusScheduler = liveStatusScheduler; // did → increasing id, bumped when an actor's live status expires this.$liveStatusRecheckIds = new SignalMap(); this.$showLessInteractions = new ComputedMap( (feedUri) => this.dataStore.$showLessInteractions.get(feedUri) ?? [], ); this.$joinLinkPreviewsByCode = new ComputedMap( (code) => this.dataStore.$joinLinkPreviewsByCode.get(code) ?? null, ); this.$isFollowPending = new ComputedMap((did) => this.patchStore.hasPendingProfilePatch(did, [ "followProfile", "unfollowProfile", ]), ); this.$isBlockPending = new ComputedMap((did) => this.patchStore.hasPendingProfilePatch(did, [ "blockProfile", "unblockProfile", ]), ); this.$isMutePending = new ComputedMap((did) => this.patchStore.hasPendingProfilePatch(did, [ "muteProfile", "unmuteProfile", ]), ); this.$patchedPosts = new ComputedMap((uri) => { const post = this.dataStore.$posts.get(uri); if (!post) return null; const patches = this.patchStore.$postPatches.get(uri); if (!patches?.length) return post; return this.patchStore.applyPostPatches(post, patches); }); this.$patchedProfiles = new ComputedMap((did) => { const profile = this.dataStore.$profiles.get(did); if (!profile) return null; const patches = this.patchStore.$profilePatches.get(did); if (!patches?.length) return profile; return this.patchStore.applyProfilePatches(profile, patches); }); this.$patchedDetailedProfiles = new ComputedMap((did) => { const profile = this.dataStore.$detailedProfiles.get(did); if (!profile) return null; const patches = this.patchStore.$profilePatches.get(did); if (!patches?.length) return profile; return this.patchStore.applyProfilePatches(profile, patches); }); // { state: "none" | "active" | "inactive" } this.$actorLiveStatus = new ComputedMap((did) => { const statusView = this.dataStore.$profileStatuses.get(did); if (!statusView || typeof statusView !== "object") { this.liveStatusScheduler.cancel(did); return { state: "none" }; } const profile = this.$patchedDetailedProfiles.get(did) ?? this.$patchedProfiles.get(did); const viewer = profile?.viewer; const preferences = this.$preferences.get(); if ( viewer?.blocking || viewer?.blockedBy || viewer?.muted || (profile && preferences && preferences.getProfileBlurLabel(profile)) ) { this.liveStatusScheduler.cancel(did); return { state: "none" }; } if (isStatusValid(statusView)) { // Subscribe to expiry rechecks this.$liveStatusRecheckIds.get(did); const delayMs = Date.parse(statusView.expiresAt) - Date.now(); if (Number.isFinite(delayMs) && delayMs > 0) { // Schedule expiry recheck - note this is a side effect, so be careful with this logic! this.liveStatusScheduler.schedule(did, delayMs, () => { this.$liveStatusRecheckIds.set( did, (this.$liveStatusRecheckIds.get(did) ?? 0) + 1, ); }); if (!statusView.isDisabled) { return { state: "active", uri: statusView.uri, cid: statusView.cid ?? null, embed: statusView.embed, expiresAt: statusView.expiresAt, record: statusView.record ?? null, }; } } } return { state: "inactive", isDisabled: !!statusView.isDisabled, uri: statusView.uri ?? null, cid: statusView.cid ?? null, record: statusView.record ?? null, expiresAt: statusView.expiresAt ?? null, }; }); this.$isActorLive = new ComputedMap( (did) => this.$actorLiveStatus.get(did).state === "active", ); this.$patchedMessages = new ComputedMap((messageId) => { const message = this.dataStore.$messages.get(messageId); if (!message) return null; const patches = this.patchStore.$messagePatches.get(messageId); if (!patches?.length) return message; return this.patchStore.applyMessagePatches(message, patches); }); this.$patchedConvos = new ComputedMap((convoId) => { const convo = this.dataStore.$convos.get(convoId); if (!convo) return null; const patches = this.patchStore.$convoPatches.get(convoId); if (!patches?.length) return convo; return this.patchStore.applyConvoPatches(convo, patches); }); this.$hydratedPosts = new ComputedMap((uri) => { const post = this.$patchedPosts.get(uri); const preferences = this.$preferences.get(); return this.hydratePost(post, preferences); }); this.$hydratedEmbeddedPosts = new ComputedMap((uri) => { const post = this.dataStore.$embeddedPosts.get(uri); const preferences = this.$preferences.get(); return this.hydratePost(post, preferences); }); this.$hydratedFeeds = new ComputedMap((feedURI) => { const feed = this.dataStore.$feeds.get(feedURI); if (!feed) { return null; } const hydratedFeedItems = []; for (const feedItem of feed.feed) { const hydratedFeedItem = { feedContext: feedItem.feedContext, post: this.$hydratedPosts.get(feedItem.post.uri), }; if (feedItem.reason) { hydratedFeedItem.reason = feedItem.reason; } const reply = feedItem.reply; if (reply) { let root = reply.root; if (isPostView(root)) { root = this.$hydratedPosts.get(root.uri); } let parent = reply.parent; if (isPostView(parent)) { parent = this.$hydratedPosts.get(parent.uri); } hydratedFeedItem.reply = { ...reply, root, parent }; } hydratedFeedItems.push(hydratedFeedItem); } const hydratedFeed = { feed: hydratedFeedItems, cursor: feed.cursor, }; const pluginFilteredFeedItems = this.hiddenFeedItemsStore.$items.get(feedURI) ?? {}; if (isFollowingFeedUri(feedURI)) { const currentUser = this.$currentUser.get(); const preferences = this.$preferences.get(); return filterFollowingFeed( hydratedFeed, currentUser, preferences, pluginFilteredFeedItems, ); } else { return filterAlgorithmicFeed( hydratedFeed, this.isAuthenticated, pluginFilteredFeedItems, ); } }); this.$currentUser = new Signal.Computed(() => { const user = this.dataStore.$currentUser.get(); const patches = this.patchStore.$currentUserPatches.get(); return this.patchStore.applyCurrentUserPatches(user, patches); }); this.$preferences = new Signal.Computed(() => { const preferences = this.preferencesProvider.$preferences.get(); if (!preferences) return null; const patches = this.patchStore.$preferencePatches.get(); return this.patchStore.applyPreferencePatches(preferences, patches); }); this.$notifications = new Signal.Computed(() => { const data = this.dataStore.$notifications.get(); if (!data) return null; return data.notifications .map((notification) => this.hydrateNotification(notification)) .filter((notification) => !shouldHideNotification(notification)); }); this.$mentionNotifications = new Signal.Computed(() => { const data = this.dataStore.$mentionNotifications.get(); if (!data) return null; return data.notifications .map((notification) => this.hydrateNotification(notification)) .filter((notification) => !shouldHideNotification(notification)); }); this.$hydratedPostThreads = new ComputedMap((postURI) => { const postThread = this.dataStore.$postThreads.get(postURI); const postThreadOther = this.dataStore.$postThreadOthers.get(postURI); if (!postThread || !postThreadOther) { return null; } if (isEmptyPost(postThread)) { return postThread; } const hiddenReplyUris = new Set(postThreadOther.map((item) => item.uri)); const hydrated = this.hydratePostThreadNode(postThread, hiddenReplyUris); if (!hydrated) { return null; } if (postThread.parent) { hydrated.parent = this.hydratePostThreadParent(postThread.parent); } return hydrated; }); this.$hydratedHashtagFeeds = new ComputedMap((hashtagKey) => { const data = this.dataStore.$hashtagFeeds.get(hashtagKey); if (!data) { return null; } const hydratedFeedItems = []; for (const searchPost of data.posts) { const post = this.$hydratedPosts.get(searchPost.uri); if (!post) continue; hydratedFeedItems.push({ post: this.attachParentAuthor(post), }); } return { feed: hydratedFeedItems, cursor: data.cursor, }; }); this.$feedGenerators = new ComputedMap((feedUri) => this.dataStore.$feedGenerators.get(feedUri), ); this.$lists = new ComputedMap((listUri) => this.dataStore.$lists.get(listUri), ); this.$starterPacks = new ComputedMap((starterPackUri) => this.dataStore.$starterPacks.get(starterPackUri), ); this.$listMembers = new ComputedMap((listUri) => { const data = this.dataStore.$listMembers.get(listUri); if (!data) return data; return { members: data.items.map((item) => this.$hydratedProfiles.get(item.subject.did), ), cursor: data.cursor, }; }); this.$profileSearchResults = new Signal.Computed(() => { const data = this.dataStore.$profileSearchResults.get(); if (!data) return null; return data.actors.map((actor) => this.$hydratedProfiles.get(actor.did)); }); this.$profileSearchCursor = new Signal.Computed( () => this.dataStore.$profileSearchResults.get()?.cursor ?? null, ); this.$chatRecipientSearchResults = new Signal.Computed(() => { const data = this.dataStore.$chatRecipientSearchResults.get(); if (!data) return null; return data.actors.map((actor) => this.$hydratedProfiles.get(actor.did)); }); this.$searchTypeaheadResults = new Signal.Computed(() => { const data = this.dataStore.$searchTypeaheadResults.get(); if (!data) return null; return data.actors.map((actor) => this.$hydratedProfiles.get(actor.did)); }); this.$sidebarSearchTypeaheadResults = new Signal.Computed(() => { const data = this.dataStore.$sidebarSearchTypeaheadResults.get(); if (!data) return null; return data.actors.map((actor) => this.$hydratedProfiles.get(actor.did)); }); this.$recentSearchTerms = new Signal.Computed(() => { const preferences = this.$preferences.get(); if (!preferences) return []; return preferences.getRecentSearches(); }); this.$recentSearchProfiles = new Signal.Computed(() => { const preferences = this.$preferences.get(); if (!preferences) return null; return preferences.getRecentSearchProfiles().map((did) => ({ did, profile: this.$hydratedDetailedProfiles.get(did) ?? this.$hydratedProfiles.get(did) ?? null, })); }); this.$feedSearchResults = new Signal.Computed(() => { const data = this.dataStore.$feedSearchResults.get(); if (!data) return null; return data.feeds; }); this.$feedSearchCursor = new Signal.Computed( () => this.dataStore.$feedSearchResults.get()?.cursor ?? null, ); this.$gifResults = new Signal.Computed( () => this.dataStore.$gifResults.get()?.gifs ?? null, ); this.$gifCursor = new Signal.Computed( () => this.dataStore.$gifResults.get()?.cursor ?? null, ); this.$recentGifs = new Signal.Computed(() => { const preferences = this.$preferences.get(); if (!preferences) return []; return preferences.getRecentGifs(); }); this.$trends = new Signal.Computed(() => this.dataStore.$trends.get()); this.$chatActorStatus = new Signal.Computed(() => this.dataStore.$chatActorStatus.get(), ); this.$selectedFeedUri = new Signal.Computed(() => this.dataStore.$selectedFeedUri.get(), ); this.$postSearchResultsTop = new Signal.Computed(() => this.hydratePostSearchResults(this.dataStore.$postSearchResultsTop), ); this.$postSearchResultsLatest = new Signal.Computed(() => this.hydratePostSearchResults(this.dataStore.$postSearchResultsLatest), ); this.$postSearchCursorTop = new Signal.Computed( () => this.dataStore.$postSearchResultsTop.get()?.cursor ?? null, ); this.$postSearchCursorLatest = new Signal.Computed( () => this.dataStore.$postSearchResultsLatest.get()?.cursor ?? null, ); this.$hydratedPostQuotes = new ComputedMap((postUri) => { const quotes = this.dataStore.$postQuotes.get(postUri); if (!quotes) { return null; } const hydratedPosts = []; for (const quote of quotes.posts) { const post = this.$hydratedPosts.get(quote.uri); if (!post) continue; hydratedPosts.push(this.attachParentAuthor(post)); } return { posts: hydratedPosts, cursor: quotes.cursor, }; }); this.$hydratedPinnedItems = new Signal.Computed(() => { const pinnedItems = this.dataStore.$pinnedItems.get(); if (!pinnedItems) return null; return pinnedItems.map((item) => { if (item.type === "timeline") { return { type: "timeline", data: item.data, uri: FOLLOWING_FEED_URI, displayName: "Following", }; } if (item.type === "list") { return { type: "list", data: item.data, uri: item.data.uri, displayName: item.data.name, }; } const feedGenerator = this.$feedGenerators.get(item.data.uri) ?? item.data; return { type: "feed", data: feedGenerator, ...feedGenerator, }; }); }); this.$hydratedProfiles = new ComputedMap((did) => { const profile = this.$patchedProfiles.get(did); if (!profile) return profile; return this.hydrateProfile(profile); }); this.$hydratedDetailedProfiles = new ComputedMap((did) => { const profile = this.$patchedDetailedProfiles.get(did); if (!profile) return null; return this.hydrateProfile(profile); }); this.$hydratedAuthorFeeds = new ComputedMap((feedURI) => { const rawFeed = this.dataStore.$authorFeeds.get(feedURI); if (!rawFeed) { return null; } const patches = this.patchStore.$authorFeedPatches.get(feedURI) || []; let feed = { feed: [...rawFeed.feed], cursor: rawFeed.cursor }; for (const patch of patches) { feed = this.patchStore.applyAuthorFeedPatch(feed, patch.body); } const hydratedFeedItems = []; for (const feedItem of feed.feed) { const hydratedFeedItem = { post: this.$hydratedPosts.get(feedItem.post.uri), }; if (feedItem.reason) { hydratedFeedItem.reason = feedItem.reason; } if (feedItem.reply) { let root = feedItem.reply.root; if (isPostView(root)) { root = this.$hydratedPosts.get(root.uri); } let parent = feedItem.reply.parent; if (isPostView(parent)) { parent = this.$hydratedPosts.get(parent.uri); } hydratedFeedItem.reply = { ...feedItem.reply, root, parent }; } hydratedFeedItems.push(hydratedFeedItem); } let hydratedFeed = { feed: hydratedFeedItems, cursor: feed.cursor, }; const dashIndex = feedURI.lastIndexOf("-"); const feedType = dashIndex >= 0 ? feedURI.slice(dashIndex + 1) : ""; if (feedType === "replies") { const filteredFeedItems = []; for (const feedItem of hydratedFeed.feed) { if (feedItem.reply && !feedItem.reason) { filteredFeedItems.push(feedItem); } } hydratedFeed = { feed: filteredFeedItems, cursor: hydratedFeed.cursor, }; } return filterAuthorFeed(hydratedFeed, this.isAuthenticated); }); this.$actorFeeds = new ComputedMap((did) => this.dataStore.$actorFeeds.get(did), ); this.$actorLists = new ComputedMap((did) => this.dataStore.$actorLists.get(did), ); this.$listsWithMembershipByActor = new ComputedMap((did) => this.dataStore.$listsWithMembershipByActor.get(did), ); this.$profileChatStatus = new ComputedMap((did) => this.dataStore.$profileChatStatus.get(did), ); this.$labelerInfo = new ComputedMap((did) => this.dataStore.$labelerInfo.get(did), ); this.$hydratedBookmarks = new Signal.Computed(() => { const data = this.dataStore.$bookmarks.get(); if (!data) { return null; } const hydratedFeed = []; for (const bookmark of data.bookmarks) { const post = this.$hydratedPosts.get(bookmark.item.uri); hydratedFeed.push({ post: this.attachParentAuthor(post) }); } return filterBookmarksFeed({ feed: hydratedFeed, cursor: data.cursor, }); }); this.$hydratedDrafts = new Signal.Computed(() => { const data = this.dataStore.$drafts.get(); if (!data) { return null; } const media = this.draftMediaStore.$media.get(); return { drafts: data.drafts.map((draftView) => ({ ...draftView, posts: (draftView.draft.posts ?? []).map((draftPost) => this.hydrateDraftPost(draftPost, media), ), })), cursor: data.cursor, }; }); this.$labelerSettings = new ComputedMap((labelerDid) => { const preferences = this.$preferences.get(); if (!preferences) return null; return preferences.getLabelerSettings(labelerDid); }); this.$convos = new ComputedMap((convoId) => { const convo = this.$patchedConvos.get(convoId); if (!convo) return convo; return { ...convo, members: convo.members.map((member) => this.hydrateProfile(member)), }; }); this.$convoList = new Signal.Computed(() => { const data = this.dataStore.$convoList.get(); if (!data) return null; const hydrated = data.convos.map((convo) => this.$convos.get(convo.id)); return sortBy( hydrated, (convo) => new Date(getLastInteractionTimestamp(convo)), { direction: "desc" }, ); }); this.$convoListCursor = new Signal.Computed( () => this.dataStore.$convoList.get()?.cursor ?? null, ); this.$convoRequestList = new Signal.Computed(() => { const data = this.dataStore.$convoRequestList.get(); if (!data) return null; const hydrated = data.convos .map((convo) => this.$convos.get(convo.id)) .filter(Boolean); return sortBy( hydrated, (convo) => new Date(getLastInteractionTimestamp(convo)), { direction: "desc" }, ); }); this.$convoRequestListCursor = new Signal.Computed( () => this.dataStore.$convoRequestList.get()?.cursor ?? null, ); // The convo's members plus the hydrated profiles its interactions // reference (group convo member lists are partial) this.$convoProfiles = new ComputedMap((convoId) => { const convo = this.dataStore.$convos.get(convoId); if (!convo) return []; const messages = this.dataStore.$convoMessages.get(convoId); const interactions = [ convo.lastMessage, convo.lastReaction, ...(messages?.messages ?? []), ]; const referencedDids = new Set( interactions.flatMap((interaction) => getInteractionProfileDids(interaction), ), ); const referencedProfiles = [...referencedDids] .filter((did) => !convo.members.some((member) => member.did === did)) .map((did) => this.$hydratedProfiles.get(did)) .filter(Boolean); const members = convo.members.map((member) => this.hydrateProfile(member), ); return [...members, ...referencedProfiles]; }); this.$convoForProfile = new ComputedMap((profileDid) => { const convoIds = [...this.dataStore.$convos.keys()]; for (const convoId of convoIds) { const convo = untrack(() => this.dataStore.$convos.get(convoId)); if (!convo) continue; if (isGroupConvo(convo)) continue; if ( convo.members.length === 2 && convo.members.some((member) => member.did === profileDid) ) { return this.$convos.get(convo.id); } } return null; }); this.$convoMembers = new ComputedMap((convoId) => { return this.dataStore.$convos.get(convoId)?.members ?? null; }); this.$groupConvoMemberList = new ComputedMap((convoId) => { return this.dataStore.$convoMemberLists.get(convoId); }); this.$convoMessages = new ComputedMap((convoId) => { const messages = this.dataStore.$convoMessages.get(convoId); if (!messages) return null; const members = this.$convoMembers.get(convoId) ?? []; return { messages: messages.messages.map((message) => { const patched = this.$patchedMessages.get(message.id); const hydrated = this.attachJoinLinkPreview(patched); if (!hydrated.reactions) return hydrated; return { ...hydrated, reactions: filterBlockedReactions(hydrated.reactions, members), }; }), cursor: messages.cursor, }; }); this.$hydratedConvoMessages = new ComputedMap((convoId) => { return this.$convoMessages.get(convoId); }); this.$postLikes = new ComputedMap((postUri) => { const data = this.dataStore.$postLikes.get(postUri); if (!data) return data; return { ...data, likes: data.likes.map((like) => ({ ...like, actor: this.$hydratedProfiles.get(like.actor.did), })), }; }); this.$postReposts = new ComputedMap((postUri) => { const data = this.dataStore.$postReposts.get(postUri); if (!data) return data; return { ...data, repostedBy: data.repostedBy.map((actor) => this.$hydratedProfiles.get(actor.did), ), }; }); this.$profileFollows = new ComputedMap((did) => { const data = this.dataStore.$profileFollows.get(did); if (!data) return data; return { ...data, follows: data.follows.map((actor) => this.$hydratedProfiles.get(actor.did), ), }; }); this.$profileFollowers = new ComputedMap((did) => { const data = this.dataStore.$profileFollowers.get(did); if (!data) return data; return { ...data, followers: data.followers.map((actor) => this.$hydratedProfiles.get(actor.did), ), }; }); this.$knownFollowers = new ComputedMap((did) => { const data = this.dataStore.$knownFollowers.get(did); if (!data) return data; return { ...data, followers: data.followers.map((actor) => this.$hydratedProfiles.get(actor.did), ), }; }); this.$mutedProfiles = new Signal.Computed(() => { const data = this.dataStore.$mutedProfiles.get(); if (!data) return data; return { ...data, mutes: data.mutes.map((profile) => this.$hydratedProfiles.get(profile.did), ), }; }); this.$blockedProfiles = new Signal.Computed(() => { const data = this.dataStore.$blockedProfiles.get(); if (!data) return data; return { ...data, blocks: data.blocks.map((profile) => this.$hydratedProfiles.get(profile.did), ), }; }); this.$notificationCursor = new Signal.Computed( () => this.dataStore.$notifications.get()?.cursor ?? null, ); this.$mentionNotificationCursor = new Signal.Computed( () => this.dataStore.$mentionNotifications.get()?.cursor ?? null, ); }
hydrateDraftImageItem(item, media) { if (!item.localRef?.path) { return item; } const entry = media[item.localRef.path]; return { ...item, exists: entry != null, previewUrl: entry?.url ?? null, }; }
// Decorates a draft post's media embeds with local state: `exists` on // images and videos, plus `previewUrl` on images hydrateDraftPost(draftPost, media) { const hydrated = { ...draftPost }; if (draftPost.embedGallery) { hydrated.embedGallery = { ...draftPost.embedGallery, items: (draftPost.embedGallery.items ?? []).map((item) => this.hydrateDraftImageItem(item, media), ), }; } if (draftPost.embedImages) { hydrated.embedImages = draftPost.embedImages.map((item) => this.hydrateDraftImageItem(item, media), ); } if (draftPost.embedVideos) { hydrated.embedVideos = draftPost.embedVideos.map((videoEmbed) => { if (!videoEmbed.localRef?.path) { return videoEmbed; } const entry = media[videoEmbed.localRef.path]; return { ...videoEmbed, exists: entry != null }; }); } return hydrated; }
attachJoinLinkPreview(item) { const code = getJoinLinkCodeFromEmbed(item?.embed); if (!code) return item; const preview = this.$joinLinkPreviewsByCode.get(code); if (!preview) return item; const updated = attachJoinLinkPreviewToEmbed(item.embed, preview); if (!updated) return item; return { ...item, embed: updated }; }
hydrateProfile(profile) { const preferences = this.$preferences.get(); let result = profile; if (preferences) { const blurLabel = preferences.getProfileBlurLabel(result); if (blurLabel) { result = { ...result, blurLabel }; } const badgeLabels = preferences.getBadgeLabelsForProfile(result); if (badgeLabels.length > 0) { result = { ...result, badgeLabels }; } } if (result?.did && this.$isActorLive.get(result.did)) { // NOTE: LEXICON DEVIATION result = { ...result, isLive: true }; } return result; }
hydratePost(post, preferences) { if (!post || !preferences) { return null; } const storedAuthor = post.author?.did ? this.$hydratedProfiles.get(post.author.did) : null; if (storedAuthor) { post = { ...post, author: storedAuthor }; } if (!isBlockedPost(post) && isBlockedByViewer(post)) { // Create synthetic blocked post if the user has blocked the author return createBlockedPost({ uri: post.uri, author: post.author, }); } let result = this.resolveBlockedQuote(post); result = this.attachJoinLinkPreview(result); result = applyMutedWords(result, preferences); result = applyIsHidden(result, preferences); return applyLabels(result, preferences); }
resolveBlockedQuote(post) { const blockedQuote = getBlockedQuote(post); if (!blockedQuote) return post; if (this.dataStore.$unavailablePosts.get(blockedQuote.uri)) { return markBlockedQuoteNotFound(post, blockedQuote.uri); } if (isBlockingUser(blockedQuote) || isBlockedByViewer(blockedQuote)) { return post; } const fullBlockedPost = this.$hydratedPosts.get(blockedQuote.uri); if (fullBlockedPost) { const blockedQuoteEmbed = isEmptyPost(fullBlockedPost) ? fullBlockedPost : createEmbedFromPost(fullBlockedPost); return replaceBlockedQuote(post, blockedQuoteEmbed); } return post; }
// Attach parentAuthor to a post's reply record when its parent is loaded. // Returns the input unchanged if there's no reply or the parent isn't loaded. attachParentAuthor(post) { const parentUri = post?.record?.reply?.parent?.uri; if (!parentUri) return post; const parentPost = this.$hydratedPosts.get(parentUri); if (!parentPost) return post; return { ...post, record: { ...post.record, reply: { // NOTE: LEXICON DEVIATION ...post.record.reply, parentAuthor: parentPost.author, }, }, }; }
hydratePostSearchResults($storedResults) { const data = $storedResults.get(); if (!data) return null; const hydratedSearchResults = []; for (const result of data.posts) { const post = this.$hydratedPosts.get(result.uri); if (!post) continue; hydratedSearchResults.push(this.attachParentAuthor(post)); } return hydratedSearchResults; }
hydrateNotification(rawNotification) { const notification = { ...rawNotification, author: this.$hydratedProfiles.get(rawNotification.author.did), }; // The server marks every notification as read the moment updateSeen // fires, so pages fetched after that arrive with isRead: true even when // the user hasn't seen them. Recompute read state against the seenAt // captured from the first page's response instead. const seenAt = this.dataStore.$notificationsLastSeenAt.get(); if (seenAt) { notification.isRead = new Date(notification.indexedAt) <= new Date(seenAt); } if (notification.reason === "like" || notification.reason === "repost") { const subject = this.$hydratedPosts.get(notification.reasonSubject) ?? createUnavailablePost(notification.reasonSubject); return { ...notification, subject }; } if ( notification.reason === "like-via-repost" || notification.reason === "repost-via-repost" ) { const postUri = notification.record.subject.uri; const subject = this.$hydratedPosts.get(postUri) ?? createUnavailablePost(postUri); return { ...notification, subject }; } if ( notification.reason === "reply" || notification.reason === "mention" || notification.reason === "quote" ) { const replyPost = this.$hydratedPosts.get(notification.uri); const parentPostUri = notification.record?.reply?.parent?.uri; const parentPost = parentPostUri ? this.$hydratedPosts.get(parentPostUri) : null; return { ...notification, post: replyPost, parentPost }; } if (notification.reason === "subscribed-post") { const post = this.$hydratedPosts.get(notification.uri); // NOTE: LEXICON DEVIATION return { ...notification, reasonSubject: post }; } return notification; }
hydratePostThreadNode(node, hiddenReplyUris) { if (!node || isEmptyPost(node)) return node; const post = this.$hydratedPosts.get(node.post.uri); if (!post) return null; const hydrated = { post }; if (hiddenReplyUris.has(node.post.uri)) { // NOTE: LEXICON DEVIATION hydrated.post = { ...post, isHidden: true }; } if (node.replies) { hydrated.replies = node.replies.map((reply) => { if (reply.$type === "app.bsky.feed.defs#threadViewPost") { return this.hydratePostThreadNode(reply, hiddenReplyUris); } return reply; }); } return hydrated; }
hydratePostThreadParent(parent) { if (this.dataStore.$unavailablePosts.get(parent.uri) !== null) { return createUnavailablePost(parent.uri); } if (isBlockedPost(parent) && isBlockingUser(parent)) { return parent; } if (parent.$type !== "app.bsky.feed.defs#threadViewPost") { return parent; } const hydratedParent = { $type: "app.bsky.feed.defs#threadViewPost", post: this.$hydratedPosts.get(parent.post.uri), }; if (parent.parent) { hydratedParent.parent = this.hydratePostThreadParent(parent.parent); } return hydratedParent; }}