From a99a5b21670c8c9d129569b9353bae1925900b28 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Fri, 28 Aug 2026 18:09:53 -0500 Subject: [PATCH] Add follow button to large posts --- package.json | 2 +- src/css/style.css | 30 +- src/js/components/trending-pane.js | 7 +- src/js/dataLayer/dataLayer.js | 2 +- src/js/dataLayer/dataStore.js | 17 +- src/js/dataLayer/derived.js | 51 +++- src/js/dataLayer/patchStore.js | 65 +--- src/js/router.js | 1 - src/js/templates/largePost.template.js | 45 ++- src/js/templates/postFeed.template.js | 5 +- src/js/views/chat.view.js | 5 +- src/js/views/chatDetail.view.js | 10 +- src/js/views/chatRequests.view.js | 5 +- src/js/views/communityPluginListing.view.js | 2 +- src/js/views/communityPlugins.view.js | 5 +- src/js/views/groupChatDetails.view.js | 10 +- src/js/views/home.view.js | 5 +- src/js/views/notifications.view.js | 5 +- src/js/views/postLikes.view.js | 5 +- src/js/views/postQuotes.view.js | 5 +- src/js/views/postReposts.view.js | 5 +- src/js/views/postThread.view.js | 74 ++++- src/js/views/profile.view.js | 15 +- src/js/views/profileFollowers.view.js | 5 +- src/js/views/profileFollowing.view.js | 5 +- src/js/views/profileKnownFollowers.view.js | 5 +- src/js/views/settings/blockedAccounts.view.js | 5 +- src/js/views/settings/mutedAccounts.view.js | 5 +- tests/e2e/specs/views/postThread.view.test.js | 206 +++++++++++++ tests/shared/factories.js | 5 +- tests/unit/specs/dataLayer/dataStore.test.js | 89 ++++++ tests/unit/specs/dataLayer/derived.test.js | 140 ++++++++- tests/unit/specs/dataLayer/mutations.test.js | 264 +++++++++------- tests/unit/specs/dataLayer/patchStore.test.js | 287 ++++++------------ tests/unit/specs/dataLayer/requests.test.js | 39 ++- .../templates/largePost.template.test.js | 99 ++++++ 36 files changed, 1123 insertions(+), 407 deletions(-) diff --git a/package.json b/package.json index cd3faa08..7408d1dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.16", + "version": "0.20.17", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 0d0fc0b7..26855a6b 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -718,6 +718,15 @@ plugin-slot:empty { border-top-color: var(--primary-button-text-color); } +.rounded-button.rounded-button-secondary-inverted { + background-color: var(--text-color); + color: var(--background-color); +} + +.rounded-button.rounded-button-secondary-inverted .loading-spinner { + border-top-color: var(--background-color); +} + @media (hover: hover) { .rounded-button:not(:disabled):hover { background-color: var(--secondary-button-hover-color); @@ -726,6 +735,14 @@ plugin-slot:empty { .rounded-button.rounded-button-primary:not(:disabled):hover { background-color: var(--primary-button-hover-color); } + + .rounded-button.rounded-button-secondary-inverted:not(:disabled):hover { + background-color: color-mix( + in srgb, + var(--text-color) 85%, + var(--background-color) + ); + } } .rounded-button.bell-button { @@ -1970,7 +1987,7 @@ container-link { } .post-content-bottom { - margin-top: 8px; + margin-top: 14px; } .post-content-left { @@ -2109,6 +2126,13 @@ container-link { width: 100%; } +.large-post-follow-button { + margin-left: auto; + align-self: center; + white-space: nowrap; + flex-shrink: 0; +} + .label-badge { display: inline-flex; align-items: center; @@ -2648,6 +2672,10 @@ animated-button { color: var(--text-color-muted); } +.large-post .post-embed > * { + margin-top: 12px; +} + .post-embed > * { margin-top: 8px; } diff --git a/src/js/components/trending-pane.js b/src/js/components/trending-pane.js index ab59a19f..6abe3dcf 100644 --- a/src/js/components/trending-pane.js +++ b/src/js/components/trending-pane.js @@ -39,7 +39,12 @@ function trendingErrorTemplate({ onRetry }) { data-testid="trending-error" >
Error loading trends
- + `; } diff --git a/src/js/dataLayer/dataLayer.js b/src/js/dataLayer/dataLayer.js index 55da399b..600de144 100644 --- a/src/js/dataLayer/dataLayer.js +++ b/src/js/dataLayer/dataLayer.js @@ -25,7 +25,7 @@ export class DataLayer extends EventEmitter { api.isAuthenticated ? api.session : null, ); this.dataStore = new DataStore(this.sessionState); - this.patchStore = new PatchStore(this.dataStore); + this.patchStore = new PatchStore(); this.preferencesProvider = preferencesProvider; this.hiddenFeedItemsStore = hiddenFeedItemsStore; this.requests = new Requests( diff --git a/src/js/dataLayer/dataStore.js b/src/js/dataLayer/dataStore.js index 80a4e652..104d7b5c 100644 --- a/src/js/dataLayer/dataStore.js +++ b/src/js/dataLayer/dataStore.js @@ -85,6 +85,7 @@ export class DataStore extends ReactiveStore { seenQuotedPostUris.add(quotedPost.uri); const normalizedQuotedPost = embedViewRecordToPostView(quotedPost); + this.mergeProfile(normalizedQuotedPost.author); if (!this.$posts.has(quotedPost.uri)) { this.$embeddedPosts.set(quotedPost.uri, normalizedQuotedPost); } @@ -92,6 +93,7 @@ export class DataStore extends ReactiveStore { }; for (const post of posts) { + this.mergeProfile(post.author); this.$posts.set(post.uri, post); // Delete matching embedded post, since they're only used as previews this.$embeddedPosts.delete(post.uri); @@ -99,9 +101,22 @@ export class DataStore extends ReactiveStore { } } + // Merge into existing profile object if present + mergeProfile(profile) { + if (!profile?.did || !profile.handle) { + return; + } + const existing = this.$profiles.get(profile.did); + const merged = existing ? { ...existing, ...profile } : profile; + if (existing && JSON.stringify(merged) === JSON.stringify(existing)) { + return; + } + this.$profiles.set(profile.did, merged); + } + setProfiles(profiles) { for (const profile of profiles) { - this.$profiles.set(profile.did, profile); + this.mergeProfile(profile); } } diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index 87c03961..d90e10ca 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -152,8 +152,43 @@ export class Derived extends ReactiveStore { "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); + }); + 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.patchStore.$patchedPosts.get(uri); + const post = this.$patchedPosts.get(uri); const preferences = this.$preferences.get(); return this.hydratePost(post, preferences); }); @@ -417,14 +452,14 @@ export class Derived extends ReactiveStore { }); }); this.$hydratedProfiles = new ComputedMap((did) => { - const profile = this.patchStore.$patchedProfiles.get(did); + const profile = this.$patchedProfiles.get(did); if (!profile) return profile; const preferences = this.$preferences.get(); if (!preferences) return profile; return this.hydrateProfileLabels(profile, preferences); }); this.$hydratedDetailedProfiles = new ComputedMap((did) => { - const profile = this.patchStore.$patchedDetailedProfiles.get(did); + const profile = this.$patchedDetailedProfiles.get(did); if (!profile) return null; const preferences = this.$preferences.get(); if (!preferences) return profile; @@ -533,7 +568,7 @@ export class Derived extends ReactiveStore { return preferences.getLabelerSettings(labelerDid); }); this.$convos = new ComputedMap((convoId) => - this.patchStore.$patchedConvos.get(convoId), + this.$patchedConvos.get(convoId), ); this.$convoList = new Signal.Computed(() => { const data = this.dataStore.$convoList.get(); @@ -618,7 +653,7 @@ export class Derived extends ReactiveStore { const members = this.$convoMembers.get(convoId) ?? []; return { messages: messages.messages.map((message) => { - const patched = this.patchStore.$patchedMessages.get(message.id); + const patched = this.$patchedMessages.get(message.id); const hydrated = this.attachJoinLinkPreview(patched); if (!hydrated.reactions) return hydrated; return { @@ -779,6 +814,12 @@ export class Derived extends ReactiveStore { if (!post || !preferences) { return null; } + const storedAuthor = post.author?.did + ? this.$patchedProfiles.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({ diff --git a/src/js/dataLayer/patchStore.js b/src/js/dataLayer/patchStore.js index e3857329..874afec5 100644 --- a/src/js/dataLayer/patchStore.js +++ b/src/js/dataLayer/patchStore.js @@ -1,55 +1,17 @@ import { deepClone, SimpleUUID } from "/js/utils.js"; import { pinPostInFeed, unpinPostInFeed } from "/js/dataHelpers.js"; -import { Signal, SignalMap, ComputedMap, ReactiveStore } from "/js/signals.js"; +import { Signal, SignalMap, ReactiveStore } from "/js/signals.js"; // The store saves patch data for optimistic updates. // Patches are convergent - if the target has already // been updated they have no effect. export class PatchStore extends ReactiveStore { - constructor(dataStore) { + constructor() { super("patchStore"); - this.dataStore = dataStore; this.$postPatches = new SignalMap(); - - this.$patchedPosts = new ComputedMap((postURI) => { - const post = this.dataStore.$posts.get(postURI); - if (!post) return null; - const patches = this.$postPatches.get(postURI) || []; - return this.applyPostPatches(post, patches); - }); - this.$profilePatches = new SignalMap(); - this.$patchedProfiles = new ComputedMap((did) => { - const profile = this.dataStore.$profiles.get(did); - if (!profile) return null; - const patches = this.$profilePatches.get(did) || []; - return this.applyProfilePatches(profile, patches); - }); - this.$patchedDetailedProfiles = new ComputedMap((did) => { - const profile = this.dataStore.$detailedProfiles.get(did); - if (!profile) return null; - const patches = this.$profilePatches.get(did) || []; - return this.applyProfilePatches(profile, patches); - }); - this.$messagePatches = new SignalMap(); - this.$patchedMessages = new ComputedMap((messageId) => { - const message = this.dataStore.$messages.get(messageId); - if (!message) return null; - const patches = this.$messagePatches.get(messageId) || []; - let patchedMessage = message; - for (const patch of patches) { - patchedMessage = this.applyMessagePatch(patchedMessage, patch.body); - } - return patchedMessage; - }); this.$convoPatches = new SignalMap(); - this.$patchedConvos = new ComputedMap((convoId) => { - const convo = this.dataStore.$convos.get(convoId); - if (!convo) return convo ?? null; - const patches = this.$convoPatches.get(convoId) || []; - return this.applyConvoPatches(convo, patches); - }); this.$preferencePatches = new Signal.State([]); this.$currentUserPatches = new Signal.State([]); this.$authorFeedPatches = new SignalMap(); @@ -83,12 +45,6 @@ export class PatchStore extends ReactiveStore { for (const patch of patches) { patchedPost = this.applyPostPatch(patchedPost, patch.body); } - if (patchedPost.author) { - patchedPost = { - ...patchedPost, - author: this.applyProfilePatches(patchedPost.author), - }; - } return patchedPost; } @@ -196,9 +152,8 @@ export class PatchStore extends ReactiveStore { } applyProfilePatches(profile, patches) { - const profilePatches = patches ?? this._getProfilePatches(profile.did); let patchedProfile = deepClone(profile); - for (const patch of profilePatches) { + for (const patch of patches) { patchedProfile = this.applyProfilePatch(patchedProfile, patch.body); } return patchedProfile; @@ -295,10 +250,9 @@ export class PatchStore extends ReactiveStore { ); } - applyMessagePatches(message) { - const messagePatches = this._getMessagePatches(message.id); + applyMessagePatches(message, patches) { let patchedMessage = deepClone(message); - for (const patch of messagePatches) { + for (const patch of patches) { patchedMessage = this.applyMessagePatch(patchedMessage, patch.body); } return patchedMessage; @@ -359,9 +313,8 @@ export class PatchStore extends ReactiveStore { } applyConvoPatches(convo, patches) { - const convoPatches = patches ?? this._getConvoPatches(convo.id); let patchedConvo = convo; - for (const patch of convoPatches) { + for (const patch of patches) { patchedConvo = this.applyConvoPatch(patchedConvo, patch.body); } return patchedConvo; @@ -391,9 +344,8 @@ export class PatchStore extends ReactiveStore { } applyPreferencePatches(preferences, patches) { - const preferencePatches = patches ?? this.$preferencePatches.get(); let patchedPreferences = preferences.clone(); - for (const patch of preferencePatches) { + for (const patch of patches) { patchedPreferences = this.applyPreferencePatch( patchedPreferences, patch.body, @@ -449,9 +401,8 @@ export class PatchStore extends ReactiveStore { applyCurrentUserPatches(user, patches) { if (!user) return user; - const currentUserPatches = patches ?? this.$currentUserPatches.get(); let patched = deepClone(user); - for (const patch of currentUserPatches) { + for (const patch of patches) { patched = this.applyCurrentUserPatch(patched, patch.body); } return patched; diff --git a/src/js/router.js b/src/js/router.js index dc96b315..45888e4a 100644 --- a/src/js/router.js +++ b/src/js/router.js @@ -174,7 +174,6 @@ export class Router extends EventEmitter { } mount(root) { - // Clear any pre-mount loading state root.innerHTML = ""; let layoutContainer = null; if (this.layout) { diff --git a/src/js/templates/largePost.template.js b/src/js/templates/largePost.template.js index 8d364557..22cd55a6 100644 --- a/src/js/templates/largePost.template.js +++ b/src/js/templates/largePost.template.js @@ -1,5 +1,11 @@ import { html } from "/js/lib/lit-html.js"; -import { noop, formatFullTimestamp, formatLargeNumber } from "/js/utils.js"; +import { + noop, + classnames, + formatFullTimestamp, + formatLargeNumber, +} from "/js/utils.js"; +import { plusIconTemplate } from "/js/templates/icons/plusIcon.template.js"; import { isBlockedPost, isNotFoundPost, @@ -40,6 +46,33 @@ function mutedWarningTemplate({ post, children }) { return children; } +function followButtonTemplate({ profile, isFollowPending, onClick }) { + const isFollowing = !!profile.viewer?.following; + const isFollowedBy = !!profile.viewer?.followedBy; + return html``; +} + function postActionCountsTemplate({ repostCount, likeCount, @@ -108,6 +141,9 @@ export function largePostTemplate({ afterDelete = null, afterHide = null, afterBlock = null, + showFollowButton = false, + isFollowPending = false, + onClickFollow = noop, pluginService, }) { if (isBlockedPost(post)) { @@ -147,6 +183,13 @@ export function largePostTemplate({ includeTime: false, })} + ${showFollowButton && post.author + ? followButtonTemplate({ + profile: post.author, + isFollowPending, + onClick: (profile, doFollow) => onClickFollow(profile, doFollow), + }) + : ""} ${authorBadgesTemplate({ badgeLabels, diff --git a/src/js/templates/postFeed.template.js b/src/js/templates/postFeed.template.js index 4cb7610e..df10ea7e 100644 --- a/src/js/templates/postFeed.template.js +++ b/src/js/templates/postFeed.template.js @@ -304,7 +304,10 @@ export function postFeedTemplate({ console.error(error); return html`
Error loading posts
-
`; diff --git a/src/js/views/chat.view.js b/src/js/views/chat.view.js index ef7dc436..cd4a51e1 100644 --- a/src/js/views/chat.view.js +++ b/src/js/views/chat.view.js @@ -188,7 +188,10 @@ export default async function chatView({ console.error(error); return html`
There was an error loading conversations.
-
`; diff --git a/src/js/views/chatDetail.view.js b/src/js/views/chatDetail.view.js index 7854a6ad..eb68d46e 100644 --- a/src/js/views/chatDetail.view.js +++ b/src/js/views/chatDetail.view.js @@ -1318,7 +1318,10 @@ export default async function chatDetailView({ return html`

Not Found

Conversation not found
-
`; @@ -1326,7 +1329,10 @@ export default async function chatDetailView({ console.error(error); return html`
There was an error loading messages.
-
`; diff --git a/src/js/views/chatRequests.view.js b/src/js/views/chatRequests.view.js index 6ec09a62..a37504e8 100644 --- a/src/js/views/chatRequests.view.js +++ b/src/js/views/chatRequests.view.js @@ -203,7 +203,10 @@ export default async function chatRequestsView({ console.error(error); return html`
There was an error loading chat requests.
-
`; diff --git a/src/js/views/communityPluginListing.view.js b/src/js/views/communityPluginListing.view.js index 6a99b022..c136ca68 100644 --- a/src/js/views/communityPluginListing.view.js +++ b/src/js/views/communityPluginListing.view.js @@ -176,7 +176,7 @@ export default async function communityPluginListingView({ return html`
Failed to load plugin
` diff --git a/src/js/views/groupChatDetails.view.js b/src/js/views/groupChatDetails.view.js index 2f785dae..0dd81211 100644 --- a/src/js/views/groupChatDetails.view.js +++ b/src/js/views/groupChatDetails.view.js @@ -156,7 +156,10 @@ function detailsErrorTemplate({ error }) { return html`

Not Found

Conversation not found
-
`; @@ -164,7 +167,10 @@ function detailsErrorTemplate({ error }) { console.error(error); return html`
There was an error loading the group chat.
-
`; diff --git a/src/js/views/home.view.js b/src/js/views/home.view.js index 5a14fac7..135779d1 100644 --- a/src/js/views/home.view.js +++ b/src/js/views/home.view.js @@ -185,7 +185,10 @@ export default async function homeView({ >View profile
` : ""} - `; diff --git a/src/js/views/notifications.view.js b/src/js/views/notifications.view.js index 2cdf644c..a59fac67 100644 --- a/src/js/views/notifications.view.js +++ b/src/js/views/notifications.view.js @@ -661,7 +661,10 @@ export default async function notificationsView({ console.error(error); return html`
There was an error loading notifications.
-
`; diff --git a/src/js/views/postLikes.view.js b/src/js/views/postLikes.view.js index 1638bb5b..5b10bb0e 100644 --- a/src/js/views/postLikes.view.js +++ b/src/js/views/postLikes.view.js @@ -30,7 +30,10 @@ export default async function postLikesView({ console.error(error); return html`
Error loading likes
-
`; diff --git a/src/js/views/postQuotes.view.js b/src/js/views/postQuotes.view.js index 83811f81..6198586f 100644 --- a/src/js/views/postQuotes.view.js +++ b/src/js/views/postQuotes.view.js @@ -31,7 +31,10 @@ export default async function postQuotesView({ console.error(error); return html`
Error loading quotes
-
`; diff --git a/src/js/views/postReposts.view.js b/src/js/views/postReposts.view.js index 7909485b..79d78d20 100644 --- a/src/js/views/postReposts.view.js +++ b/src/js/views/postReposts.view.js @@ -30,7 +30,10 @@ export default async function postRepostsView({ console.error(error); return html`
Error loading reposts
-
`; diff --git a/src/js/views/postThread.view.js b/src/js/views/postThread.view.js index 721ea909..805a45d1 100644 --- a/src/js/views/postThread.view.js +++ b/src/js/views/postThread.view.js @@ -6,6 +6,7 @@ import { pageEffect, bindPageTitle, onPageShow, + onPageHide, } from "/js/router.js"; import { headerTemplate } from "/js/templates/header.template.js"; import { smallPostTemplate } from "/js/templates/smallPost.template.js"; @@ -53,7 +54,36 @@ export default async function postThreadView({ } const postUri = `at://${authorDid}/app.bsky.feed.post/${rkey}`; - const { postInteractionHandler } = interactionHandlers; + const { postInteractionHandler, profileInteractionHandler } = + interactionHandlers; + + // Mirrors social-app: the follow button only shows for authors the viewer + // isn't following, but stays visible (as "Following") after an in-view + // follow so it can be undone. + function doShowFollowButton( + author, + rootPost, + currentUser, + hasFollowedInView, + ) { + if (!isAuthenticated || !currentUser || !author?.did) { + return false; + } + if (author.did === currentUser.did) { + return false; + } + const isRootAuthor = rootPost?.author?.did === author.did; + const onlyFollowersCanReply = !!rootPost?.threadgate?.record?.allow?.some( + (rule) => rule.$type === "app.bsky.feed.threadgate#followerRule", + ); + if (isRootAuthor && onlyFollowersCanReply) { + return false; + } + if (author.viewer?.blocking || author.viewer?.blockedBy) { + return false; + } + return !author.viewer?.following || hasFollowedInView; + } function postThreadErrorTemplate({ error }) { if ( @@ -63,7 +93,10 @@ export default async function postThreadView({ ) { return html`
Post not found
-
`; @@ -71,7 +104,10 @@ export default async function postThreadView({ console.error(error); return html`
Error loading thread
-
`; @@ -345,7 +381,7 @@ export default async function postThreadView({ `; } - function threadTemplate({ postThread, currentUser }) { + function threadTemplate({ postThread, currentUser, hasFollowedInView }) { try { const mainPost = isEmptyPost(postThread) ? postThread : postThread.post; const parents = flattenParents(postThread); @@ -357,6 +393,8 @@ export default async function postThreadView({ const hasBrokenReplyRef = hasParent && !postThread.__isPrefill && parents.length === 0; const root = getReplyRootFromPost(mainPost); + const rootCandidate = parents.length ? parents[0].post : mainPost; + const rootPost = rootCandidate?.uri === root?.uri ? rootCandidate : null; const replies = postThread.replies; const postAuthor = mainPost?.author; const hiddenUnauthenticated = @@ -421,6 +459,21 @@ export default async function postThreadView({ pluginService, isUserPost: currentUser?.did === mainPost?.author?.did, postInteractionHandler, + showFollowButton: doShowFollowButton( + postAuthor, + rootPost, + currentUser, + hasFollowedInView, + ), + isFollowPending: postAuthor?.did + ? dataLayer.derived.$isFollowPending.get(postAuthor.did) + : false, + onClickFollow: (profile, doFollow) => { + if (doFollow) { + state.$hasFollowedInView.set(true); + } + profileInteractionHandler.handleFollow(profile, doFollow); + }, afterHide: () => { // if the main post is hidden, go back to the previous page router.back(); @@ -502,6 +555,8 @@ export default async function postThreadView({ const state = new ReactiveStore("postThreadView"); + state.$hasFollowedInView = new Signal.State(false); + state.$postThread = new Signal.Computed(() => { const hydratedPostThread = dataLayer.derived.$hydratedPostThreads.get(postUri); @@ -556,6 +611,7 @@ export default async function postThreadView({ const currentUser = dataLayer.derived.$currentUser.get(); const postThreadRequestStatus = dataLayer.requests.statusStore.$statuses.get("loadPostThread-" + postUri); + const hasFollowedInView = state.$hasFollowedInView.get(); render( html`
@@ -567,7 +623,11 @@ export default async function postThreadView({ error: postThreadRequestStatus.error, }); } else if (postThread) { - return threadTemplate({ postThread, currentUser }); + return threadTemplate({ + postThread, + currentUser, + hasFollowedInView, + }); } else { return threadSkeletonTemplate(); } @@ -644,4 +704,8 @@ export default async function postThreadView({ // Revalidate await dataLayer.requests.loadPostThread(postUri); }); + + onPageHide(root, () => { + state.$hasFollowedInView.set(false); + }); } diff --git a/src/js/views/profile.view.js b/src/js/views/profile.view.js index ed3ef459..5e9757de 100644 --- a/src/js/views/profile.view.js +++ b/src/js/views/profile.view.js @@ -192,7 +192,10 @@ export default async function profileView({ return html`

Not Found

${message}
-
`; @@ -200,7 +203,10 @@ export default async function profileView({ console.error(error); return html`
There was an error loading the profile.
-
`; @@ -213,7 +219,10 @@ export default async function profileView({

This account has requested that users sign in to view their profile.

-
diff --git a/src/js/views/profileFollowers.view.js b/src/js/views/profileFollowers.view.js index 780ae273..46e0d277 100644 --- a/src/js/views/profileFollowers.view.js +++ b/src/js/views/profileFollowers.view.js @@ -32,7 +32,10 @@ export default async function profileFollowersView({ console.error(error); return html`
Error loading followers
-
`; diff --git a/src/js/views/profileFollowing.view.js b/src/js/views/profileFollowing.view.js index a375cf14..e3944175 100644 --- a/src/js/views/profileFollowing.view.js +++ b/src/js/views/profileFollowing.view.js @@ -32,7 +32,10 @@ export default async function profileFollowingView({ console.error(error); return html`
Error loading following
-
`; diff --git a/src/js/views/profileKnownFollowers.view.js b/src/js/views/profileKnownFollowers.view.js index 72ed9136..76d129fe 100644 --- a/src/js/views/profileKnownFollowers.view.js +++ b/src/js/views/profileKnownFollowers.view.js @@ -32,7 +32,10 @@ export default async function profileKnownFollowersView({ console.error(error); return html`
Error loading followers you know
-
`; diff --git a/src/js/views/settings/blockedAccounts.view.js b/src/js/views/settings/blockedAccounts.view.js index 5cc3e750..1b2e8fed 100644 --- a/src/js/views/settings/blockedAccounts.view.js +++ b/src/js/views/settings/blockedAccounts.view.js @@ -27,7 +27,10 @@ export default async function settingsBlockedAccountsView({ console.error(error); return html`
Error loading blocked accounts
-
`; diff --git a/src/js/views/settings/mutedAccounts.view.js b/src/js/views/settings/mutedAccounts.view.js index a084187d..8d0ad81d 100644 --- a/src/js/views/settings/mutedAccounts.view.js +++ b/src/js/views/settings/mutedAccounts.view.js @@ -27,7 +27,10 @@ export default async function settingsMutedAccountsView({ console.error(error); return html`
Error loading muted accounts
-
`; diff --git a/tests/e2e/specs/views/postThread.view.test.js b/tests/e2e/specs/views/postThread.view.test.js index faf6a4c1..767919eb 100644 --- a/tests/e2e/specs/views/postThread.view.test.js +++ b/tests/e2e/specs/views/postThread.view.test.js @@ -85,6 +85,212 @@ test.describe("Post thread view", () => { await expect(view).toContainText("This is the main post"); }); + test("should show a follow button on the main post and follow on click", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.addPosts([mainPost]); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/abc123"); + + const followButton = page.locator( + '[data-testid="large-post"] [data-testid="follow-button"]', + ); + await expect(followButton).toBeVisible({ timeout: 10000 }); + await expect(followButton).toHaveAttribute("data-teststate", "follow"); + + await followButton.click(); + + // The button stays visible after following so it can be undone + await expect(followButton).toHaveAttribute("data-teststate", "following"); + await expect(followButton).toBeVisible(); + }); + + test("should not show a follow button when already following the author", async ({ + page, + }) => { + const followedAuthorPost = createPost({ + uri: postUri, + text: "Post by a followed author", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + authorViewer: { following: "at://did:plc:testuser123/follow/1" }, + }); + const mockServer = new MockServer(); + mockServer.addPosts([followedAuthorPost]); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/abc123"); + + const largePost = page.locator('[data-testid="large-post"]'); + await expect(largePost).toBeVisible({ timeout: 10000 }); + await expect( + largePost.locator('[data-testid="follow-button"]'), + ).toHaveCount(0); + }); + + test("should not show a follow button on the user's own post", async ({ + page, + }) => { + const ownPost = createPost({ + uri: "at://did:plc:testuser123/app.bsky.feed.post/own1", + text: "My own post", + authorHandle: "testuser.bsky.social", + authorDisplayName: "Test User", + }); + const mockServer = new MockServer(); + mockServer.addPosts([ownPost]); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/testuser.bsky.social/post/own1"); + + const largePost = page.locator('[data-testid="large-post"]'); + await expect(largePost).toBeVisible({ timeout: 10000 }); + await expect( + largePost.locator('[data-testid="follow-button"]'), + ).toHaveCount(0); + }); + + test("should not show a follow button on a root post with a followers-only threadgate", async ({ + page, + }) => { + const gatedPost = createPost({ + uri: postUri, + text: "Followers-only thread", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + threadgate: { + uri: postUri.replace("feed.post", "feed.threadgate"), + cid: "bafytgate1", + record: { + allow: [{ $type: "app.bsky.feed.threadgate#followerRule" }], + }, + lists: [], + }, + }); + const mockServer = new MockServer(); + mockServer.addPosts([gatedPost]); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/abc123"); + + const largePost = page.locator('[data-testid="large-post"]'); + await expect(largePost).toBeVisible({ timeout: 10000 }); + await expect( + largePost.locator('[data-testid="follow-button"]'), + ).toHaveCount(0); + }); + + test("should show a follow button on a reply in someone else's follower-gated thread", async ({ + page, + }) => { + const rootUri = "at://did:plc:gatekeeper/app.bsky.feed.post/root1"; + const rootPost = createPost({ + uri: rootUri, + text: "Followers-only thread root", + authorHandle: "gatekeeper.bsky.social", + authorDisplayName: "Gate Keeper", + threadgate: { + uri: rootUri.replace("feed.post", "feed.threadgate"), + cid: "bafytgate2", + record: { + allow: [{ $type: "app.bsky.feed.threadgate#followerRule" }], + }, + lists: [], + }, + }); + const replyPost = createPost({ + uri: postUri, + text: "A reply by someone else", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + reply: { + parent: { uri: rootPost.uri, cid: rootPost.cid }, + root: { uri: rootPost.uri, cid: rootPost.cid }, + }, + }); + const mockServer = new MockServer(); + mockServer.addPosts([replyPost, rootPost]); + mockServer.setPostThread(postUri, { + $type: "app.bsky.feed.defs#threadViewPost", + post: replyPost, + parent: { + $type: "app.bsky.feed.defs#threadViewPost", + post: rootPost, + parent: null, + replies: [], + }, + replies: [], + }); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/abc123"); + + const followButton = page.locator( + '[data-testid="large-post"] [data-testid="follow-button"]', + ); + await expect(followButton).toBeVisible({ timeout: 10000 }); + }); + + test("should not show a follow button on the root author's reply in their own follower-gated thread", async ({ + page, + }) => { + const rootUri = "at://did:plc:author1/app.bsky.feed.post/root1"; + const rootPost = createPost({ + uri: rootUri, + text: "Followers-only thread root", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + threadgate: { + uri: rootUri.replace("feed.post", "feed.threadgate"), + cid: "bafytgate3", + record: { + allow: [{ $type: "app.bsky.feed.threadgate#followerRule" }], + }, + lists: [], + }, + }); + const replyPost = createPost({ + uri: postUri, + text: "Continuing my own gated thread", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + reply: { + parent: { uri: rootPost.uri, cid: rootPost.cid }, + root: { uri: rootPost.uri, cid: rootPost.cid }, + }, + }); + const mockServer = new MockServer(); + mockServer.addPosts([replyPost, rootPost]); + mockServer.setPostThread(postUri, { + $type: "app.bsky.feed.defs#threadViewPost", + post: replyPost, + parent: { + $type: "app.bsky.feed.defs#threadViewPost", + post: rootPost, + parent: null, + replies: [], + }, + replies: [], + }); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/abc123"); + + const largePost = page.locator('[data-testid="large-post"]'); + await expect(largePost).toBeVisible({ timeout: 10000 }); + await expect( + largePost.locator('[data-testid="follow-button"]'), + ).toHaveCount(0); + }); + test("should render an emoji-only post enlarged", async ({ page }) => { const mockServer = new MockServer(); const emojiPost = createPost({ diff --git a/tests/shared/factories.js b/tests/shared/factories.js index bbe2f0d4..3c9ca7e1 100644 --- a/tests/shared/factories.js +++ b/tests/shared/factories.js @@ -364,6 +364,7 @@ export function createPost({ recordEmbed, labels, viewer, + authorViewer, loggedOut = false, threadgate, }) { @@ -376,7 +377,9 @@ export function createPost({ handle: authorHandle, displayName: authorDisplayName, avatar: authorAvatar || "", - ...(loggedOut ? {} : { viewer: { muted: false, blockedBy: false } }), + ...(loggedOut + ? {} + : { viewer: { muted: false, blockedBy: false, ...authorViewer } }), labels: [], createdAt: "2025-01-01T00:00:00.000Z", }, diff --git a/tests/unit/specs/dataLayer/dataStore.test.js b/tests/unit/specs/dataLayer/dataStore.test.js index f11ddb92..cab79399 100644 --- a/tests/unit/specs/dataLayer/dataStore.test.js +++ b/tests/unit/specs/dataLayer/dataStore.test.js @@ -132,6 +132,95 @@ describe("setPosts", () => { assert.deepEqual(dataStore.$embeddedPosts.get(quotedUri), null); assert.deepEqual(dataStore.$posts.get(quotedUri), fullPost); }); + + it("should normalize post authors into $profiles", () => { + const dataStore = new DataStore(createSessionState(null)); + const author = { + did: "did:test:author", + handle: "author.test", + displayName: "Author", + viewer: { following: null }, + }; + dataStore.setPosts([ + { + uri: "at://did:test:author/app.bsky.feed.post/1", + record: { text: "one" }, + author, + }, + ]); + assert.deepEqual(dataStore.$profiles.get("did:test:author"), author); + }); + + it("should normalize quoted post authors into $profiles", () => { + const dataStore = new DataStore(createSessionState(null)); + const quotedAuthor = { did: "did:test:quoted", handle: "quoted.test" }; + dataStore.setPosts([ + { + uri: "at://did:test:author/app.bsky.feed.post/1", + record: { text: "root" }, + author: { did: "did:test:author", handle: "author.test" }, + embed: { + $type: "app.bsky.embed.record#view", + record: { + $type: "app.bsky.embed.record#viewRecord", + uri: "at://did:test:quoted/app.bsky.feed.post/2", + cid: "quoted-cid", + author: quotedAuthor, + value: { text: "quoted" }, + indexedAt: "2026-07-19T00:00:00Z", + }, + }, + }, + ]); + assert.deepEqual(dataStore.$profiles.get("did:test:quoted"), quotedAuthor); + }); +}); + +describe("mergeProfile", () => { + it("should keep fields the new fragment does not carry", () => { + const dataStore = new DataStore(createSessionState(null)); + dataStore.mergeProfile({ + did: "did:test:a", + handle: "a.test", + description: "richer profile view", + viewer: { following: null }, + }); + dataStore.mergeProfile({ + did: "did:test:a", + handle: "a.test", + displayName: "A", + viewer: { following: "at://follow-uri" }, + }); + assert.deepEqual(dataStore.$profiles.get("did:test:a"), { + did: "did:test:a", + handle: "a.test", + description: "richer profile view", + displayName: "A", + viewer: { following: "at://follow-uri" }, + }); + }); + + it("should not merge tombstone authors without a handle", () => { + const dataStore = new DataStore(createSessionState(null)); + dataStore.mergeProfile({ + did: "did:test:blocked", + viewer: { blockedBy: true }, + }); + assert.deepEqual(dataStore.$profiles.get("did:test:blocked"), null); + }); + + it("should not dirty the entry when nothing changed", () => { + const dataStore = new DataStore(createSessionState(null)); + const profile = { + did: "did:test:a", + handle: "a.test", + viewer: { following: null }, + }; + dataStore.mergeProfile(profile); + const stored = dataStore.$profiles.get("did:test:a"); + dataStore.mergeProfile({ ...profile, viewer: { following: null } }); + assert(dataStore.$profiles.get("did:test:a") === stored); + }); }); describe("setConvo", () => { diff --git a/tests/unit/specs/dataLayer/derived.test.js b/tests/unit/specs/dataLayer/derived.test.js index bdc8f936..7259266e 100644 --- a/tests/unit/specs/dataLayer/derived.test.js +++ b/tests/unit/specs/dataLayer/derived.test.js @@ -18,7 +18,7 @@ import { } from "../../../shared/factories.js"; function makeDerived(dataStore, { preferences, draftMediaStore } = {}) { - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const prefs = preferences ?? Preferences.createLoggedOutPreferences(); const preferencesProvider = { requirePreferences: () => prefs, @@ -2459,3 +2459,141 @@ describe("$isFollowPending / $isBlockPending / $isMutePending", () => { assert.deepEqual(derived.$isFollowPending.get("did:test:other"), false); }); }); + +describe("patched overlays ($patchedPosts / $patchedProfiles / $patchedConvos / $patchedMessages)", () => { + it("$patchedPosts overlays post + author profile patches without mutating the store", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived, patchStore } = makeDerived(dataStore); + const postURI = "at://did:plc:author/app.bsky.feed.post/1"; + dataStore.setPosts([ + { + uri: postURI, + likeCount: 3, + viewer: { like: null }, + author: { + did: "did:plc:author", + handle: "author.test", + viewer: { following: null }, + followersCount: 10, + }, + }, + ]); + + patchStore.addPostPatch(postURI, { type: "addLike" }); + patchStore.addProfilePatch("did:plc:author", { type: "followProfile" }); + + const patched = derived.$patchedPosts.get(postURI); + assert.deepEqual(patched.viewer.like, "fake like"); + assert.deepEqual(patched.likeCount, 4); + assert.deepEqual(dataStore.$posts.get(postURI).likeCount, 3); + + // Author profile patches apply through the author substitution in hydratePost. + const hydratedAuthor = derived.$hydratedPosts.get(postURI).author; + assert.deepEqual(hydratedAuthor.viewer.following, "fake following"); + assert.deepEqual(hydratedAuthor.followersCount, 11); + assert.deepEqual( + dataStore.$profiles.get("did:plc:author").followersCount, + 10, + ); + }); + + it("patched overlays return the store object by reference when no patches exist", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + const postURI = "at://did:plc:author/app.bsky.feed.post/1"; + const post = { uri: postURI, likeCount: 3, viewer: { like: null } }; + dataStore.$posts.set(postURI, post); + const profile = { did: "did:plc:x", handle: "x.test", viewer: {} }; + dataStore.$profiles.set(profile.did, profile); + const convo = { id: "convo-1", muted: false }; + dataStore.$convos.set(convo.id, convo); + + assert(derived.$patchedPosts.get(postURI) === post); + assert(derived.$patchedProfiles.get(profile.did) === profile); + assert(derived.$patchedConvos.get(convo.id) === convo); + }); + + it("$patchedPosts returns null when the underlying post is absent", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + assert.deepEqual(derived.$patchedPosts.get("missing"), null); + }); + + it("$patchedProfiles overlays profile patches on $profiles reads", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived, patchStore } = makeDerived(dataStore); + const did = "did:plc:x"; + dataStore.$profiles.set(did, { + did, + followersCount: 4, + viewer: { following: null }, + }); + patchStore.addProfilePatch(did, { type: "followProfile" }); + const patched = derived.$patchedProfiles.get(did); + assert.deepEqual(patched.viewer.following, "fake following"); + assert.deepEqual(patched.followersCount, 5); + assert.deepEqual(dataStore.$profiles.get(did).followersCount, 4); + }); + + it("$patchedDetailedProfiles overlays profile patches on $detailedProfiles reads", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived, patchStore } = makeDerived(dataStore); + const did = "did:plc:x"; + dataStore.$detailedProfiles.set(did, { + did, + followersCount: 4, + viewer: { following: null }, + description: "hello", + }); + patchStore.addProfilePatch(did, { type: "followProfile" }); + const patched = derived.$patchedDetailedProfiles.get(did); + assert.deepEqual(patched.viewer.following, "fake following"); + assert.deepEqual(patched.description, "hello"); + }); + + it("patched profile overlays return null when the underlying profile is absent", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + assert.deepEqual(derived.$patchedProfiles.get("missing"), null); + assert.deepEqual(derived.$patchedDetailedProfiles.get("missing"), null); + }); + + it("$patchedConvos overlays the patch and isolates convos", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived, patchStore } = makeDerived(dataStore); + dataStore.$convos.set("convo-1", { + id: "convo-1", + muted: false, + rev: "rev-1", + }); + dataStore.$convos.set("convo-2", { id: "convo-2", muted: false }); + + patchStore.addConvoPatch("convo-1", { type: "setConvoMuted", muted: true }); + + const patched = derived.$patchedConvos.get("convo-1"); + assert.deepEqual(patched.muted, true); + assert.deepEqual(patched.rev, "rev-1"); + assert.deepEqual(derived.$patchedConvos.get("convo-2").muted, false); + assert.deepEqual(derived.$patchedConvos.get("nope"), null); + assert.deepEqual(dataStore.$convos.get("convo-1").muted, false); + }); + + it("$patchedMessages overlays reaction patches without mutating the store", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived, patchStore } = makeDerived(dataStore); + const messageId = "msg-1"; + dataStore.$messages.set(messageId, { id: messageId, reactions: [] }); + + assert.deepEqual(derived.$patchedMessages.get(messageId).reactions, []); + + patchStore.addMessagePatch(messageId, { + type: "addReaction", + reaction: { sender: { did: "did:plc:me" }, value: "🎉" }, + }); + const patched = derived.$patchedMessages.get(messageId); + assert.deepEqual(patched.reactions.length, 1); + assert.deepEqual(patched.reactions[0].value, "🎉"); + assert.deepEqual(dataStore.$messages.get(messageId).reactions, []); + assert.deepEqual(derived.$patchedMessages.get("missing"), null); + }); +}); diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index 2928a864..bdca48f4 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -30,13 +30,23 @@ function makeMutations(api, dataStore, patchStore, preferencesProvider) { ); } -// `applyPostPatches` now requires the patches array. Helper that fetches the -// current patches for a post URI and applies them. +// The appliers require the patches array. Helpers that fetch the current +// patches for a key and apply them. function applyPostPatches(patchStore, post) { const patches = patchStore.$postPatches.get(post.uri) || []; return patchStore.applyPostPatches(post, patches); } +function applyProfilePatches(patchStore, profile) { + const patches = patchStore.$profilePatches.get(profile.did) || []; + return patchStore.applyProfilePatches(profile, patches); +} + +function applyConvoPatches(patchStore, convo) { + const patches = patchStore.$convoPatches.get(convo.id) || []; + return patchStore.applyConvoPatches(convo, patches); +} + function makeDerived( dataStore, patchStore, @@ -77,7 +87,7 @@ describe("addLike", () => { createLikeRecord: async () => ({ uri: "like-uri" }), }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -101,7 +111,7 @@ describe("addLike", () => { createLikeRecord: async () => mockLike, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -130,7 +140,7 @@ describe("addLike", () => { ), }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -154,7 +164,7 @@ describe("addLike", () => { it("should not double-count when a refresh lands while the like is in flight", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockApi = { createLikeRecord: async () => { // Simulate a feed refresh delivering the server state (like already @@ -182,7 +192,7 @@ describe("addLike", () => { const storedPost = dataStore.$posts.get(testPost.uri); assert.deepEqual(storedPost.likeCount, 6); assert.deepEqual(storedPost.viewer.like, "server-like-uri"); - assert.deepEqual(patchStore.$patchedPosts.get(testPost.uri).likeCount, 6); + assert.deepEqual(applyPostPatches(patchStore, storedPost).likeCount, 6); }); }); @@ -201,7 +211,7 @@ describe("removeLike", () => { }), }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -224,7 +234,7 @@ describe("removeLike", () => { deleteLikeRecord: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -263,7 +273,7 @@ describe("followProfile", () => { }), }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -276,7 +286,7 @@ describe("followProfile", () => { mutations.followProfile(testProfile); - const patchedProfile = patchStore.applyProfilePatches(testProfile); + const patchedProfile = applyProfilePatches(patchStore, testProfile); assert.deepEqual(patchedProfile.viewer.following, "fake following"); assert.deepEqual(patchedProfile.followersCount, 11); }); @@ -288,7 +298,7 @@ describe("followProfile", () => { }; const dataStore = new DataStore(createSessionState(null)); dataStore.$detailedProfiles.set(testProfile.did, testProfile); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -308,9 +318,47 @@ describe("followProfile", () => { assert.deepEqual(storedDetailed.viewer.following, "follow-123"); assert.deepEqual(storedDetailed.followersCount, 11); - const patchedProfile = patchStore.applyProfilePatches(storedProfile); + const patchedProfile = applyProfilePatches(patchStore, storedProfile); assert.deepEqual(patchedProfile, storedProfile); }); + + it("should update hydrated post authors after the patch is removed", async () => { + const mockApi = { + createFollowRecord: async () => ({ uri: "follow-123" }), + }; + const dataStore = new DataStore(createSessionState(null)); + const patchStore = new PatchStore(); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + const derived = makeDerived(dataStore, patchStore, mockPreferencesProvider); + const mutations = makeMutations( + mockApi, + dataStore, + patchStore, + mockPreferencesProvider, + ); + + const postUri = "at://did:test:profile/app.bsky.feed.post/1"; + dataStore.setPosts([ + { + uri: postUri, + record: { text: "hello" }, + author: { + did: testProfile.did, + handle: testProfile.handle, + viewer: { following: null }, + }, + viewer: {}, + labels: [], + }, + ]); + + await mutations.followProfile(testProfile); + + const hydratedPost = derived.$hydratedPosts.get(postUri); + assert.deepEqual(hydratedPost.author.viewer.following, "follow-123"); + }); }); describe("unfollowProfile", () => { @@ -330,7 +378,7 @@ describe("unfollowProfile", () => { }), }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -343,7 +391,7 @@ describe("unfollowProfile", () => { mutations.unfollowProfile(testProfile); - const patchedProfile = patchStore.applyProfilePatches(testProfile); + const patchedProfile = applyProfilePatches(patchStore, testProfile); assert.deepEqual(patchedProfile.viewer.following, null); assert.deepEqual(patchedProfile.followersCount, 9); }); @@ -354,7 +402,7 @@ describe("unfollowProfile", () => { }; const dataStore = new DataStore(createSessionState(null)); dataStore.$detailedProfiles.set(testProfile.did, testProfile); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -374,7 +422,7 @@ describe("unfollowProfile", () => { assert.deepEqual(storedDetailed.viewer.following, null); assert.deepEqual(storedDetailed.followersCount, 9); - const patchedProfile = patchStore.applyProfilePatches(storedProfile); + const patchedProfile = applyProfilePatches(patchStore, storedProfile); assert.deepEqual(patchedProfile, storedProfile); }); }); @@ -399,7 +447,7 @@ describe("subscribeLabeler", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -423,7 +471,7 @@ describe("subscribeLabeler", () => { updatePreferences: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -447,7 +495,7 @@ describe("subscribeLabeler", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -484,7 +532,7 @@ describe("unsubscribeLabeler", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -508,7 +556,7 @@ describe("unsubscribeLabeler", () => { updatePreferences: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -532,7 +580,7 @@ describe("unsubscribeLabeler", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -568,7 +616,7 @@ describe("updateLabelerSetting", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -594,7 +642,7 @@ describe("updateLabelerSetting", () => { updatePreferences: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -618,7 +666,7 @@ describe("updateLabelerSetting", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -650,7 +698,7 @@ describe("updateLabelerSetting", () => { updatePreferences: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -683,7 +731,7 @@ describe("Error Handling and Edge Cases", () => { new Promise((resolve) => setTimeout(resolve, 75)), }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -716,7 +764,7 @@ describe("Error Handling and Edge Cases", () => { deleteLikeRecord: async () => undefined, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -744,7 +792,7 @@ describe("addMutedWord", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -774,7 +822,7 @@ describe("addMutedWord", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -828,7 +876,7 @@ describe("removeMutedWord", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -856,7 +904,7 @@ describe("updateProfile", () => { function createMutationsWithMockApi(mockApi) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -1086,7 +1134,7 @@ describe("pinPost", () => { function setup(mockApi, { pinnedPost = null, authorFeed = null } = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -1260,7 +1308,7 @@ describe("unpinPost", () => { function setup(mockApi, { pinnedPost, authorFeed = null } = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -1341,7 +1389,7 @@ describe("muteProfile", () => { function setup(mockApi = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -1402,7 +1450,7 @@ describe("unmuteProfile", () => { function setup(mockApi = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -1458,7 +1506,7 @@ describe("blockProfile", () => { function setup(mockApi = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -1550,7 +1598,7 @@ describe("unblockProfile", () => { function setup(mockApi = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -1630,7 +1678,7 @@ describe("addBookmark", () => { function setup(mockApi = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -1694,7 +1742,7 @@ describe("removeBookmark", () => { function setup(mockApi = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -1761,7 +1809,7 @@ describe("createRepost", () => { function setup(mockApi = {}, { authorFeed } = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -1867,7 +1915,7 @@ describe("deleteRepost", () => { function setup(mockApi = {}, { authorFeed } = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -1943,7 +1991,7 @@ describe("pinFeed", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -2007,7 +2055,7 @@ describe("pinFeed", () => { updatePreferences: () => updatePromise, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -2048,7 +2096,7 @@ describe("unpinFeed", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -2080,7 +2128,7 @@ describe("unpinFeed", () => { updatePreferences: () => updatePromise, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -2145,7 +2193,7 @@ describe("setPinnedItems", () => { { type: "list", data: { uri: listA } }, ]); } - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -2216,7 +2264,7 @@ describe("setPinnedItems", () => { { type: "feed", data: { uri: feedA } }, { type: "list", data: { uri: listA } }, ]); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -2251,7 +2299,7 @@ describe("hidePost", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -2275,7 +2323,7 @@ describe("hidePost", () => { updatePreferences: () => updatePromise, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -2323,7 +2371,7 @@ describe("updateMutedWord", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -2354,7 +2402,7 @@ describe("updatePostNotificationSubscription", () => { it("should set viewer.activitySubscription on the profile", async () => { const subscription = { post: true, reply: false }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2382,7 +2430,7 @@ describe("updatePostNotificationSubscription", () => { it("should remove the patch on failure and rethrow", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2416,7 +2464,7 @@ describe("createThread", () => { function setup({ replyPostThread, authorFeed, replyAuthorFeed } = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2539,7 +2587,7 @@ describe("deletePost", () => { }; let apiCalledWith = null; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2574,7 +2622,7 @@ describe("createMessage", () => { function setup({ convoMessages, convo } = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2628,7 +2676,7 @@ describe("createMessage", () => { it("should pass replyTo to the api", async () => { let apiCalledWith = null; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2653,7 +2701,7 @@ describe("createMessage", () => { it("should pass embed to the api", async () => { let apiCalledWith = null; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2678,7 +2726,7 @@ describe("createMessage", () => { it("should propagate the raw error on send failure", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2707,7 +2755,7 @@ describe("acceptConvo", () => { function setup({ convoList, convoRequestList } = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2791,7 +2839,7 @@ describe("acceptConvo", () => { describe("createGroupChat", () => { function setup({ convoList, apiFailure } = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2867,7 +2915,7 @@ describe("rejectConvo", () => { const otherAccepted = { id: "convo-2", status: "accepted" }; const otherRequest = { id: "convo-3", status: "request" }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2915,7 +2963,7 @@ describe("leaveConvo", () => { const otherAccepted = { id: "convo-2", status: "accepted" }; const otherRequest = { id: "convo-3", status: "request" }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2958,7 +3006,7 @@ describe("leaveConvo", () => { it("should leave the store unchanged when api.leaveConvo throws", async () => { const otherAccepted = { id: "convo-2", status: "accepted" }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -2992,7 +3040,7 @@ describe("setConvoMuted", () => { it("should optimistically patch, then write to dataStore and clear the patch on success", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3003,7 +3051,10 @@ describe("setConvoMuted", () => { { muteConvo: async (id) => { muteCalledWith = id; - patchDuringApi = patchStore.$patchedConvos.get(convo.id); + patchDuringApi = applyConvoPatches( + patchStore, + dataStore.$convos.get(convo.id), + ); }, }, dataStore, @@ -3024,7 +3075,7 @@ describe("setConvoMuted", () => { it("should call api.unmuteConvo when muted=false", async () => { const mutedConvo = { id: "convo-1", muted: true }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3049,7 +3100,7 @@ describe("setConvoMuted", () => { it("should revert the optimistic patch and leave the dataStore unchanged when the api throws", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3069,12 +3120,15 @@ describe("setConvoMuted", () => { assert.deepEqual(dataStore.$convos.get(convo.id).muted, false); assert.deepEqual(patchStore.$convoPatches.get(convo.id), []); - assert.deepEqual(patchStore.$patchedConvos.get(convo.id).muted, false); + assert.deepEqual( + applyConvoPatches(patchStore, dataStore.$convos.get(convo.id)).muted, + false, + ); }); it("should not write to dataStore if the underlying convo was cleared during the api call", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3101,7 +3155,7 @@ describe("markConvoAsRead", () => { it("should call api.markConvoAsRead and zero the unread count", async () => { const convoId = "convo-1"; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3126,7 +3180,7 @@ describe("markConvoAsRead", () => { it("should not throw when the convo is not cached", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3144,7 +3198,7 @@ describe("markConvoAsRead", () => { it("should not call the api when the convo has no unread messages", async () => { const convoId = "convo-read"; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3179,7 +3233,7 @@ describe("addMessageReaction", () => { function setup({ convo } = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3246,7 +3300,7 @@ describe("removeMessageReaction", () => { function setup({ convo } = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3309,7 +3363,7 @@ describe("sendShowLessInteraction", () => { it("should append the interaction to the dataStore (empty list branch)", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3344,7 +3398,7 @@ describe("sendShowLessInteraction", () => { it("should append to an existing list (non-empty branch)", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3372,7 +3426,7 @@ describe("sendShowLessInteraction", () => { it("should key stored interactions by feed", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3397,7 +3451,7 @@ describe("sendShowLessInteraction", () => { it("should omit feedContext when null but keep an empty string", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3433,7 +3487,7 @@ describe("sendShowLessInteraction", () => { it("should store but not send when there is no feed proxy url", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3464,7 +3518,7 @@ describe("sendShowMoreInteraction", () => { it("should append the interaction to the dataStore (empty list branch)", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3498,7 +3552,7 @@ describe("sendShowMoreInteraction", () => { it("should append to an existing list (non-empty branch)", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3550,7 +3604,7 @@ describe("pinList", () => { new Promise((resolve) => setTimeout(resolve, 100)), }); const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations({}, dataStore, patchStore, provider); mutations.pinList(listUri); @@ -3565,7 +3619,7 @@ describe("pinList", () => { it("should call preferences.pinFeed with type 'list'", async () => { const { provider, pinFeedCalls } = makeMockProvider(); const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations({}, dataStore, patchStore, provider); await mutations.pinList(listUri); @@ -3578,7 +3632,7 @@ describe("pinList", () => { it("should remove patch after successful update", async () => { const { provider } = makeMockProvider(); const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations({}, dataStore, patchStore, provider); await mutations.pinList(listUri); @@ -3593,7 +3647,7 @@ describe("pinList", () => { }, }); const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations({}, dataStore, patchStore, provider); let errorThrown = false; @@ -3621,7 +3675,7 @@ describe("pinFeed entryType", () => { new Promise((resolve) => setTimeout(resolve, 100)), }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, @@ -3652,7 +3706,7 @@ describe("unpinList", () => { updatePreferences: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations({}, dataStore, patchStore, provider); await mutations.unpinList(listUri); @@ -3673,7 +3727,7 @@ describe("unpinList", () => { updatePreferences: () => updatePromise, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mutations = makeMutations({}, dataStore, patchStore, provider); const promise = mutations.unpinList(listUri); @@ -3703,7 +3757,7 @@ describe("addProfileToList", () => { createListItemRecord: async () => ({ uri: "listitem-real-uri" }), }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3738,7 +3792,7 @@ describe("addProfileToList", () => { createListItemRecord: async () => ({ uri: "listitem-real-uri" }), }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3771,7 +3825,7 @@ describe("addProfileToList", () => { ], cursor: null, }); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3797,7 +3851,7 @@ describe("addProfileToList", () => { }, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3847,7 +3901,7 @@ describe("removeProfileFromList", () => { ], cursor: null, }); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3869,7 +3923,7 @@ describe("removeProfileFromList", () => { deleteListItemRecord: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3915,7 +3969,7 @@ describe("removeProfileFromList", () => { ], cursor: null, }); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3945,7 +3999,7 @@ describe("removeProfileFromList", () => { listsWithMembership: [{ list: testList, listItem: initialListItem }], cursor: null, }); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -3990,7 +4044,7 @@ describe("$detailedProfiles mirroring", () => { function setup(mockApi, { seedDetailed = true } = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -4055,7 +4109,7 @@ describe("$detailedProfiles mirroring", () => { const seedFollowed = { ...detailedSeed, viewer: { following: "at://x" } }; const mockApi = { deleteFollowRecord: async () => {} }; const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -4091,7 +4145,7 @@ describe("$detailedProfiles mirroring", () => { it("unmuteProfile mirrors viewer.muted=false into $detailedProfiles", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -4132,7 +4186,7 @@ describe("$detailedProfiles mirroring", () => { it("unblockProfile mirrors viewer.blocking=null into $detailedProfiles", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -4190,7 +4244,7 @@ describe("updateList", () => { function setup(overrides = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; @@ -4418,7 +4472,7 @@ describe("deleteList", () => { function setup({ listItems = [], overrides = {} } = {}) { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); const preferences = Preferences.createLoggedOutPreferences(); const preferencesProvider = { requirePreferences: () => preferences, @@ -4605,7 +4659,7 @@ describe("deleteList", () => { it("unpins the list if it was pinned", async () => { const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); + const patchStore = new PatchStore(); let preferences = Preferences.createLoggedOutPreferences().pinFeed( listUri, "list", diff --git a/tests/unit/specs/dataLayer/patchStore.test.js b/tests/unit/specs/dataLayer/patchStore.test.js index ab8ce895..90bb8c2c 100644 --- a/tests/unit/specs/dataLayer/patchStore.test.js +++ b/tests/unit/specs/dataLayer/patchStore.test.js @@ -1,16 +1,43 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { PatchStore } from "/js/dataLayer/patchStore.js"; -import { DataStore } from "/js/dataLayer/dataStore.js"; -import { createSessionState } from "/js/dataLayer/sessionState.js"; -// applyPostPatches now requires the patches array explicitly. This helper -// fetches the current patches for a post URI and applies them. +// The appliers require the patches array explicitly. These helpers fetch the +// current patches for a key and apply them. function applyPostPatches(patchStore, post) { const patches = patchStore.$postPatches.get(post.uri) || []; return patchStore.applyPostPatches(post, patches); } +function applyProfilePatchesForDid(patchStore, profile) { + const patches = patchStore.$profilePatches.get(profile.did) || []; + return patchStore.applyProfilePatches(profile, patches); +} + +function applyMessagePatchesForId(patchStore, message) { + const patches = patchStore.$messagePatches.get(message.id) || []; + return patchStore.applyMessagePatches(message, patches); +} + +function applyConvoPatchesForId(patchStore, convo) { + const patches = patchStore.$convoPatches.get(convo.id) || []; + return patchStore.applyConvoPatches(convo, patches); +} + +function applyPreferencePatchesAll(patchStore, preferences) { + return patchStore.applyPreferencePatches( + preferences, + patchStore.$preferencePatches.get(), + ); +} + +function applyCurrentUserPatchesAll(patchStore, user) { + return patchStore.applyCurrentUserPatches( + user, + patchStore.$currentUserPatches.get(), + ); +} + describe("Post Patches - Patch Management", () => { const postURI = "at://did:test/app.bsky.feed.post/test"; const basePost = { @@ -163,14 +190,14 @@ describe("Profile Patches - Patch Management", () => { }); // Verify patch exists - const patchedProfile = patchStore.applyProfilePatches(baseProfile); + const patchedProfile = applyProfilePatchesForDid(patchStore, baseProfile); assert.deepEqual(patchedProfile.viewer.following, "fake following"); // Remove patch patchStore.removeProfilePatch(profileDID, patchId); // Verify patch is removed - const unpatchedProfile = patchStore.applyProfilePatches(baseProfile); + const unpatchedProfile = applyProfilePatchesForDid(patchStore, baseProfile); assert.deepEqual(unpatchedProfile.viewer.following, null); }); }); @@ -185,7 +212,7 @@ describe("Profile Patches - Follow Patches", () => { it("should apply followProfile patch correctly", () => { const patchStore = new PatchStore(); patchStore.addProfilePatch(profileDID, { type: "followProfile" }); - const result = patchStore.applyProfilePatches(baseProfile); + const result = applyProfilePatchesForDid(patchStore, baseProfile); assert.deepEqual(result.viewer.following, "fake following"); assert.deepEqual(result.did, profileDID); @@ -199,7 +226,7 @@ describe("Profile Patches - Follow Patches", () => { }; patchStore.addProfilePatch(profileDID, { type: "unfollowProfile" }); - const result = patchStore.applyProfilePatches(followedProfile); + const result = applyProfilePatchesForDid(patchStore, followedProfile); assert.deepEqual(result.viewer.following, null); }); @@ -210,7 +237,7 @@ describe("Profile Patches - Follow Patches", () => { patchStore.addProfilePatch(profileDID, { type: "followProfile" }); patchStore.addProfilePatch(profileDID, { type: "unfollowProfile" }); - const result = patchStore.applyProfilePatches(baseProfile); + const result = applyProfilePatchesForDid(patchStore, baseProfile); assert.deepEqual(result.viewer.following, null); }); }); @@ -292,7 +319,7 @@ describe("Profile Patches - Error Handling", () => { let errorThrown = false; let errorMessage = ""; try { - patchStore.applyProfilePatches(baseProfile); + applyProfilePatchesForDid(patchStore, baseProfile); } catch (e) { errorThrown = true; errorMessage = e.message; @@ -342,8 +369,8 @@ describe("Patch Isolation", () => { patchStore.addProfilePatch(profile1URI, { type: "followProfile" }); - const result1 = patchStore.applyProfilePatches(baseProfile1); - const result2 = patchStore.applyProfilePatches(baseProfile2); + const result1 = applyProfilePatchesForDid(patchStore, baseProfile1); + const result2 = applyProfilePatchesForDid(patchStore, baseProfile2); assert.deepEqual(result1.viewer.following, "fake following"); assert.deepEqual(result2.viewer.following, null); // Unchanged @@ -374,7 +401,7 @@ describe("Preference Patches - Labeler Patches", () => { did: labelerDid, labelerInfo, }); - const result = patchStore.applyPreferencePatches(mockPreferences); + const result = applyPreferencePatchesAll(patchStore, mockPreferences); assert.deepEqual(result._subscribedLabeler, labelerDid); assert.deepEqual(result._labelerInfo, labelerInfo); @@ -397,7 +424,7 @@ describe("Preference Patches - Labeler Patches", () => { type: "unsubscribeLabeler", did: labelerDid, }); - const result = patchStore.applyPreferencePatches(mockPreferences); + const result = applyPreferencePatchesAll(patchStore, mockPreferences); assert.deepEqual(result._unsubscribedLabeler, labelerDid); }); @@ -444,7 +471,7 @@ describe("Preference Patches - Labeler Patches", () => { did: labelerDid1, }); - patchStore.applyPreferencePatches(mockPreferences); + applyPreferencePatchesAll(patchStore, mockPreferences); assert.deepEqual(calls.length, 3); assert.deepEqual(calls[0].type, "subscribe"); @@ -472,7 +499,7 @@ describe("Preference Patches - Pin Feed Patches", () => { feedUri: "at://did:test/app.bsky.graph.list/abc", entryType: "list", }); - patchStore.applyPreferencePatches(mockPreferences); + applyPreferencePatchesAll(patchStore, mockPreferences); assert.deepEqual(calls.length, 1); assert.deepEqual(calls[0].feedUri, "at://did:test/app.bsky.graph.list/abc"); @@ -494,7 +521,7 @@ describe("Preference Patches - Pin Feed Patches", () => { type: "pinFeed", feedUri: "at://did:test/app.bsky.feed.generator/xyz", }); - patchStore.applyPreferencePatches(mockPreferences); + applyPreferencePatchesAll(patchStore, mockPreferences); assert.deepEqual(calls.length, 1); assert.deepEqual(calls[0].type, undefined); @@ -565,7 +592,7 @@ describe("Preference Patches - Content Label Patches", () => { visibility, labelerDid, }); - const result = patchStore.applyPreferencePatches(mockPreferences); + const result = applyPreferencePatchesAll(patchStore, mockPreferences); assert.deepEqual(result._contentLabelPref.label, label); assert.deepEqual(result._contentLabelPref.visibility, visibility); @@ -599,7 +626,7 @@ describe("Preference Patches - Content Label Patches", () => { labelerDid, }); - patchStore.applyPreferencePatches(mockPreferences); + applyPreferencePatchesAll(patchStore, mockPreferences); assert.deepEqual(calls.length, 2); assert.deepEqual(calls[0].label, "nsfw"); @@ -641,7 +668,7 @@ describe("Preference Patches - Content Label Patches", () => { labelerDid, }); - patchStore.applyPreferencePatches(mockPreferences); + applyPreferencePatchesAll(patchStore, mockPreferences); assert.deepEqual(calls.length, 2); assert.deepEqual(calls[0].type, "subscribe"); @@ -660,7 +687,7 @@ describe("Current User Patches", () => { type: "setPinnedPost", pinnedPost: { uri: "at://x/y/1", cid: "c1" }, }); - const patched = patchStore.applyCurrentUserPatches(baseUser); + const patched = applyCurrentUserPatchesAll(patchStore, baseUser); assert.deepEqual(patched.pinnedPost.uri, "at://x/y/1"); }); @@ -668,14 +695,14 @@ describe("Current User Patches", () => { const patchStore = new PatchStore(); patchStore.addCurrentUserPatch({ type: "clearPinnedPost" }); const user = { ...baseUser, pinnedPost: { uri: "at://x/y/1", cid: "c1" } }; - const patched = patchStore.applyCurrentUserPatches(user); + const patched = applyCurrentUserPatchesAll(patchStore, user); assert.deepEqual(patched.pinnedPost, undefined); }); it("should return null user unchanged", () => { const patchStore = new PatchStore(); patchStore.addCurrentUserPatch({ type: "clearPinnedPost" }); - assert.deepEqual(patchStore.applyCurrentUserPatches(null), null); + assert.deepEqual(applyCurrentUserPatchesAll(patchStore, null), null); }); it("should drop the patch after remove", () => { @@ -685,7 +712,7 @@ describe("Current User Patches", () => { pinnedPost: { uri: "at://x/y/1", cid: "c1" }, }); patchStore.removeCurrentUserPatch(id); - const patched = patchStore.applyCurrentUserPatches(baseUser); + const patched = applyCurrentUserPatchesAll(patchStore, baseUser); assert.deepEqual(patched.pinnedPost, undefined); }); }); @@ -794,12 +821,12 @@ describe("Convo Patches - Patch Management", () => { muted: true, }); - const patched = patchStore.applyConvoPatches(baseConvo); + const patched = applyConvoPatchesForId(patchStore, baseConvo); assert.deepEqual(patched.muted, true); patchStore.removeConvoPatch(convoId, patchId); - const unpatched = patchStore.applyConvoPatches(baseConvo); + const unpatched = applyConvoPatchesForId(patchStore, baseConvo); assert.deepEqual(unpatched.muted, false); }); @@ -822,7 +849,10 @@ describe("Convo Patches - setConvoMuted", () => { it("should apply setConvoMuted(true) to a previously unmuted convo", () => { const patchStore = new PatchStore(); patchStore.addConvoPatch(convoId, { type: "setConvoMuted", muted: true }); - const patched = patchStore.applyConvoPatches({ id: convoId, muted: false }); + const patched = applyConvoPatchesForId(patchStore, { + id: convoId, + muted: false, + }); assert.deepEqual(patched.muted, true); assert.deepEqual(patched.id, convoId); }); @@ -830,7 +860,10 @@ describe("Convo Patches - setConvoMuted", () => { it("should apply setConvoMuted(false) to a previously muted convo", () => { const patchStore = new PatchStore(); patchStore.addConvoPatch(convoId, { type: "setConvoMuted", muted: false }); - const patched = patchStore.applyConvoPatches({ id: convoId, muted: true }); + const patched = applyConvoPatchesForId(patchStore, { + id: convoId, + muted: true, + }); assert.deepEqual(patched.muted, false); }); @@ -838,14 +871,17 @@ describe("Convo Patches - setConvoMuted", () => { const patchStore = new PatchStore(); patchStore.addConvoPatch(convoId, { type: "setConvoMuted", muted: true }); patchStore.addConvoPatch(convoId, { type: "setConvoMuted", muted: false }); - const patched = patchStore.applyConvoPatches({ id: convoId, muted: false }); + const patched = applyConvoPatchesForId(patchStore, { + id: convoId, + muted: false, + }); assert.deepEqual(patched.muted, false); }); it("should preserve unrelated convo fields", () => { const patchStore = new PatchStore(); patchStore.addConvoPatch(convoId, { type: "setConvoMuted", muted: true }); - const patched = patchStore.applyConvoPatches({ + const patched = applyConvoPatchesForId(patchStore, { id: convoId, muted: false, rev: "rev-1", @@ -860,7 +896,7 @@ describe("Convo Patches - setConvoMuted", () => { it("should return the convo unchanged when no patches exist", () => { const patchStore = new PatchStore(); const convo = { id: convoId, muted: false }; - const patched = patchStore.applyConvoPatches(convo); + const patched = applyConvoPatchesForId(patchStore, convo); assert.deepEqual(patched, convo); }); }); @@ -870,7 +906,7 @@ describe("Convo Patches - Error Handling", () => { const patchStore = new PatchStore(); patchStore.addConvoPatch("convo-1", { type: "unknownConvoPatch" }); assert.throws(() => - patchStore.applyConvoPatches({ id: "convo-1", muted: false }), + applyConvoPatchesForId(patchStore, { id: "convo-1", muted: false }), ); }); }); @@ -925,10 +961,6 @@ describe("Post Patches - Reposts, Bookmarks, HidePost", () => { }); }); -function applyProfilePatchesForDid(patchStore, profile) { - return patchStore.applyProfilePatches(profile); -} - describe("Profile Patches - Mute/Block/NotificationSubscription", () => { const did = "did:plc:test"; const baseProfile = { @@ -1003,12 +1035,12 @@ describe("Message Patches", () => { }); assert.deepEqual(typeof patchId, "number"); - let patched = patchStore.applyMessagePatches(baseMessage); + let patched = applyMessagePatchesForId(patchStore, baseMessage); assert.deepEqual(patched.reactions.length, 1); assert.deepEqual(patched.reactions[0].value, "👍"); patchStore.removeMessagePatch(messageId, patchId); - patched = patchStore.applyMessagePatches(baseMessage); + patched = applyMessagePatchesForId(patchStore, baseMessage); assert.deepEqual(patched.reactions.length, 0); }); @@ -1023,7 +1055,7 @@ describe("Message Patches", () => { currentUserDid, value: "👍", }); - const patched = patchStore.applyMessagePatches(messageWithReaction); + const patched = applyMessagePatchesForId(patchStore, messageWithReaction); assert.deepEqual(patched.reactions.length, 0); }); @@ -1042,7 +1074,7 @@ describe("Message Patches", () => { currentUserDid, value: "👍", }); - const patched = patchStore.applyMessagePatches(messageWithReactions); + const patched = applyMessagePatchesForId(patchStore, messageWithReactions); const surviving = patched.reactions.map( (reaction) => `${reaction.sender.did}:${reaction.value}`, ); @@ -1052,31 +1084,18 @@ describe("Message Patches", () => { it("should throw for an unknown message patch type", () => { const patchStore = new PatchStore(); patchStore.addMessagePatch(messageId, { type: "nope" }); - assert.throws(() => patchStore.applyMessagePatches(baseMessage)); + assert.throws(() => applyMessagePatchesForId(patchStore, baseMessage)); }); - it("should expose the overlay via $patchedMessages", () => { - const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); - dataStore.$messages.set(messageId, baseMessage); - - assert.deepEqual(patchStore.$patchedMessages.get(messageId).reactions, []); - + it("should not mutate the input message", () => { + const patchStore = new PatchStore(); patchStore.addMessagePatch(messageId, { type: "addReaction", reaction: { sender: { did: currentUserDid }, value: "🎉" }, }); - const patched = patchStore.$patchedMessages.get(messageId); + const patched = applyMessagePatchesForId(patchStore, baseMessage); assert.deepEqual(patched.reactions.length, 1); - assert.deepEqual(patched.reactions[0].value, "🎉"); - // Underlying store should not be mutated by the overlay. - assert.deepEqual(dataStore.$messages.get(messageId).reactions, []); - }); - - it("should return null from $patchedMessages when the underlying message is absent", () => { - const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); - assert.deepEqual(patchStore.$patchedMessages.get("missing"), null); + assert.deepEqual(baseMessage.reactions, []); }); }); @@ -1095,7 +1114,7 @@ describe("Preference Patches - unpinFeed", () => { type: "unpinFeed", feedUri: "at://feed/1", }); - const result = patchStore.applyPreferencePatches(mockPreferences); + const result = applyPreferencePatchesAll(patchStore, mockPreferences); assert.deepEqual(seen, ["at://feed/1"]); assert.deepEqual(result, { after: "at://feed/1" }); }); @@ -1129,85 +1148,6 @@ describe("Author Feed Patches - pinPost apply", () => { }); }); -describe("Post Patches - $patchedPosts overlay", () => { - it("should overlay post + author profile patches", () => { - const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); - const postURI = "at://did:plc:author/app.bsky.feed.post/1"; - const post = { - uri: postURI, - likeCount: 3, - viewer: { like: null }, - author: { - did: "did:plc:author", - viewer: { following: null }, - followersCount: 10, - }, - }; - dataStore.$posts.set(postURI, post); - - patchStore.addPostPatch(postURI, { type: "addLike" }); - patchStore.addProfilePatch("did:plc:author", { type: "followProfile" }); - - const patched = patchStore.$patchedPosts.get(postURI); - assert.deepEqual(patched.viewer.like, "fake like"); - assert.deepEqual(patched.likeCount, 4); - assert.deepEqual(patched.author.viewer.following, "fake following"); - assert.deepEqual(patched.author.followersCount, 11); - // Underlying store isn't mutated. - assert.deepEqual(dataStore.$posts.get(postURI).likeCount, 3); - assert.deepEqual(dataStore.$posts.get(postURI).author.followersCount, 10); - }); - - it("should return null when the underlying post is absent", () => { - const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); - assert.deepEqual(patchStore.$patchedPosts.get("missing"), null); - }); -}); - -describe("Profile Patches - $patchedProfiles / $patchedDetailedProfiles overlays", () => { - const did = "did:plc:x"; - - it("should overlay profile patches on $profiles reads", () => { - const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); - dataStore.$profiles.set(did, { - did, - followersCount: 4, - viewer: { following: null }, - }); - patchStore.addProfilePatch(did, { type: "followProfile" }); - const patched = patchStore.$patchedProfiles.get(did); - assert.deepEqual(patched.viewer.following, "fake following"); - assert.deepEqual(patched.followersCount, 5); - // Underlying store unchanged. - assert.deepEqual(dataStore.$profiles.get(did).followersCount, 4); - }); - - it("should overlay profile patches on $detailedProfiles reads", () => { - const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); - dataStore.$detailedProfiles.set(did, { - did, - followersCount: 4, - viewer: { following: null }, - description: "hello", - }); - patchStore.addProfilePatch(did, { type: "followProfile" }); - const patched = patchStore.$patchedDetailedProfiles.get(did); - assert.deepEqual(patched.viewer.following, "fake following"); - assert.deepEqual(patched.description, "hello"); - }); - - it("should return null when the underlying profile is absent", () => { - const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); - assert.deepEqual(patchStore.$patchedProfiles.get("missing"), null); - assert.deepEqual(patchStore.$patchedDetailedProfiles.get("missing"), null); - }); -}); - describe("Post Patches - convergence", () => { const postURI = "at://did:test/app.bsky.feed.post/test"; @@ -1277,23 +1217,18 @@ describe("Post Patches - convergence", () => { }); it("should not double-apply when a canonical refresh lands while the patch is installed", () => { - const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); - dataStore.$posts.set(postURI, { - uri: postURI, - likeCount: 5, - viewer: { like: null }, - }); + const patchStore = new PatchStore(); patchStore.addPostPatch(postURI, { type: "addLike" }); - assert.deepEqual(patchStore.$patchedPosts.get(postURI).likeCount, 6); + const stalePost = { uri: postURI, likeCount: 5, viewer: { like: null } }; + assert.deepEqual(applyPostPatches(patchStore, stalePost).likeCount, 6); // Refresh delivers the server state with the like already applied. - dataStore.$posts.set(postURI, { + const refreshedPost = { uri: postURI, likeCount: 6, viewer: { like: "server-like-uri" }, - }); - const patched = patchStore.$patchedPosts.get(postURI); + }; + const patched = applyPostPatches(patchStore, refreshedPost); assert.deepEqual(patched.likeCount, 6); assert.deepEqual(patched.viewer.like, "server-like-uri"); }); @@ -1310,7 +1245,7 @@ describe("Profile Patches - convergence", () => { viewer: { following: "server-follow-uri" }, }; patchStore.addProfilePatch(did, { type: "followProfile" }); - const result = patchStore.applyProfilePatches(followedProfile); + const result = applyProfilePatchesForDid(patchStore, followedProfile); assert.deepEqual(result.followersCount, 11); assert.deepEqual(result.viewer.following, "server-follow-uri"); }); @@ -1319,7 +1254,7 @@ describe("Profile Patches - convergence", () => { const patchStore = new PatchStore(); const profile = { did, followersCount: 10, viewer: { following: null } }; patchStore.addProfilePatch(did, { type: "unfollowProfile" }); - const result = patchStore.applyProfilePatches(profile); + const result = applyProfilePatchesForDid(patchStore, profile); assert.deepEqual(result.followersCount, 10); }); @@ -1330,13 +1265,13 @@ describe("Profile Patches - convergence", () => { viewer: { blocking: "server-block-uri" }, }; patchStore.addProfilePatch(did, { type: "blockProfile" }); - let result = patchStore.applyProfilePatches(blockedProfile); + let result = applyProfilePatchesForDid(patchStore, blockedProfile); assert.deepEqual(result.viewer.blocking, "server-block-uri"); const patchStore2 = new PatchStore(); const plainProfile = { did, viewer: { blocking: null } }; patchStore2.addProfilePatch(did, { type: "unblockProfile" }); - result = patchStore2.applyProfilePatches(plainProfile); + result = applyProfilePatchesForDid(patchStore2, plainProfile); assert.deepEqual(result.viewer.blocking, null); }); }); @@ -1362,7 +1297,7 @@ describe("Message Patches - convergence", () => { type: "addReaction", reaction: { sender: { did: currentUserDid }, value: "👍" }, }); - const patched = patchStore.applyMessagePatches(messageWithReaction); + const patched = applyMessagePatchesForId(patchStore, messageWithReaction); assert.deepEqual(patched.reactions.length, 1); assert.deepEqual(patched.reactions[0].sender.handle, "me.test"); }); @@ -1381,49 +1316,7 @@ describe("Message Patches - convergence", () => { type: "addReaction", reaction: { sender: { did: currentUserDid }, value: "❤️" }, }); - const patched = patchStore.applyMessagePatches(messageWithReaction); + const patched = applyMessagePatchesForId(patchStore, messageWithReaction); assert.deepEqual(patched.reactions.length, 3); }); }); - -describe("Convo Patches - $patchedConvos", () => { - it("should overlay the patch on top of the dataStore convo", () => { - const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); - const convo = { id: "convo-1", muted: false, rev: "rev-1" }; - dataStore.$convos.set("convo-1", convo); - - assert.deepEqual(patchStore.$patchedConvos.get("convo-1").muted, false); - - patchStore.addConvoPatch("convo-1", { - type: "setConvoMuted", - muted: true, - }); - const patched = patchStore.$patchedConvos.get("convo-1"); - assert.deepEqual(patched.muted, true); - assert.deepEqual(patched.rev, "rev-1"); - // Underlying store is not mutated by the patch overlay. - assert.deepEqual(dataStore.$convos.get("convo-1").muted, false); - }); - - it("should return null when the underlying convo is absent", () => { - const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); - assert.deepEqual(patchStore.$patchedConvos.get("nope"), null); - }); - - it("should isolate patches between different convos", () => { - const dataStore = new DataStore(createSessionState(null)); - const patchStore = new PatchStore(dataStore); - dataStore.$convos.set("convo-1", { id: "convo-1", muted: false }); - dataStore.$convos.set("convo-2", { id: "convo-2", muted: false }); - - patchStore.addConvoPatch("convo-1", { - type: "setConvoMuted", - muted: true, - }); - - assert.deepEqual(patchStore.$patchedConvos.get("convo-1").muted, true); - assert.deepEqual(patchStore.$patchedConvos.get("convo-2").muted, false); - }); -}); diff --git a/tests/unit/specs/dataLayer/requests.test.js b/tests/unit/specs/dataLayer/requests.test.js index 479cb268..5f6eaf0c 100644 --- a/tests/unit/specs/dataLayer/requests.test.js +++ b/tests/unit/specs/dataLayer/requests.test.js @@ -544,7 +544,10 @@ describe("loadLabelerInfo", () => { describe("loadMutedProfiles", () => { it("should store muted profiles on first load", async () => { const res = { - mutes: [{ did: "did:plc:a" }, { did: "did:plc:b" }], + mutes: [ + { did: "did:plc:a", handle: "a.test" }, + { did: "did:plc:b", handle: "b.test" }, + ], cursor: "next", }; const mockApi = { getMutes: async () => res }; @@ -563,9 +566,11 @@ describe("loadMutedProfiles", () => { assert.deepEqual(dataStore.$mutedProfiles.get(), res); assert.deepEqual(dataStore.$profiles.get("did:plc:a"), { did: "did:plc:a", + handle: "a.test", }); assert.deepEqual(dataStore.$profiles.get("did:plc:b"), { did: "did:plc:b", + handle: "b.test", }); }); @@ -666,7 +671,10 @@ function makeRequests( describe("loadBlockedProfiles", () => { it("should store blocked profiles on first load", async () => { const res = { - blocks: [{ did: "did:plc:a" }, { did: "did:plc:b" }], + blocks: [ + { did: "did:plc:a", handle: "a.test" }, + { did: "did:plc:b", handle: "b.test" }, + ], cursor: "next", }; const mockApi = { getBlocks: async () => res }; @@ -678,9 +686,11 @@ describe("loadBlockedProfiles", () => { assert.deepEqual(dataStore.$blockedProfiles.get(), res); assert.deepEqual(dataStore.$profiles.get("did:plc:a"), { did: "did:plc:a", + handle: "a.test", }); assert.deepEqual(dataStore.$profiles.get("did:plc:b"), { did: "did:plc:b", + handle: "b.test", }); }); @@ -1220,7 +1230,7 @@ describe("loadSearchTypeahead", () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchProfilesTypeahead: async () => ({ - actors: [{ did: "did:plc:a" }], + actors: [{ did: "did:plc:a", handle: "a.test" }], }), }; const requests = makeRequests(mockApi, dataStore); @@ -1232,6 +1242,7 @@ describe("loadSearchTypeahead", () => { assert.deepEqual(stored.actors[0].did, "did:plc:a"); assert.deepEqual(dataStore.$profiles.get("did:plc:a"), { did: "did:plc:a", + handle: "a.test", }); }); @@ -1276,7 +1287,7 @@ describe("loadSidebarSearchTypeahead", () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchProfilesTypeahead: async () => ({ - actors: [{ did: "did:plc:a" }], + actors: [{ did: "did:plc:a", handle: "a.test" }], }), }; const requests = makeRequests(mockApi, dataStore); @@ -1288,6 +1299,7 @@ describe("loadSidebarSearchTypeahead", () => { assert.deepEqual(stored.actors[0].did, "did:plc:a"); assert.deepEqual(dataStore.$profiles.get("did:plc:a"), { did: "did:plc:a", + handle: "a.test", }); }); @@ -1438,7 +1450,11 @@ describe("loadNotifications", () => { const mockApi = { getNotifications: async () => ({ notifications: [ - { reason: "like", uri: "n1", author: { did: "did:plc:liker" } }, + { + reason: "like", + uri: "n1", + author: { did: "did:plc:liker", handle: "liker.test" }, + }, ], cursor: "next", }), @@ -1452,6 +1468,7 @@ describe("loadNotifications", () => { assert.deepEqual(dataStore.$notifications.get().cursor, "next"); assert.deepEqual(dataStore.$profiles.get("did:plc:liker"), { did: "did:plc:liker", + handle: "liker.test", }); }); @@ -1520,7 +1537,11 @@ describe("loadNotifications", () => { const mockApi = { getNotifications: async () => ({ notifications: [ - { reason: "like", uri: "n1", author: { did: "did:plc:liker" } }, + { + reason: "like", + uri: "n1", + author: { did: "did:plc:liker", handle: "liker.test" }, + }, ], cursor: "next", seenAt: "2025-01-15T10:00:00.000Z", @@ -1599,7 +1620,11 @@ describe("loadNotifications", () => { const mockApi = { getNotifications: async () => ({ notifications: [ - { reason: "like", uri: "n1", author: { did: "did:plc:liker" } }, + { + reason: "like", + uri: "n1", + author: { did: "did:plc:liker", handle: "liker.test" }, + }, ], cursor: "next", }), diff --git a/tests/unit/specs/templates/largePost.template.test.js b/tests/unit/specs/templates/largePost.template.test.js index 5e3f4c84..76c6776d 100644 --- a/tests/unit/specs/templates/largePost.template.test.js +++ b/tests/unit/specs/templates/largePost.template.test.js @@ -121,6 +121,105 @@ describe("largePostTemplate", () => { }); }); +describe("largePostTemplate - follow button", () => { + function postWithAuthorViewer(viewer) { + return { ...post, author: { ...post.author, viewer } }; + } + + it("should not render a follow button by default", () => { + const result = largePostTemplate({ post, ...baseProps }); + const container = document.createElement("div"); + render(result, container); + assert.deepEqual( + container.querySelector("[data-testid='follow-button']"), + null, + ); + }); + + it("should render a follow button when showFollowButton is set", () => { + const result = largePostTemplate({ + post: postWithAuthorViewer({}), + ...baseProps, + showFollowButton: true, + }); + const container = document.createElement("div"); + render(result, container); + const button = container.querySelector("[data-testid='follow-button']"); + assert(button !== null); + assert.deepEqual(button.getAttribute("data-teststate"), "follow"); + assert(!button.disabled); + }); + + it("should show follow-back state when the author follows the user", () => { + const result = largePostTemplate({ + post: postWithAuthorViewer({ followedBy: "at://follow-back-uri" }), + ...baseProps, + showFollowButton: true, + }); + const container = document.createElement("div"); + render(result, container); + const button = container.querySelector("[data-testid='follow-button']"); + assert.deepEqual(button.getAttribute("data-teststate"), "follow-back"); + }); + + it("should show following state when the user follows the author", () => { + const result = largePostTemplate({ + post: postWithAuthorViewer({ following: "at://follow-uri" }), + ...baseProps, + showFollowButton: true, + }); + const container = document.createElement("div"); + render(result, container); + const button = container.querySelector("[data-testid='follow-button']"); + assert.deepEqual(button.getAttribute("data-teststate"), "following"); + }); + + it("should call onClickFollow with the author and the toggled follow state", () => { + const followablePost = postWithAuthorViewer({}); + const calls = []; + const result = largePostTemplate({ + post: followablePost, + ...baseProps, + showFollowButton: true, + onClickFollow: (profile, doFollow) => calls.push([profile, doFollow]), + }); + const container = document.createElement("div"); + render(result, container); + container.querySelector("[data-testid='follow-button']").click(); + assert.deepEqual(calls, [[followablePost.author, true]]); + }); + + it("should call onClickFollow with false when already following", () => { + const followingPost = postWithAuthorViewer({ + following: "at://follow-uri", + }); + const calls = []; + const result = largePostTemplate({ + post: followingPost, + ...baseProps, + showFollowButton: true, + onClickFollow: (profile, doFollow) => calls.push([profile, doFollow]), + }); + const container = document.createElement("div"); + render(result, container); + container.querySelector("[data-testid='follow-button']").click(); + assert.deepEqual(calls, [[followingPost.author, false]]); + }); + + it("should disable the follow button while a follow is pending", () => { + const result = largePostTemplate({ + post: postWithAuthorViewer({}), + ...baseProps, + showFollowButton: true, + isFollowPending: true, + }); + const container = document.createElement("div"); + render(result, container); + const button = container.querySelector("[data-testid='follow-button']"); + assert(button.disabled); + }); +}); + describe("largePostTemplate - rich text", () => { it("should truncate long URLs in post text", () => { const url = "https://example.com/very/long/path/to/some/page"; -- 2.51.2