diff --git a/src/css/style.css b/src/css/style.css index 7c04a952..99601750 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -135,6 +135,13 @@ html { --avatar-border-color: var(--generic-border-color); --reply-context-line-color: light-dark(var(--lighter-gray), var(--mid-gray)); --reply-context-line-width: 2px; + --thread-tree-post-padding-x: 16px; + --thread-tree-avatar-center: calc( + var(--thread-tree-post-padding-x) + var(--avatar-size-thread-tree) / 2 + ); + --thread-tree-column-width: calc( + var(--thread-tree-avatar-center) + var(--reply-context-line-width) / 2 + ); /* button colors */ --primary-button-color: var(--highlight-color); @@ -156,6 +163,7 @@ html { /* avatar sizes */ --avatar-size-quoted-post: 16px; --avatar-size-footer-nav: 24px; + --avatar-size-thread-tree: 24px; --avatar-size-followed-by: 24px; --avatar-size-reply-prompt: 28px; --avatar-size-message: 32px; @@ -1555,6 +1563,10 @@ tab-bar.is-scroll-frozen { } } +.sidebar-profile-avatar .avatar { + display: flex; +} + .sidebar-profile-avatar .avatar-image-frame { width: var(--avatar-size-sidebar); height: var(--avatar-size-sidebar); @@ -2530,6 +2542,110 @@ container-link { height: 100px; } +.post-thread-tree { + border-bottom: var(--hair) solid var(--post-border-color); +} + +.post-thread-tree-row { + display: flex; + flex-direction: row; +} + +.post-thread-tree-row.is-depth-1:not(:first-child) { + border-top: var(--hair) solid var(--post-border-color); +} + +.tree-indent-column { + flex-shrink: 0; + width: var(--thread-tree-column-width); + border-right: var(--reply-context-line-width) solid + var(--reply-context-line-color); +} + +.tree-indent-column.is-skipped { + border-right-width: 0; +} + +.tree-row-content { + position: relative; + flex: 1; + min-width: 0; +} + +.post-thread-tree-row .post-content-left { + width: var(--avatar-size-thread-tree); +} + +.post-thread-tree-row .small-post .avatar-image-frame { + width: var(--avatar-size-thread-tree); + height: var(--avatar-size-thread-tree); +} + +.tree-elbow { + position: absolute; + left: calc(var(--reply-context-line-width) * -1); + top: 0; + width: calc( + var(--thread-tree-post-padding-x) + var(--reply-context-line-width) + ); + height: calc( + 12px + var(--avatar-size-thread-tree) / 2 + + var(--reply-context-line-width) / 2 + ); + border-left: var(--reply-context-line-width) solid + var(--reply-context-line-color); + border-bottom: var(--reply-context-line-width) solid + var(--reply-context-line-color); + border-bottom-left-radius: 8px; + pointer-events: none; +} + +.tree-child-line { + position: absolute; + left: calc( + var(--thread-tree-avatar-center) - var(--reply-context-line-width) / 2 + ); + top: calc(12px + var(--avatar-size-thread-tree) + 4px); + bottom: 0; + width: var(--reply-context-line-width); + background-color: var(--reply-context-line-color); + pointer-events: none; +} + +.tree-read-more-elbow { + flex-shrink: 0; + margin-left: calc( + var(--thread-tree-avatar-center) - var(--reply-context-line-width) / 2 + ); + width: var(--thread-tree-post-padding-x); + height: 18px; + border-left: var(--reply-context-line-width) solid + var(--reply-context-line-color); + border-bottom: var(--reply-context-line-width) solid + var(--reply-context-line-color); + border-bottom-left-radius: 8px; +} + +.tree-read-more-link { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 8px 12px 4px; + font-size: 14px; + color: var(--text-color-muted); +} + +.tree-read-more-link app-icon { + width: 18px; + height: 18px; +} + +@media (hover: hover) { + .tree-read-more-link:hover { + text-decoration: underline; + } +} + .reply-to-author { font-size: 14px; color: var(--text-color-muted); diff --git a/src/img/icons/majesticons/arrow-down-circle-line.svg b/src/img/icons/majesticons/arrow-down-circle-line.svg index 9bd5170c..579682dd 100644 --- a/src/img/icons/majesticons/arrow-down-circle-line.svg +++ b/src/img/icons/majesticons/arrow-down-circle-line.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/img/icons/majesticons/arrow-down-circle.svg b/src/img/icons/majesticons/arrow-down-circle.svg index af775d17..bae66747 100644 --- a/src/img/icons/majesticons/arrow-down-circle.svg +++ b/src/img/icons/majesticons/arrow-down-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/img/icons/majesticons/arrow-left-circle-line.svg b/src/img/icons/majesticons/arrow-left-circle-line.svg index 579682dd..f478a2d9 100644 --- a/src/img/icons/majesticons/arrow-left-circle-line.svg +++ b/src/img/icons/majesticons/arrow-left-circle-line.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/img/icons/majesticons/arrow-left-circle.svg b/src/img/icons/majesticons/arrow-left-circle.svg index bae66747..1f032f3f 100644 --- a/src/img/icons/majesticons/arrow-left-circle.svg +++ b/src/img/icons/majesticons/arrow-left-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/img/icons/majesticons/arrow-right-circle-line.svg b/src/img/icons/majesticons/arrow-right-circle-line.svg index f478a2d9..4e5889ed 100644 --- a/src/img/icons/majesticons/arrow-right-circle-line.svg +++ b/src/img/icons/majesticons/arrow-right-circle-line.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/img/icons/majesticons/arrow-right-circle.svg b/src/img/icons/majesticons/arrow-right-circle.svg index 1f032f3f..acedb901 100644 --- a/src/img/icons/majesticons/arrow-right-circle.svg +++ b/src/img/icons/majesticons/arrow-right-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/img/icons/majesticons/arrow-up-circle-line.svg b/src/img/icons/majesticons/arrow-up-circle-line.svg index 4e5889ed..9bd5170c 100644 --- a/src/img/icons/majesticons/arrow-up-circle-line.svg +++ b/src/img/icons/majesticons/arrow-up-circle-line.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/img/icons/majesticons/arrow-up-circle.svg b/src/img/icons/majesticons/arrow-up-circle.svg index acedb901..af775d17 100644 --- a/src/img/icons/majesticons/arrow-up-circle.svg +++ b/src/img/icons/majesticons/arrow-up-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/js/dataHelpers.js b/src/js/dataHelpers.js index 584d6b39..7f6c926f 100644 --- a/src/js/dataHelpers.js +++ b/src/js/dataHelpers.js @@ -277,6 +277,24 @@ export function createNestedThreadViewPost(posts) { return current; } +export function insertReplyIntoThread(node, parentUri, reply) { + if (!node.post || !node.replies) { + return node; + } + if (node.post.uri === parentUri) { + return { ...node, replies: [reply, ...node.replies] }; + } + let didChange = false; + const replies = node.replies.map((child) => { + const updatedChild = insertReplyIntoThread(child, parentUri, reply); + if (updatedChild !== child) { + didChange = true; + } + return updatedChild; + }); + return didChange ? { ...node, replies } : node; +} + export function flattenParents(postThread) { const parents = []; let current = postThread.parent; diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index e8e5c265..8385e9b0 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -459,6 +459,11 @@ export class Derived extends ReactiveStore { if (!preferences) return []; return preferences.getRecentGifs(); }); + this.$threadView = new Signal.Computed(() => { + const preferences = this.$preferences.get(); + if (!preferences) return null; + return preferences.getThreadView(); + }); this.$trends = new Signal.Computed(() => this.dataStore.$trends.get()); this.$chatActorStatus = new Signal.Computed(() => this.dataStore.$chatActorStatus.get(), diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 1e9e56fb..4535be98 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -16,10 +16,12 @@ import { valueForPinnedItem, buildCdnUrl, getReplyRootFromPost, + insertReplyIntoThread, } from "/js/dataHelpers.js"; import { addPostLanguagesToHistory, batch, + isNil, getCurrentTimestamp, truncateGraphemes, wait, @@ -636,6 +638,23 @@ export class Mutations { } } + async setThreadView(view) { + const patchId = this.patchStore.addPreferencePatch({ + type: "setThreadView", + view, + }); + const preferences = await this.preferencesProvider.requirePreferences(); + const newPreferences = preferences.setThreadView(view); + try { + await this.preferencesProvider.updatePreferences(newPreferences); + } catch (error) { + console.error(error); + throw error; + } finally { + this.patchStore.removePreferencePatch(patchId); + } + } + async addRecentGif(gif) { const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.addRecentGif(gif); @@ -2013,18 +2032,30 @@ export class Mutations { post.viewer.priorityReply = true; } this.dataStore.setPosts(hydratedPosts); + const replyToPost = replyTo + ? this.dataStore.$posts.get(replyTo.uri) + : null; + if (!isNil(replyToPost?.replyCount)) { + this.dataStore.$posts.set(replyTo.uri, { + ...replyToPost, + replyCount: replyToPost.replyCount + 1, + }); + } const rootPost = hydratedPosts[0]; - // If it's a reply, update the reply post thread in the store + // If it's a reply, add it under the replied-to post in every cached thread that contains it if (replyTo) { - const replyPostThread = this.dataStore.$postThreads.get(replyTo.uri); - if (replyPostThread) { - this.dataStore.setPostThread(replyTo.uri, { - ...replyPostThread, - replies: [ - createNestedThreadViewPost(hydratedPosts), - ...replyPostThread.replies, - ], - }); + const newReply = createNestedThreadViewPost(hydratedPosts); + for (const [uri, postThread] of [ + ...this.dataStore.$postThreads.entries(), + ]) { + const updatedPostThread = insertReplyIntoThread( + postThread, + replyTo.uri, + newReply, + ); + if (updatedPostThread !== postThread) { + this.dataStore.setPostThread(uri, updatedPostThread); + } } } const { repo: did } = parseUri(rootPost.uri); @@ -2054,6 +2085,14 @@ export class Mutations { async deletePost(post) { // no optimistic update await this.api.deletePost(post); + const parentUri = post.record.reply?.parent.uri; + const parentPost = parentUri ? this.dataStore.$posts.get(parentUri) : null; + if (!isNil(parentPost?.replyCount)) { + this.dataStore.$posts.set(parentUri, { + ...parentPost, + replyCount: Math.max(0, parentPost.replyCount - 1), + }); + } // Replace the post with a not found post. // This *should* remove the post from all relevant places in the UI. this.dataStore.$posts.set(post.uri, createNotFoundPost(post.uri)); diff --git a/src/js/dataLayer/patchStore.js b/src/js/dataLayer/patchStore.js index 40ba075e..f00824e0 100644 --- a/src/js/dataLayer/patchStore.js +++ b/src/js/dataLayer/patchStore.js @@ -451,6 +451,8 @@ export class PatchStore extends ReactiveStore { return preferences.removeRecentSearch(patchBody.q); case "removeRecentSearchProfile": return preferences.removeRecentSearchProfile(patchBody.did); + case "setThreadView": + return preferences.setThreadView(patchBody.view); default: throw new Error("Unknown patch type", patchBody.type); } diff --git a/src/js/preferences.js b/src/js/preferences.js index 358fd45d..f5d5028c 100644 --- a/src/js/preferences.js +++ b/src/js/preferences.js @@ -24,6 +24,7 @@ export const INSTALLED_PLUGINS_PREF_TYPE = export const SEARCH_HISTORY_PREF_TYPE = "app.bsky.actor.defs#improSearchHistoryPref"; export const GIF_HISTORY_PREF_TYPE = "app.bsky.actor.defs#improGifHistoryPref"; +export const THREAD_VIEW_PREF_TYPE = "app.bsky.actor.defs#threadViewPref"; function getContentTextFromEmbed(embed) { const texts = []; @@ -611,6 +612,23 @@ export class Preferences { return clone; } + getThreadView() { + const pref = Preferences.getThreadViewPreference(this.obj); + return pref?.lab_treeViewEnabled === true ? "tree" : "linear"; + } + + setThreadView(view) { + const clone = this.clone(); + let pref = Preferences.getThreadViewPreference(clone.obj); + if (!pref) { + pref = { $type: THREAD_VIEW_PREF_TYPE }; + clone.obj.push(pref); + } + // NOTE: LEXICON DEVIATION - lab_treeViewEnabled isn't in the lexicon, but it's what social-app stores + pref.lab_treeViewEnabled = view === "tree"; + return clone; + } + hasMutedWord({ text, facets, embed, languages, author }) { const mutedWordsPreference = Preferences.getMutedWordsPreference(this.obj); if (!mutedWordsPreference) { @@ -806,6 +824,10 @@ export class Preferences { ); } + static getThreadViewPreference(obj) { + return Preferences.getPreferenceByType(obj, THREAD_VIEW_PREF_TYPE); + } + static getSavedFeedsPreference(obj) { return Preferences.getPreferenceByType( obj, diff --git a/src/js/views/postThread.view.js b/src/js/views/postThread.view.js index 83a9ac21..8c1868e2 100644 --- a/src/js/views/postThread.view.js +++ b/src/js/views/postThread.view.js @@ -1,7 +1,13 @@ import { html, render } from "/js/lib/lit-html.js"; import { resolveDidFromHandleOrDid } from "/js/atproto.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; -import { sortBy, maxBy, pinScrollPosition } from "/js/utils.js"; +import { + sortBy, + maxBy, + pinScrollPosition, + isMobileViewport, + classnames, +} from "/js/utils.js"; import { bindToPage, pageEffect, @@ -18,7 +24,6 @@ import { flattenParents, isBlockedPost, isNotFoundPost, - isUnavailablePost, isEmptyPost, isMutedPost, getReplyRootFromPost, @@ -33,6 +38,10 @@ import { linkToPostFromUri } from "/js/navigation.js"; import { Signal, ReactiveStore } from "/js/signals.js"; import { tryAgainButtonTemplate } from "/js/templates/tryAgainButton.template.js"; +const THREAD_TREE_MAX_DEPTH_MOBILE = 4; +const THREAD_TREE_MAX_DEPTH_DESKTOP = 6; +const THREAD_TREE_BRANCHING_FACTOR = 10; + export default async function postThreadView({ root, router, @@ -178,42 +187,111 @@ export default async function postThreadView({ return !!post.viewer?.like ? likeCount - 1 : likeCount; } - function buildReplyChains(replies, postAuthor) { - const replyChains = []; - for (const reply of replies) { - if (doShowReply(reply)) { - replyChains.push(buildReplyChain(reply)); - } - } - let sortedReplyChains = sortBy( - replyChains, - (chain) => getLikesWithoutUser(chain[0].post), + function sortReplies(replies, postAuthor) { + let sortedReplies = sortBy( + replies, + (reply) => getLikesWithoutUser(reply.post), { direction: "desc", }, ); // Put replies by the post author first if (postAuthor) { - sortedReplyChains = [ - ...sortedReplyChains.filter( - (chain) => chain[0].post.author?.did === postAuthor.did, + sortedReplies = [ + ...sortedReplies.filter( + (reply) => reply.post.author?.did === postAuthor.did, ), - ...sortedReplyChains.filter( - (chain) => chain[0].post.author?.did !== postAuthor.did, + ...sortedReplies.filter( + (reply) => reply.post.author?.did !== postAuthor.did, ), ]; } // If there's a recent reply from the user, put it at the top - const recentReplyFromUser = sortedReplyChains.find( - (chain) => chain[0].post.viewer?.priorityReply, + const recentReplyFromUser = sortedReplies.find( + (reply) => reply.post.viewer?.priorityReply, ); if (recentReplyFromUser) { - sortedReplyChains = [ + sortedReplies = [ recentReplyFromUser, - ...sortedReplyChains.filter((chain) => chain !== recentReplyFromUser), + ...sortedReplies.filter((reply) => reply !== recentReplyFromUser), ]; } - return sortedReplyChains; + return sortedReplies; + } + + function buildReplyChains(replies, postAuthor) { + return sortReplies(getShownReplies(replies), postAuthor).map((reply) => + buildReplyChain(reply), + ); + } + + function buildThreadTree(postThread, { maxDepth, postAuthor }) { + function walk( + reply, + depth, + parentSkippedIndices, + isLastSibling, + parentHasMore, + rows, + ) { + const skippedIndentIndices = new Set(parentSkippedIndices); + if (depth > 1 && isLastSibling && !parentHasMore) { + skippedIndentIndices.add(depth - 2); + } + const replies = depth < maxDepth ? (reply.replies ?? []) : []; + const shownReplies = sortReplies(getShownReplies(replies), postAuthor); + const keptReplies = shownReplies.slice(0, THREAD_TREE_BRANCHING_FACTOR); + const numLoadedReplies = replies.filter( + (child) => !isNotFoundPost(child.post ?? child), + ).length; + const moreReplies = + Math.max(0, (reply.post.replyCount ?? 0) - numLoadedReplies) + + (shownReplies.length - keptReplies.length); + rows.push({ + type: "post", + key: reply.post.uri, + reply, + depth, + skippedIndentIndices, + showChildReplyLine: keptReplies.length > 0 || moreReplies > 0, + }); + keptReplies.forEach((child, i) => { + walk( + child, + depth + 1, + skippedIndentIndices, + i === keptReplies.length - 1, + moreReplies > 0, + rows, + ); + }); + if (moreReplies > 0) { + rows.push({ + type: "readMore", + key: `readMore:${reply.post.uri}`, + depth, + ownerUri: reply.post.uri, + moreReplies, + skippedIndentIndices, + }); + } + } + + const topLevelReplies = postThread.replies ?? []; + const items = []; + for (const reply of sortReplies( + getShownReplies(topLevelReplies), + postAuthor, + )) { + walk(reply, 1, new Set(), true, false, items); + } + const hiddenItems = []; + for (const reply of topLevelReplies.filter( + (reply) => !doShowReply(reply) && doPutReplyInHiddenSection(reply), + )) { + walk(reply, 1, new Set(), true, false, hiddenItems); + } + return { items, hiddenItems }; } function getReplyContext(replyIndex, numReplies) { @@ -277,13 +355,164 @@ export default async function postThreadView({ return false; } - function postThreadRepliesTemplate({ replies, postAuthor, currentUser }) { + function linearRepliesTemplate({ replies, postAuthor, currentUser }) { const hiddenSectionReplies = replies.filter((reply) => doPutReplyInHiddenSection(reply), ); const replyChains = buildReplyChains(replies, postAuthor); const isEmpty = replyChains.length === 0 && hiddenSectionReplies.length === 0; + const content = html`
+ ${replyChains.map((replyChain, i) => + // there can be a lot of images in a reply chain, so lazy load them after the first few + replyChainTemplate({ + replyChain, + currentUser, + lazyLoadImages: i > 20, + }), + )} +
+ ${hiddenSectionReplies.length > 0 + ? html` + ${hiddenSectionReplies.map((reply) => + smallPostTemplate({ + post: reply.post, + currentUser, + isAuthenticated, + isUserPost: currentUser?.did === reply.post?.author?.did, + postInteractionHandler, + ignoreContentWarning: true, + ignoreMuteWarning: true, + lazyLoadImages: true, + pluginService, + }), + )} + ` + : ""}`; + return { isEmpty, content }; + } + + function treeIndentColumnsTemplate({ depth, skippedIndentIndices }) { + return Array.from({ length: Math.max(0, depth - 1) }).map( + (_, i) => + html``, + ); + } + + function treeReplyRowTemplate({ + row, + currentUser, + lazyLoadImages, + isHiddenRoot, + }) { + const post = row.reply.post; + return html`
+ ${treeIndentColumnsTemplate(row)} +
+ ${row.depth > 1 ? html`` : ""} + ${row.showChildReplyLine + ? html`` + : ""} + ${smallPostTemplate({ + post, + currentUser, + isAuthenticated, + isUserPost: currentUser?.did === post.author?.did, + postInteractionHandler, + replyContext: null, + postNumbering: row.reply.postNumbering, + ignoreContentWarning: isHiddenRoot, + ignoreMuteWarning: isHiddenRoot, + lazyLoadImages, + pluginService, + })} +
+
`; + } + + function treeReadMoreTemplate({ row }) { + return html`
+ ${treeIndentColumnsTemplate(row)} + + + + Read ${row.moreReplies} more + ${row.moreReplies === 1 ? "reply" : "replies"} + +
`; + } + + function treeRowsTemplate({ rows, currentUser, isHiddenSection }) { + return rows.map((row, i) => + row.type === "readMore" + ? treeReadMoreTemplate({ row }) + : treeReplyRowTemplate({ + row, + currentUser, + lazyLoadImages: isHiddenSection || i > 20, + isHiddenRoot: isHiddenSection && row.depth === 1, + }), + ); + } + + function treeRepliesTemplate({ postThread, postAuthor, currentUser }) { + const { items, hiddenItems } = buildThreadTree(postThread, { + maxDepth: isMobileViewport() + ? THREAD_TREE_MAX_DEPTH_MOBILE + : THREAD_TREE_MAX_DEPTH_DESKTOP, + postAuthor, + }); + const isEmpty = items.length === 0 && hiddenItems.length === 0; + const content = html`
+ ${treeRowsTemplate({ + rows: items, + currentUser, + isHiddenSection: false, + })} +
+ ${hiddenItems.length > 0 + ? html` + ${treeRowsTemplate({ + rows: hiddenItems, + currentUser, + isHiddenSection: true, + })} + ` + : ""}`; + return { isEmpty, content }; + } + + function postThreadRepliesTemplate({ + postThread, + postAuthor, + currentUser, + threadView, + }) { + const { isEmpty, content } = + threadView === "tree" + ? treeRepliesTemplate({ postThread, postAuthor, currentUser }) + : linearRepliesTemplate({ + replies: postThread.replies, + postAuthor, + currentUser, + }); return html`
${isEmpty @@ -297,34 +526,7 @@ export default async function postThreadView({ context-uri=${postUri} .pluginService=${pluginService} > -
- ${replyChains.map((replyChain, i) => - // there can be a lot of images in a reply chain, so lazy load them after the first few - replyChainTemplate({ - replyChain, - currentUser, - lazyLoadImages: i > 20, - }), - )} -
- ${hiddenSectionReplies.length > 0 - ? html` - ${hiddenSectionReplies.map((reply) => - smallPostTemplate({ - post: reply.post, - currentUser, - isAuthenticated, - isUserPost: - currentUser?.did === reply.post?.author?.did, - postInteractionHandler, - ignoreContentWarning: true, - ignoreMuteWarning: true, - lazyLoadImages: true, - pluginService, - }), - )} - ` - : ""} `} + ${content}`} { if (loadError) { - return threadLoadErrorTemplate({ onRetry: retryLoadPostThread }); + return threadLoadErrorTemplate({ + onRetry: retryLoadPostThread, + }); } if (hiddenUnauthenticated) { return ""; } - if (replies) { + if (replies && threadView !== null) { return postThreadRepliesTemplate({ - replies, + postThread, postAuthor, currentUser, + threadView, }); } const numReplies = mainPost?.replyCount; @@ -632,6 +838,7 @@ export default async function postThreadView({ const postThreadRequestStatus = dataLayer.requests.statusStore.$statuses.get("loadPostThread-" + postUri); const hasFollowedInView = state.$hasFollowedInView.get(); + const threadView = dataLayer.derived.$threadView.get(); render( html`
@@ -647,6 +854,7 @@ export default async function postThreadView({ currentUser, hasFollowedInView, loadError, + threadView, }); } else { return threadSkeletonTemplate(); @@ -689,6 +897,9 @@ export default async function postThreadView({ onPageShow(root, async ({ action, scrollY }) => { userHasScrolled = false; + dataLayer.preferencesProvider.requirePreferences().catch((error) => { + console.warn("Failed to load preferences", error); + }); if (action === "restore") { window.scrollTo(0, scrollY); } else { diff --git a/src/js/views/settings/contentAndMedia.view.js b/src/js/views/settings/contentAndMedia.view.js index b6ca079d..289c9684 100644 --- a/src/js/views/settings/contentAndMedia.view.js +++ b/src/js/views/settings/contentAndMedia.view.js @@ -1,7 +1,8 @@ import { html, render } from "/js/lib/lit-html.js"; -import { pageEffect, bindPageTitle } from "/js/router.js"; +import { pageEffect, bindPageTitle, onPageShow } from "/js/router.js"; import { headerTemplate } from "/js/templates/header.template.js"; import { deviceState } from "/js/deviceState.js"; +import { showToast } from "/js/toasts.js"; import "/js/components/toggle-switch.js"; export default async function settingsContentAndMediaView({ @@ -18,11 +19,26 @@ export default async function settingsContentAndMediaView({ dataLayer.mutations.setTrendingHidden(!shown); } + async function handleThreadViewChange(isTree) { + try { + await dataLayer.mutations.setThreadView(isTree ? "tree" : "linear"); + } catch (error) { + showToast("Failed to update thread view", { style: "error" }); + } + } + + onPageShow(root, () => { + dataLayer.preferencesProvider.requirePreferences().catch((error) => { + console.warn("Failed to load preferences", error); + }); + }); + bindPageTitle(root, () => "Content and media"); pageEffect(root, () => { const autoplayDisabled = deviceState.$autoplayDisabled.get(); const trendingHidden = dataLayer.derived.$trendingHidden.get(); + const threadView = dataLayer.derived.$threadView.get(); render( html`
${headerTemplate({ @@ -30,6 +46,27 @@ export default async function settingsContentAndMediaView({ backButtonFallbackRoute: "/settings", })}
+
+
+

Threaded replies

+

+ Show post replies in a threaded tree view. +

+
+
+ + handleThreadViewChange(event.detail.checked)} + > +
+

Autoplay videos and GIFs

diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index 8a193da4..229399c7 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -120,9 +120,12 @@ export class MockServer { // Seeded/captured improSearchHistoryPref state; null means the preference // is absent from getPreferences and no putPreferences has written it. this.searchHistory = null; + // Seeded/captured app.bsky.actor.defs#threadViewPref; null means absent. + this.threadViewPref = null; this.getProfilesDelayMs = 0; this.getStarterPackDelayMs = 0; this.putPreferencesDelayMs = 0; + this.getPreferencesDelayMs = 0; // Override the source served for the local test plugin's main.js; defaults // to the standard fixture when null. this.localPluginSource = null; @@ -565,6 +568,10 @@ export class MockServer { } } + setThreadViewPref(threadViewPref) { + this.threadViewPref = threadViewPref; + } + setPostThreadOther(postUri, threadOther) { this.postThreadOthers.set(postUri, threadOther); } @@ -1138,92 +1145,108 @@ export class MockServer { }), ); - await page.route("**/xrpc/app.bsky.actor.getPreferences*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - preferences: [ - { - $type: "app.bsky.actor.defs#savedFeedsPrefV2", - items: [ - { - type: "timeline", - value: "following", - pinned: true, - id: "timeline-following", - }, - ...this.pinnedFeedUris.map((uri) => ({ - type: "feed", - value: uri, - pinned: true, - id: uri, - })), - ...this.pinnedListUris.map((uri) => ({ - type: "list", - value: uri, - pinned: true, - id: uri, - })), - ...this.savedFeedUris.map((uri) => ({ - type: "feed", - value: uri, - pinned: false, - id: uri, - })), - ], - }, - ...(this.hiddenPostUris.length > 0 - ? [ - { - $type: "app.bsky.actor.defs#improHiddenPostsPref", - items: this.hiddenPostUris, - }, - ] - : []), - ...(this.searchHistory - ? [ - { - $type: "app.bsky.actor.defs#improSearchHistoryPref", - ...this.searchHistory, - }, - ] - : []), - ...(this.labelerSubscriptions.length > 0 - ? [ - { - $type: "app.bsky.actor.defs#labelersPref", - labelers: this.labelerSubscriptions.map((did) => ({ - did, - })), - }, - ] - : []), - ...this.contentLabelPrefs, - ...(this.mutedWords.length > 0 - ? [ - { - $type: "app.bsky.actor.defs#mutedWordsPref", - items: this.mutedWords, - }, - ] - : []), - ...[...this.pluginSettings.entries()].map(([pluginId, data]) => ({ - $type: "app.bsky.actor.defs#improPluginSettingsPref", - pluginId, - data, - })), - ...(this.installedPlugins.length > 0 - ? [ + await page.route( + "**/xrpc/app.bsky.actor.getPreferences*", + async (route) => { + if (this.getPreferencesDelayMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, this.getPreferencesDelayMs), + ); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + preferences: [ + { + $type: "app.bsky.actor.defs#savedFeedsPrefV2", + items: [ { - $type: "app.bsky.actor.defs#improInstalledPluginsPref", - plugins: this.installedPlugins, + type: "timeline", + value: "following", + pinned: true, + id: "timeline-following", }, - ] - : []), - ], - }), - }), + ...this.pinnedFeedUris.map((uri) => ({ + type: "feed", + value: uri, + pinned: true, + id: uri, + })), + ...this.pinnedListUris.map((uri) => ({ + type: "list", + value: uri, + pinned: true, + id: uri, + })), + ...this.savedFeedUris.map((uri) => ({ + type: "feed", + value: uri, + pinned: false, + id: uri, + })), + ], + }, + ...(this.hiddenPostUris.length > 0 + ? [ + { + $type: "app.bsky.actor.defs#improHiddenPostsPref", + items: this.hiddenPostUris, + }, + ] + : []), + ...(this.searchHistory + ? [ + { + $type: "app.bsky.actor.defs#improSearchHistoryPref", + ...this.searchHistory, + }, + ] + : []), + ...(this.threadViewPref + ? [ + { + $type: "app.bsky.actor.defs#threadViewPref", + ...this.threadViewPref, + }, + ] + : []), + ...(this.labelerSubscriptions.length > 0 + ? [ + { + $type: "app.bsky.actor.defs#labelersPref", + labelers: this.labelerSubscriptions.map((did) => ({ + did, + })), + }, + ] + : []), + ...this.contentLabelPrefs, + ...(this.mutedWords.length > 0 + ? [ + { + $type: "app.bsky.actor.defs#mutedWordsPref", + items: this.mutedWords, + }, + ] + : []), + ...[...this.pluginSettings.entries()].map(([pluginId, data]) => ({ + $type: "app.bsky.actor.defs#improPluginSettingsPref", + pluginId, + data, + })), + ...(this.installedPlugins.length > 0 + ? [ + { + $type: "app.bsky.actor.defs#improInstalledPluginsPref", + plugins: this.installedPlugins, + }, + ] + : []), + ], + }), + }); + }, ); await page.route("**/xrpc/app.bsky.labeler.getServices*", (route) => { @@ -3144,6 +3167,13 @@ export class MockServer { profiles: searchHistoryPref.profiles || [], }; } + const threadViewPref = body?.preferences?.find( + (p) => p.$type === "app.bsky.actor.defs#threadViewPref", + ); + if (threadViewPref) { + const { $type, ...rest } = threadViewPref; + this.threadViewPref = rest; + } const labelersPref = body?.preferences?.find( (p) => p.$type === "app.bsky.actor.defs#labelersPref", ); diff --git a/tests/e2e/specs/views/postThread.view.test.js b/tests/e2e/specs/views/postThread.view.test.js index b585a6bb..bbd6778d 100644 --- a/tests/e2e/specs/views/postThread.view.test.js +++ b/tests/e2e/specs/views/postThread.view.test.js @@ -1,10 +1,8 @@ import { test, expect } from "../../base.js"; import { login } from "../../helpers.js"; import { MockServer } from "../../mockServer.js"; -import { - createPost, - createThreadViewPost, -} from "../../../shared/factories.js"; +import { userProfile } from "../../testData.js"; +import { createPost, createThreadViewPost } from "../../../shared/factories.js"; const postUri = "at://did:plc:author1/app.bsky.feed.post/abc123"; @@ -979,7 +977,9 @@ test.describe("Post thread view", () => { }); await expect(view).toContainText("This is the main post"); await expect( - view.locator('[data-testid="large-post"] [data-testid="repost-button"]'), + view.locator( + '[data-testid="large-post"] [data-testid="repost-button"]', + ), ).toBeVisible(); await expect( view.locator('[data-testid="thread-load-error"]'), @@ -1031,7 +1031,9 @@ test.describe("Post thread view", () => { }); await expect(view).toContainText("This is the main post"); await expect( - view.locator('[data-testid="large-post"] [data-testid="repost-button"]'), + view.locator( + '[data-testid="large-post"] [data-testid="repost-button"]', + ), ).not.toBeAttached(); await expect( view.locator('[data-testid="thread-load-error"]'), @@ -1276,6 +1278,339 @@ test.describe("Post thread view", () => { }); }); + test.describe("Threaded view", () => { + // Builds a linear reply chain under the main post: reply depth 1 → depth n + function setupReplyChain(mockServer, depth) { + const replies = Array.from({ length: depth }).map((_, index) => + createPost({ + uri: `at://did:plc:nested${index}/app.bsky.feed.post/nested${index}`, + text: `Nested reply ${index + 1}`, + authorHandle: `nested${index}.bsky.social`, + replyCount: index < depth - 1 ? 1 : 0, + }), + ); + let node = null; + for (const reply of replies.toReversed()) { + node = createThreadViewPost({ + post: reply, + replies: node ? [node] : [], + }); + } + const anchorPost = createPost({ + uri: postUri, + text: "Threaded main post", + authorHandle: "author1.bsky.social", + replyCount: 2, + }); + const siblingReply = createPost({ + uri: "at://did:plc:sibling/app.bsky.feed.post/sibling", + text: "Sibling reply", + authorHandle: "sibling.bsky.social", + likeCount: 0, + }); + mockServer.addPosts([anchorPost, siblingReply, ...replies]); + mockServer.setPostThread( + postUri, + createThreadViewPost({ + post: anchorPost, + replies: [ + node, + createThreadViewPost({ post: siblingReply, replies: [] }), + ], + }), + ); + return replies; + } + + test("should render replies as a linear reply chain by default", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupReplyChain(mockServer, 3); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/abc123"); + + const view = page.locator("#post-detail-view"); + await expect(view.locator(".post-thread-reply-chains")).toBeVisible({ + timeout: 10000, + }); + await expect( + view.locator('[data-testid="post-thread-tree"]'), + ).not.toBeAttached(); + }); + + test("should render nested replies as a tree when enabled", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setThreadViewPref({ lab_treeViewEnabled: true }); + setupReplyChain(mockServer, 3); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/abc123"); + + const view = page.locator("#post-detail-view"); + const tree = view.locator('[data-testid="post-thread-tree"]'); + await expect(tree).toBeVisible({ timeout: 10000 }); + const rows = tree.locator('[data-testid="thread-tree-reply"]'); + await expect(rows).toHaveCount(4); + await expect( + rows.evaluateAll((elements) => + elements.map((element) => element.dataset.depth), + ), + ).resolves.toEqual(["1", "2", "3", "1"]); + await expect( + tree.locator('[data-testid="thread-read-more"]'), + ).not.toBeAttached(); + }); + + test("should cap a reply's children and add read more rows deepest first", async ({ + page, + }) => { + const anchorPost = createPost({ + uri: postUri, + text: "Threaded main post", + authorHandle: "author1.bsky.social", + replyCount: 1, + }); + const children = Array.from({ length: 12 }).map((_, index) => + createPost({ + uri: `at://did:plc:child${index}/app.bsky.feed.post/child${index}`, + text: `Child reply ${index}`, + authorHandle: `child${index}.bsky.social`, + likeCount: 12 - index, + // The last child that fits under the cap has replies the server didn't return + replyCount: index === 9 ? 2 : 0, + }), + ); + const topReply = createPost({ + uri: "at://did:plc:top/app.bsky.feed.post/top", + text: "Top reply", + authorHandle: "top.bsky.social", + replyCount: 12, + }); + const mockServer = new MockServer(); + mockServer.setThreadViewPref({ lab_treeViewEnabled: true }); + mockServer.addPosts([anchorPost, topReply, ...children]); + mockServer.setPostThread( + postUri, + createThreadViewPost({ + post: anchorPost, + replies: [ + createThreadViewPost({ + post: topReply, + replies: children.map((child) => + createThreadViewPost({ post: child, replies: [] }), + ), + }), + ], + }), + ); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/abc123"); + + const tree = page.locator( + '#post-detail-view [data-testid="post-thread-tree"]', + ); + await expect(tree).toBeVisible({ timeout: 10000 }); + await expect( + tree.locator('[data-testid="thread-tree-reply"][data-depth="2"]'), + ).toHaveCount(10); + await expect(tree).not.toContainText("Child reply 10"); + const readMoreLinks = tree.locator('[data-testid="thread-read-more"]'); + await expect( + readMoreLinks.evaluateAll((links) => + links.map((link) => link.getAttribute("href")), + ), + ).resolves.toEqual([ + "/profile/did:plc:child9/post/child9", + "/profile/did:plc:top/post/top", + ]); + }); + + test("should show hidden replies with their visible replies in the hidden section", async ({ + page, + }) => { + const anchorPost = createPost({ + uri: postUri, + text: "Threaded main post", + authorHandle: "author1.bsky.social", + replyCount: 2, + }); + const normalReply = createPost({ + uri: "at://did:plc:normal/app.bsky.feed.post/normal", + text: "Normal reply", + authorHandle: "normal.bsky.social", + }); + const mutedReply = createPost({ + uri: "at://did:plc:muted/app.bsky.feed.post/muted", + text: "Reply from muted user", + authorHandle: "muted.bsky.social", + authorViewer: { muted: true }, + replyCount: 1, + }); + const replyToMuted = createPost({ + uri: "at://did:plc:child/app.bsky.feed.post/child", + text: "Reply to the muted user", + authorHandle: "child.bsky.social", + }); + const mockServer = new MockServer(); + mockServer.setThreadViewPref({ lab_treeViewEnabled: true }); + mockServer.addPosts([anchorPost, normalReply, mutedReply, replyToMuted]); + mockServer.setPostThread( + postUri, + createThreadViewPost({ + post: anchorPost, + replies: [ + createThreadViewPost({ post: normalReply, replies: [] }), + createThreadViewPost({ + post: mutedReply, + replies: [ + createThreadViewPost({ post: replyToMuted, replies: [] }), + ], + }), + ], + }), + ); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/abc123"); + + const view = page.locator("#post-detail-view"); + const tree = view.locator('[data-testid="post-thread-tree"]'); + await expect(tree).toContainText("Normal reply", { timeout: 10000 }); + await expect(tree).not.toContainText("Reply from muted user"); + await expect(tree).not.toContainText("Reply to the muted user"); + + const hiddenSection = view.locator("hidden-replies-section"); + await hiddenSection.locator(".hidden-replies-button").click(); + const hiddenRows = hiddenSection.locator( + '[data-testid="thread-tree-reply"]', + ); + await expect(hiddenRows).toHaveCount(2); + await expect(hiddenRows.nth(0)).toContainText("Reply from muted user"); + await expect(hiddenRows.nth(1)).toHaveAttribute("data-depth", "2"); + await expect(hiddenRows.nth(1)).toContainText("Reply to the muted user"); + }); + + test("should keep the read more link after deleting a loaded reply", async ({ + page, + }) => { + const anchorPost = createPost({ + uri: postUri, + text: "Threaded main post", + authorHandle: "author1.bsky.social", + replyCount: 1, + }); + const topReply = createPost({ + uri: "at://did:plc:top/app.bsky.feed.post/top", + text: "Top reply", + authorHandle: "top.bsky.social", + replyCount: 3, + }); + const replyRef = { + parent: { uri: topReply.uri, cid: topReply.cid }, + root: { uri: anchorPost.uri, cid: anchorPost.cid }, + }; + const ownReply = createPost({ + uri: `at://${userProfile.did}/app.bsky.feed.post/own`, + text: "My reply to delete", + authorHandle: userProfile.handle, + likeCount: 10, + reply: replyRef, + }); + const otherReply = createPost({ + uri: "at://did:plc:other/app.bsky.feed.post/other", + text: "Other reply", + authorHandle: "other.bsky.social", + reply: replyRef, + }); + const mockServer = new MockServer(); + mockServer.setThreadViewPref({ lab_treeViewEnabled: true }); + mockServer.addPosts([anchorPost, topReply, ownReply, otherReply]); + mockServer.setPostThread( + postUri, + createThreadViewPost({ + post: anchorPost, + replies: [ + createThreadViewPost({ + post: topReply, + replies: [ + createThreadViewPost({ post: ownReply, replies: [] }), + createThreadViewPost({ post: otherReply, replies: [] }), + ], + }), + ], + }), + ); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/abc123"); + + const tree = page.locator( + '#post-detail-view [data-testid="post-thread-tree"]', + ); + const nestedRows = tree.locator( + '[data-testid="thread-tree-reply"][data-depth="2"]', + ); + await expect(nestedRows).toHaveCount(2, { timeout: 10000 }); + const readMore = tree.locator('[data-testid="thread-read-more"]'); + await expect(readMore).toHaveAttribute( + "href", + "/profile/did:plc:top/post/top", + ); + + await nestedRows.nth(0).locator(".text-button").click(); + await page.locator('[data-testid="menu-action-post-delete"]').click(); + await page.locator("button.confirm-button").click(); + + await expect(nestedRows).toHaveCount(1); + await expect(readMore).toHaveAttribute( + "href", + "/profile/did:plc:top/post/top", + ); + }); + + test.describe("on mobile", () => { + test.use({ viewport: { width: 375, height: 667 } }); + + test("should cut deep replies off with a read more link", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setThreadViewPref({ lab_treeViewEnabled: true }); + setupReplyChain(mockServer, 5); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/abc123"); + + const tree = page.locator( + '#post-detail-view [data-testid="post-thread-tree"]', + ); + await expect(tree).toBeVisible({ timeout: 10000 }); + await expect( + tree.locator('[data-testid="thread-tree-reply"][data-depth="4"]'), + ).toHaveCount(1); + await expect( + tree.locator('[data-testid="thread-tree-reply"][data-depth="5"]'), + ).toHaveCount(0); + const readMore = tree.locator('[data-testid="thread-read-more"]'); + await expect(readMore).toHaveAttribute( + "href", + "/profile/did:plc:nested3/post/nested3", + ); + }); + }); + }); + test.describe("Hidden replies section", () => { test("should place muted account replies in the hidden section", async ({ page, @@ -2420,7 +2755,11 @@ test.describe("Post thread view", () => { root, replyCount: 2, }); - const third = opPost("third1", { text: "Third post", parent: second, root }); + const third = opPost("third1", { + text: "Third post", + parent: second, + root, + }); const other = createPost({ uri: "at://did:plc:other/app.bsky.feed.post/other1", text: "Someone else's reply", diff --git a/tests/e2e/specs/views/settings/contentAndMedia.view.test.js b/tests/e2e/specs/views/settings/contentAndMedia.view.test.js index 359e4e4b..27f10aec 100644 --- a/tests/e2e/specs/views/settings/contentAndMedia.view.test.js +++ b/tests/e2e/specs/views/settings/contentAndMedia.view.test.js @@ -78,4 +78,49 @@ test.describe("Settings Content and media view", () => { ), ).not.toHaveAttribute("checked", { timeout: 10000 }); }); + + test("should save threaded replies to preferences and keep it after a reload", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setThreadViewPref({ sort: "top" }); + await mockServer.setup(page); + + await login(page); + await page.goto("/settings/content-and-media"); + + const view = page.locator("#settings-content-and-media-view"); + const toggle = view.locator('[data-testid="thread-view-toggle"]'); + await expect(toggle).toBeVisible({ timeout: 10000 }); + await expect(toggle).not.toHaveAttribute("checked"); + await toggle.click(); + await expect(toggle).toHaveAttribute("checked", ""); + await expect + .poll(() => mockServer.threadViewPref) + .toEqual({ sort: "top", lab_treeViewEnabled: true }); + + await page.reload(); + await expect( + page.locator( + '#settings-content-and-media-view [data-testid="thread-view-toggle"]', + ), + ).toHaveAttribute("checked", "", { timeout: 10000 }); + }); + + test("should disable the threaded replies toggle until preferences load", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.getPreferencesDelayMs = 1000; + await mockServer.setup(page); + + await login(page); + await page.goto("/settings/content-and-media"); + + const toggle = page.locator( + '#settings-content-and-media-view [data-testid="thread-view-toggle"]', + ); + await expect(toggle).toHaveAttribute("disabled", "", { timeout: 10000 }); + await expect(toggle).not.toHaveAttribute("disabled", { timeout: 10000 }); + }); }); diff --git a/tests/unit/specs/dataHelpers.test.js b/tests/unit/specs/dataHelpers.test.js index c6886949..8acce640 100644 --- a/tests/unit/specs/dataHelpers.test.js +++ b/tests/unit/specs/dataHelpers.test.js @@ -7,6 +7,7 @@ import { cdnImageUrl, getPostNumberingForPostThread, createNestedThreadViewPost, + insertReplyIntoThread, getRKey, getIsLiked, isListFeed, @@ -2863,3 +2864,46 @@ describe("isReferenceList", () => { assert.deepEqual(isReferenceList(null), false); }); }); + +describe("insertReplyIntoThread", () => { + const newReply = createThreadViewPost({ + post: createPost({ uri: "at://did:plc:me/app.bsky.feed.post/new" }), + replies: [], + }); + + function threadNode(id, replies = []) { + return createThreadViewPost({ + post: createPost({ uri: `at://did:plc:x/app.bsky.feed.post/${id}` }), + replies, + }); + } + + it("prepends the reply under a nested match and copies only the changed path", () => { + const untouched = threadNode("untouched"); + const target = threadNode("target", [threadNode("existing")]); + const thread = threadNode("root", [untouched, threadNode("mid", [target])]); + const updated = insertReplyIntoThread( + thread, + "at://did:plc:x/app.bsky.feed.post/target", + newReply, + ); + assert.notEqual(updated, thread); + assert.equal(updated.replies[0], untouched); + const updatedTarget = updated.replies[1].replies[0]; + assert.deepEqual(updatedTarget.replies.length, 2); + assert.equal(updatedTarget.replies[0], newReply); + assert.deepEqual(target.replies.length, 1); + }); + + it("returns the same thread when no post matches", () => { + const thread = threadNode("root", [threadNode("a")]); + assert.equal( + insertReplyIntoThread( + thread, + "at://did:plc:x/app.bsky.feed.post/nope", + newReply, + ), + thread, + ); + }); +}); diff --git a/tests/unit/specs/dataLayer/derived.test.js b/tests/unit/specs/dataLayer/derived.test.js index 2603d39b..7ca194ea 100644 --- a/tests/unit/specs/dataLayer/derived.test.js +++ b/tests/unit/specs/dataLayer/derived.test.js @@ -3071,3 +3071,19 @@ describe("thread mute hydration", () => { assert.equal(derived.$hydratedPosts.get(rootUri).viewer, undefined); }); }); + +describe("$threadView", () => { + it("is null until preferences load, then reflects the thread view preference", () => { + const { derived } = makeDerived(new DataStore(createSessionState(null))); + derived.preferencesProvider.$preferences.set(null); + assert.equal(derived.$threadView.get(), null); + + derived.preferencesProvider.$preferences.set(new Preferences([], [])); + assert.equal(derived.$threadView.get(), "linear"); + + derived.preferencesProvider.$preferences.set( + new Preferences([], []).setThreadView("tree"), + ); + assert.equal(derived.$threadView.get(), "tree"); + }); +}); diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index e5e336d0..930bbe1a 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -2462,6 +2462,66 @@ describe("pinFeed", () => { }); }); +describe("setThreadView", () => { + function setup({ updatePreferences }) { + const preferences = new Preferences([], []); + const mockPreferencesProvider = { + requirePreferences: () => preferences, + updatePreferences, + }; + const dataStore = new DataStore(createSessionState(null)); + const patchStore = new PatchStore(); + const mutations = makeMutations( + {}, + dataStore, + patchStore, + mockPreferencesProvider, + ); + return { mutations, patchStore }; + } + + it("should save the thread view and apply an optimistic patch until it resolves", async () => { + let updateResolve; + let updatedPreferences = null; + const { mutations, patchStore } = setup({ + updatePreferences: (prefs) => { + updatedPreferences = prefs; + return new Promise((resolve) => { + updateResolve = resolve; + }); + }, + }); + + const promise = mutations.setThreadView("tree"); + const patches = patchStore.$preferencePatches.get(); + assert.deepEqual(patches.length, 1); + assert.deepEqual(patches[0].body, { type: "setThreadView", view: "tree" }); + assert.deepEqual( + patchStore + .applyPreferencePatches(new Preferences([], []), patches) + .getThreadView(), + "tree", + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + updateResolve(); + await promise; + assert.deepEqual(updatedPreferences.getThreadView(), "tree"); + assert.deepEqual(patchStore.$preferencePatches.get().length, 0); + }); + + it("should remove the patch and rethrow when saving fails", async (t) => { + t.mock.method(console, "error", () => {}); + const { mutations, patchStore } = setup({ + updatePreferences: async () => { + throw new Error("network"); + }, + }); + await assert.rejects(mutations.setThreadView("tree"), /network/); + assert.deepEqual(patchStore.$preferencePatches.get().length, 0); + }); +}); + describe("unpinFeed", () => { const feedUri = "at://did:plc:feed/app.bsky.feed.generator/cool"; @@ -3328,6 +3388,61 @@ describe("createThread", () => { assert.deepEqual(repliesFeed.feed[0].post.uri, newPostUri); }); + it("should insert the reply under a nested post in cached threads", async () => { + const rootUri = "at://did:plc:other/app.bsky.feed.post/root"; + const nestedUri = "at://did:plc:other/app.bsky.feed.post/nested"; + const siblingUri = "at://did:plc:other/app.bsky.feed.post/sibling"; + const replyTo = { uri: nestedUri, cid: "cid-nested" }; + const rootThread = { + $type: "app.bsky.feed.defs#threadViewPost", + post: { uri: rootUri }, + replies: [ + { + $type: "app.bsky.feed.defs#threadViewPost", + post: { uri: siblingUri }, + replies: [], + }, + { + $type: "app.bsky.feed.defs#threadViewPost", + post: replyTo, + replies: [], + }, + ], + }; + const { mutations, dataStore } = setup({ replyPostThread: rootThread }); + const siblingNode = rootThread.replies[0]; + + await mutations.createThread({ + posts: [{ postText: "hi" }], + replyTo, + replyRoot: { uri: rootUri, cid: "cid-root" }, + }); + + const updatedThread = dataStore.$postThreads.get(rootUri); + assert.notEqual(updatedThread, rootThread); + assert.equal(updatedThread.replies[0], siblingNode); + const nestedNode = updatedThread.replies[1]; + assert.deepEqual(nestedNode.replies.length, 1); + assert.deepEqual(nestedNode.replies[0].post.uri, newPostUri); + }); + + it("should increment the reply count of the replied-to post", async () => { + const replyTo = createPost({ + uri: "at://did:plc:other/app.bsky.feed.post/parent", + replyCount: 3, + }); + const { mutations, dataStore } = setup(); + dataStore.$posts.set(replyTo.uri, replyTo); + + await mutations.createThread({ + posts: [{ postText: "hi" }], + replyTo, + replyRoot: replyTo, + }); + + assert.deepEqual(dataStore.$posts.get(replyTo.uri).replyCount, 4); + }); + it("should extend the OP run numbering when the OP replies to their own thread", async () => { const replyTo = { uri: `at://${currentUserDid}/app.bsky.feed.post/root`, @@ -3455,6 +3570,7 @@ describe("deletePost", () => { const post = { uri: "at://did:plc:me/app.bsky.feed.post/abc", cid: "cid-abc", + record: { text: "hi" }, }; let apiCalledWith = null; const dataStore = new DataStore(createSessionState(null)); @@ -3462,7 +3578,7 @@ describe("deletePost", () => { const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; - dataStore.$posts.set(post.uri, { ...post, record: { text: "hi" } }); + dataStore.$posts.set(post.uri, post); const mutations = makeMutations( { deletePost: async (passed) => { @@ -3481,6 +3597,33 @@ describe("deletePost", () => { assert.deepEqual(stored.uri, post.uri); assert.deepEqual(stored.$type, "app.bsky.feed.defs#notFoundPost"); }); + + it("should decrement the parent's reply count when deleting a reply", async () => { + const parent = createPost({ + uri: "at://did:plc:other/app.bsky.feed.post/parent", + replyCount: 2, + }); + const reply = createPost({ + uri: "at://did:plc:me/app.bsky.feed.post/reply", + reply: { + parent: { uri: parent.uri, cid: parent.cid }, + root: { uri: parent.uri, cid: parent.cid }, + }, + }); + const dataStore = new DataStore(createSessionState(null)); + dataStore.$posts.set(parent.uri, parent); + dataStore.$posts.set(reply.uri, reply); + const mutations = makeMutations( + { deletePost: async () => {} }, + dataStore, + new PatchStore(), + { requirePreferences: () => Preferences.createLoggedOutPreferences() }, + ); + + await mutations.deletePost(reply); + + assert.deepEqual(dataStore.$posts.get(parent.uri).replyCount, 1); + }); }); describe("createMessage", () => { diff --git a/tests/unit/specs/preferences.test.js b/tests/unit/specs/preferences.test.js index 75de46de..e017a6d2 100644 --- a/tests/unit/specs/preferences.test.js +++ b/tests/unit/specs/preferences.test.js @@ -6,6 +6,7 @@ import { INSTALLED_PLUGINS_PREF_TYPE, SEARCH_HISTORY_PREF_TYPE, GIF_HISTORY_PREF_TYPE, + THREAD_VIEW_PREF_TYPE, } from "/js/preferences.js"; import { createGif } from "../../shared/factories.js"; import { createMinimalGifObject } from "/js/embedHelpers.js"; @@ -3523,3 +3524,67 @@ describe("Preferences.setPostInteractionSettings", () => { assert.deepEqual(original.obj, []); }); }); + +describe("Preferences.getThreadView", () => { + it("should default to linear when there is no thread view preference", () => { + const preferences = new Preferences([], []); + assert.deepEqual(preferences.getThreadView(), "linear"); + }); + + it("should return tree when lab_treeViewEnabled is true", () => { + const preferences = new Preferences( + [ + { + $type: THREAD_VIEW_PREF_TYPE, + sort: "top", + lab_treeViewEnabled: true, + }, + ], + [], + ); + assert.deepEqual(preferences.getThreadView(), "tree"); + }); + + it("should return linear when lab_treeViewEnabled is false", () => { + const preferences = new Preferences( + [{ $type: THREAD_VIEW_PREF_TYPE, lab_treeViewEnabled: false }], + [], + ); + assert.deepEqual(preferences.getThreadView(), "linear"); + }); +}); + +describe("Preferences.setThreadView", () => { + it("should create the thread view preference when missing", () => { + const preferences = new Preferences([], []); + const newPreferences = preferences.setThreadView("tree"); + assert.deepEqual(preferences.obj, []); + assert.deepEqual(newPreferences.obj, [ + { $type: THREAD_VIEW_PREF_TYPE, lab_treeViewEnabled: true }, + ]); + }); + + it("should preserve sort and unknown keys on the existing preference", () => { + const preferences = new Preferences( + [ + { + $type: THREAD_VIEW_PREF_TYPE, + sort: "hotness", + someFutureKey: 1, + lab_treeViewEnabled: true, + }, + ], + [], + ); + const newPreferences = preferences.setThreadView("linear"); + assert.deepEqual(newPreferences.obj, [ + { + $type: THREAD_VIEW_PREF_TYPE, + sort: "hotness", + someFutureKey: 1, + lab_treeViewEnabled: false, + }, + ]); + assert.deepEqual(newPreferences.getThreadView(), "linear"); + }); +}); -- 2.54.0 From 86772fc4c835a8892bb902f1bb98c74771380cf0 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Fri, 9 Oct 2026 23:10:51 -0500 Subject: [PATCH 2/2] Fix notification spec --- tests/e2e/specs/views/notifications.view.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/specs/views/notifications.view.test.js b/tests/e2e/specs/views/notifications.view.test.js index 61adad82..73dbe32e 100644 --- a/tests/e2e/specs/views/notifications.view.test.js +++ b/tests/e2e/specs/views/notifications.view.test.js @@ -157,6 +157,7 @@ test.describe("Notifications view", () => { await login(page); await page.goto("/"); + await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); await page.evaluate(() => { window.__sameDocument = true; });