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.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298import { filterFollowingFeed, filterAlgorithmicFeed, filterAuthorFeed, filterBookmarksFeed, filterSearchResults,} 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, getReplyRootFromPost,} 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), );}
function isHideableOffenseLabel(label) { return label.val === "!hide" || label.val === "!takedown";}
function isAccountLabel(label) { return !label.uri.endsWith("/app.bsky.actor.profile/self");}
function shouldHideNotification(notification, preferences) { const author = notification.author; if (author.labels?.some(isHideableOffenseLabel)) return true; if ( notification.reason === "subscribed-post" && notification.post?.viewer?.hasMutedWord ) { return true; } if (author.viewer?.blocking) return true; if ( preferences && ["reply", "mention", "quote"].includes(notification.reason) && notification.post && preferences.hasHideLabel(notification.post.labels ?? []) ) { return true; } if (author.viewer?.following) return false; if (author.viewer?.muted) return true; if (!preferences) return false; return preferences.hasHideLabel([ ...(notification.labels ?? []), ...(author.labels ?? []).filter(isAccountLabel), ]);}
// Match social-app: an exact handle match stays visible (so the account// can still be reached) unless it carries a !hide / !takedown label.function shouldHideTypeaheadProfile(profile, query, preferences) { const accountLabels = (profile.labels ?? []).filter(isAccountLabel); if (profile.handle.toLowerCase() === query.toLowerCase().trim()) { return accountLabels.some(isHideableOffenseLabel); } if (profile.viewer?.blocking || profile.viewer?.blockedBy) return true; if (!preferences) return false; return preferences.hasHideLabel(accountLabels);}
// Created items == feed generators and listsfunction shouldHideCreatedItem(item, preferences) { const creator = item.creator; if (creator.viewer?.blocking || creator.viewer?.blockedBy) return true; if (!preferences) return false; return preferences.hasHideLabel([ ...(item.labels ?? []), ...(creator.labels ?? []).filter(isAccountLabel), ]);}
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, sessionState, patchStore, preferencesProvider, hiddenFeedItemsStore, isAuthenticated, draftMediaStore, liveStatusScheduler = new KeyedScheduler(), ) { super("derived"); this.dataStore = dataStore; this.sessionState = sessionState; 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", "muteProfileReposts", "unmuteProfileReposts", ]), ); 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 hydratedFeed = { feed: feed.feed.map((feedItem) => this.hydrateFeedItem(feedItem)), 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; const preferences = this.$preferences.get(); return data.notifications .map((notification) => this.hydrateNotification(notification)) .filter( (notification) => !shouldHideNotification(notification, preferences), ); }); this.$mentionNotifications = new Signal.Computed(() => { const data = this.dataStore.$mentionNotifications.get(); if (!data) return null; const preferences = this.$preferences.get(); return data.notifications .map((notification) => this.hydrateNotification(notification)) .filter( (notification) => !shouldHideNotification(notification, preferences), ); }); 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 rootPost = this.$hydratedPosts.get( getReplyRootFromPost(postThread.post).uri, ); const isOtherHiddenByThreadgate = (item) => !!item.value?.hiddenByThreadgate; const hiddenReplyUris = { server: new Set( postThreadOther .filter((item) => !isOtherHiddenByThreadgate(item)) .map((item) => item.uri), ), threadgate: new Set( rootPost ? (rootPost.threadgate?.record?.hiddenReplies ?? []) : postThreadOther .filter(isOtherHiddenByThreadgate) .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.$postNumbering = new ComputedMap((uri) => { return this.dataStore.$postNumbering.get(uri) ?? null; }); 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: filterSearchResults(hydratedFeedItems, this.isAuthenticated), cursor: data.cursor, }; }); this.$feedGenerators = new ComputedMap((feedUri) => this.hydrateCreator(this.dataStore.$feedGenerators.get(feedUri)), ); this.$lists = new ComputedMap((listUri) => this.hydrateCreator( this.applyReferenceListOptOut(this.dataStore.$lists.get(listUri)), ), ); this.$starterPacks = new ComputedMap((starterPackUri) => { const starterPack = this.hydrateCreator( this.dataStore.$starterPacks.get(starterPackUri), ); if (!starterPack?.list) return starterPack; return { ...starterPack, list: this.applyReferenceListOptOut(starterPack.list), }; }); this.$starterPackUriForList = new ComputedMap( (listUri) => this.dataStore.$starterPackUrisByList.get(listUri) ?? null, ); 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), ), optedOutDids: data.items .filter((item) => item.subjectOptedOut) .map((item) => 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 this.hydrateTypeaheadResults(data); }); this.$sidebarSearchTypeaheadResults = new Signal.Computed(() => { const data = this.dataStore.$sidebarSearchTypeaheadResults.get(); if (!data) return null; return this.hydrateTypeaheadResults(data); }); this.$mentionTypeaheadResults = new Signal.Computed(() => { const data = this.dataStore.$mentionTypeaheadResults.get(); if (!data) return null; return this.hydrateTypeaheadResults(data); }); 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.map((feedGenerator) => this.hydrateCreator(feedGenerator), ); }); this.$feedSearchCursor = new Signal.Computed( () => this.dataStore.$feedSearchResults.get()?.cursor ?? null, ); this.$starterPackSearchResults = new Signal.Computed(() => { const data = this.dataStore.$starterPackSearchResults.get(); if (!data) return null; return data.starterPacks.map((starterPack) => this.hydrateCreator(starterPack), ); }); this.$starterPackSearchCursor = new Signal.Computed( () => this.dataStore.$starterPackSearchResults.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.$threadView = new Signal.Computed(() => { const preferences = this.$preferences.get(); if (!preferences) return null; return preferences.getThreadView(); }); this.$trends = new Signal.Computed(() => this.dataStore.$trends.get()); this.$chatActorStatus = new Signal.Computed(() => this.dataStore.$chatActorStatus.get(), ); this.$selectedFeedUri = new Signal.Computed(() => this.sessionState.$selectedFeedUri.get(), ); this.$trendingHidden = new Signal.Computed(() => this.sessionState.$trendingHidden.get(), ); this.$postLanguageHistory = new Signal.Computed(() => this.sessionState.$postLanguageHistory.get(), ); this.$postSearchResultsTop = new Signal.Computed(() => { const data = this.dataStore.$postSearchResultsTop.get(); if (!data) return null; return this.hydratePostSearchResults(data); }); this.$postSearchResultsLatest = new Signal.Computed(() => { const data = this.dataStore.$postSearchResultsLatest.get(); if (!data) return null; return this.hydratePostSearchResults(data); }); 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: this.hydrateCreator(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); } let hydratedFeed = { feed: feed.feed.map((feedItem) => this.hydrateFeedItem(feedItem)), 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) => { const data = this.dataStore.$actorFeeds.get(did); if (!data) return data; const currentUserDid = this.$currentUser.get()?.did; const preferences = this.$preferences.get(); return { ...data, feeds: data.feeds .map((feed) => this.hydrateCreator(feed)) .filter( (feed) => feed.creator.did === currentUserDid || !shouldHideCreatedItem(feed, preferences), ), }; }); this.$actorLists = new ComputedMap((did) => { const data = this.dataStore.$actorLists.get(did); if (!data) return data; const currentUserDid = this.$currentUser.get()?.did; const preferences = this.$preferences.get(); return { ...data, lists: data.lists .map((list) => this.hydrateCreator(list)) .filter( (list) => list.creator.did === currentUserDid || !shouldHideCreatedItem(list, preferences), ), }; }); this.$actorStarterPacks = new ComputedMap((did) => { const data = this.dataStore.$actorStarterPacks.get(did); if (!data) return data; return { ...data, starterPacks: data.starterPacks.map((starterPack) => this.hydrateCreator(starterPack), ), }; }); this.$popularFeeds = new Signal.Computed(() => { const feeds = this.dataStore.$popularFeeds.get(); if (!feeds) return null; const currentUserDid = this.$currentUser.get()?.did; const preferences = this.$preferences.get(); return feeds .map( (feed) => this.$feedGenerators.get(feed.uri) ?? this.hydrateCreator(feed), ) .filter( (feed) => feed.creator.did === currentUserDid || (!feed.creator.viewer?.muted && !shouldHideCreatedItem(feed, preferences)), ); }); 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 }; }
hydrateQuotedAuthors(post) { return transformNestedQuotes(post, (quotedPost) => { if (quotedPost.$type !== "app.bsky.embed.record#viewRecord") { return quotedPost; } const did = quotedPost.author?.did; const storedAuthor = did ? this.$hydratedProfiles.get(did) : null; if (!storedAuthor) return quotedPost; return { ...quotedPost, author: storedAuthor }; }); }
applyReferenceListOptOut(list) { if (!list) return list; if (!this.dataStore.$referenceListOptOuts.has(list.uri)) return list; return { ...list, viewer: { ...list.viewer, referenceListOptOut: this.dataStore.$referenceListOptOuts.get(list.uri), }, }; }
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; }
hydrateCreator(item) { if (!item) return item; const storedCreator = this.$hydratedProfiles.get(item.creator.did); if (!storedCreator) return item; return { ...item, creator: storedCreator }; }
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 = this.hydrateQuotedAuthors(result); result = applyMutedWords(result, preferences); result = applyIsHidden(result, preferences); if (result.viewer) { const rootUri = getReplyRootFromPost(result).uri; const threadMutePatches = this.patchStore.$threadMutePatches.get(rootUri); if (threadMutePatches?.length) { result = this.patchStore.applyThreadMutePatches( result, threadMutePatches, ); } } 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, }, }, }; }
hydrateTypeaheadResults(data) { const currentUserDid = this.$currentUser.get()?.did; const preferences = this.$preferences.get(); return data.actors .map((actor) => this.$hydratedProfiles.get(actor.did)) .filter( (profile) => profile.did === currentUserDid || !shouldHideTypeaheadProfile(profile, data.query, preferences), ); }
hydratePostSearchResults(data) { const hydratedSearchResults = []; for (const result of data.posts) { const post = this.$hydratedPosts.get(result.uri); if (!post) continue; hydratedSearchResults.push({ post: this.attachParentAuthor(post) }); } return filterSearchResults(hydratedSearchResults, this.isAuthenticated).map( (item) => item.post, ); }
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); return { ...notification, post }; } return notification; }
hydrateFeedItem(feedItem) { const postNumbering = this.dataStore.$postNumbering.get(feedItem.post.uri); const hydratedFeedItem = { ...feedItem, post: this.$hydratedPosts.get(feedItem.post.uri), // NOTE: LEXICON DEVIATION postNumbering, }; const reposter = feedItem.reason?.by; const storedReposter = reposter ? this.$hydratedProfiles.get(reposter.did) : null; if (storedReposter) { hydratedFeedItem.reason = { ...feedItem.reason, by: storedReposter }; } 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); } const opDid = feedItem.post.author?.did; const contextIsNumbered = !!postNumbering && !feedItem.reason && root?.author?.did === opDid && parent?.author?.did === opDid; hydratedFeedItem.reply = { ...reply, root, parent, // NOTE: LEXICON DEVIATION rootPostNumbering: contextIsNumbered ? { index: 1, count: postNumbering.count } : null, parentPostNumbering: contextIsNumbered ? { index: postNumbering.index - 1, count: postNumbering.count } : null, }; } return hydratedFeedItem; }
hydratePostThreadNode(node, hiddenReplyUris) { if (!node || isEmptyPost(node)) return node; const post = this.$hydratedPosts.get(node.post.uri); if (!post) return null; // NOTE: LEXICON DEVIATION const hydrated = { post, postNumbering: this.dataStore.$postNumbering.get(node.post.uri) ?? null, }; if (post.record?.reply) { const isHiddenByThreadgate = hiddenReplyUris.threadgate.has( node.post.uri, ); // NOTE: LEXICON DEVIATION hydrated.post = { ...post, isHidden: isHiddenByThreadgate || hiddenReplyUris.server.has(node.post.uri), isHiddenByThreadgate, }; } 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), // NOTE: LEXICON DEVIATION postNumbering: this.dataStore.$postNumbering.get(parent.post.uri) ?? null, }; if (parent.parent) { hydratedParent.parent = this.hydratePostThreadParent(parent.parent); } return hydratedParent; }}