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
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045import { 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 profile = this.$patchedDetailedProfiles.get(did) ?? this.$patchedProfiles.get(did); const statusView = profile?.status; if (!statusView || typeof statusView !== "object") { return { state: "none" }; } const viewer = profile.viewer; const preferences = this.$preferences.get(); if ( viewer?.blocking || viewer?.blockedBy || viewer?.muted || (preferences && preferences.getProfileBlurLabel(profile)) ) { 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; }}