Threaded replies
++ Show post replies in a threaded tree view. +
+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`
+ ${hiddenSectionReplies.length > 0 + ? html`+ Show post replies in a threaded tree view. +
+