diff --git a/package.json b/package.json index 8d164af8..688d9366 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.60", + "version": "0.20.61", "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 b51c483d..088c0f0c 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -2182,6 +2182,25 @@ container-link { margin-top: -4px; } +.post-number-badge { + display: inline-block; + border-radius: var(--label-badge-border-radius); + padding: 0 6px; + font-size: 12px; + line-height: 18px; + font-weight: 500; + font-variant-numeric: tabular-nums; + white-space: nowrap; + user-select: none; + color: var(--text-color-muted); + background-color: var(--callout-color); + vertical-align: 1px; +} + +.post-number-badge-standalone { + margin-bottom: 6px; +} + .large-post { border-bottom: var(--hair) solid var(--post-border-color); } diff --git a/src/js/components/plugin-rich-text.js b/src/js/components/plugin-rich-text.js index d9e0d80b..10e5ba02 100644 --- a/src/js/components/plugin-rich-text.js +++ b/src/js/components/plugin-rich-text.js @@ -27,6 +27,15 @@ class PluginRichText extends Component { this.$transformContext?.set(value); } + // Inline template appended after the text. Changing it re-renders the + // current tokens without re-requesting plugin transforms. + set suffix(value) { + this._suffix = value; + if (this._lastRenderProps) { + this._renderTokens(this._lastRenderProps); + } + } + connectedCallback() { if (this.initialized) return; this.initialized = true; @@ -54,6 +63,7 @@ class PluginRichText extends Component { this.disposeRender?.(); this.disposeRender = null; this._currentRequest = null; + this._lastRenderProps = null; if (this._claimedFacetFallbackTimer) { clearTimeout(this._claimedFacetFallbackTimer); this._claimedFacetFallbackTimer = null; @@ -154,10 +164,12 @@ class PluginRichText extends Component { } _renderTokens({ tokens, truncateUrls, placeholderFacetTypes }) { + this._lastRenderProps = { tokens, truncateUrls, placeholderFacetTypes }; render( richTextTokensTemplate({ tokens, truncateUrls, + suffix: this._suffix ?? null, placeholderFacetTypes, renderNodeToken: (token) => this.pluginService.renderRichTextNodeToken(token, this), diff --git a/src/js/dataHelpers.js b/src/js/dataHelpers.js index 23772146..4aa3dec2 100644 --- a/src/js/dataHelpers.js +++ b/src/js/dataHelpers.js @@ -1,4 +1,4 @@ -import { isNil, unique } from "/js/utils.js"; +import { isNil, unique, minBy } from "/js/utils.js"; import { CDN_URL, FOLLOWING_FEED_URI, @@ -266,6 +266,80 @@ export function flattenParents(postThread) { return parents; } +function isThreadViewPost(node) { + return node?.$type === "app.bsky.feed.defs#threadViewPost" && !!node.post; +} + +// The contiguous run of posts by the thread's original poster starting at the root +function getOpThreadRun(postThread) { + const chain = [...flattenParents(postThread), postThread]; + if (!chain.every(isThreadViewPost)) { + return null; + } + const root = chain[0]; + const mainPostReplyRoot = getReplyRootFromPost(postThread.post); + if (root.post.record?.reply || root.post.uri !== mainPostReplyRoot.uri) { + return null; + } + const opDid = root.post.author?.did; + if (!opDid) { + return null; + } + const run = [root]; + let chainIndex = 1; + while ( + chainIndex < chain.length && + chain[chainIndex].post.author?.did === opDid + ) { + run.push(chain[chainIndex]); + chainIndex++; + } + if (chainIndex < chain.length) { + // This means the run ends above the main post. + // The replies of the last post aren't loaded, + // so if it has any we just bail + const lastRunNode = run[run.length - 1]; + if ((lastRunNode.post.replyCount ?? 0) !== 1) { + return null; + } + return run; + } + let current = postThread; + while (true) { + const replies = current.replies; + if (!replies) { + if ((current.post.replyCount ?? 0) > 0) { + return null; + } + return run; + } + const opReplies = replies.filter( + (reply) => isThreadViewPost(reply) && reply.post.author?.did === opDid, + ); + if (opReplies.length === 0) { + return run; + } + const next = minBy(opReplies, (reply) => getRKey(reply.post)); + run.push(next); + current = next; + } +} + +export function getPostNumberingForPostThread(postThread) { + const numbering = new Map(); + const opThreadRun = getOpThreadRun(postThread); + if (!opThreadRun || opThreadRun.length < 2) { + return numbering; + } + opThreadRun.forEach((node, index) => { + numbering.set(node.post.uri, { + index: index + 1, + count: opThreadRun.length, + }); + }); + return numbering; +} + export function getParentPosts(postThread) { return flattenParents(postThread) .map((parent) => parent.post) diff --git a/src/js/dataLayer/dataStore.js b/src/js/dataLayer/dataStore.js index 6e5ef5f1..f5084d47 100644 --- a/src/js/dataLayer/dataStore.js +++ b/src/js/dataLayer/dataStore.js @@ -44,6 +44,8 @@ export class DataStore extends ReactiveStore { this.$embeddedPosts = new SignalMap(); this.$postThreads = new SignalMap(); this.$postThreadOthers = new SignalMap(); + // uri -> { index, count } as numbered by the appview in feed responses + this.$feedPostNumbering = new SignalMap(); this.$profiles = new SignalMap(); this.$detailedProfiles = new SignalMap(); // did -> statusView, normalized from profiles diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index 0ec39c96..927495a2 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -26,6 +26,7 @@ import { getJoinLinkCodeFromEmbed, isFollowingFeedUri, isStatusValid, + getPostNumberingForPostThread, } from "/js/dataHelpers.js"; import { sortBy, KeyedScheduler } from "/js/utils.js"; import { FOLLOWING_FEED_URI } from "/js/config.js"; @@ -269,31 +270,8 @@ export class Derived extends ReactiveStore { if (!feed) { return null; } - const hydratedFeedItems = []; - for (const feedItem of feed.feed) { - const hydratedFeedItem = { - feedContext: feedItem.feedContext, - post: this.$hydratedPosts.get(feedItem.post.uri), - }; - if (feedItem.reason) { - hydratedFeedItem.reason = feedItem.reason; - } - const reply = feedItem.reply; - if (reply) { - let root = reply.root; - if (isPostView(root)) { - root = this.$hydratedPosts.get(root.uri); - } - let parent = reply.parent; - if (isPostView(parent)) { - parent = this.$hydratedPosts.get(parent.uri); - } - hydratedFeedItem.reply = { ...reply, root, parent }; - } - hydratedFeedItems.push(hydratedFeedItem); - } const hydratedFeed = { - feed: hydratedFeedItems, + feed: feed.feed.map((feedItem) => this.hydrateFeedItem(feedItem)), cursor: feed.cursor, }; const pluginFilteredFeedItems = @@ -350,15 +328,26 @@ export class Derived extends ReactiveStore { return postThread; } const hiddenReplyUris = new Set(postThreadOther.map((item) => item.uri)); - const hydrated = this.hydratePostThreadNode(postThread, hiddenReplyUris); + const postNumberingMap = getPostNumberingForPostThread(postThread); + const hydrated = this.hydratePostThreadNode( + postThread, + hiddenReplyUris, + postNumberingMap, + ); if (!hydrated) { return null; } if (postThread.parent) { - hydrated.parent = this.hydratePostThreadParent(postThread.parent); + hydrated.parent = this.hydratePostThreadParent( + postThread.parent, + postNumberingMap, + ); } return hydrated; }); + this.$feedPostNumbering = new ComputedMap((uri) => { + return this.dataStore.$feedPostNumbering.get(uri) ?? null; + }); this.$hydratedHashtagFeeds = new ComputedMap((hashtagKey) => { const data = this.dataStore.$hashtagFeeds.get(hashtagKey); if (!data) { @@ -541,29 +530,8 @@ export class Derived extends ReactiveStore { for (const patch of patches) { feed = this.patchStore.applyAuthorFeedPatch(feed, patch.body); } - const hydratedFeedItems = []; - for (const feedItem of feed.feed) { - const hydratedFeedItem = { - post: this.$hydratedPosts.get(feedItem.post.uri), - }; - if (feedItem.reason) { - hydratedFeedItem.reason = feedItem.reason; - } - if (feedItem.reply) { - let root = feedItem.reply.root; - if (isPostView(root)) { - root = this.$hydratedPosts.get(root.uri); - } - let parent = feedItem.reply.parent; - if (isPostView(parent)) { - parent = this.$hydratedPosts.get(parent.uri); - } - hydratedFeedItem.reply = { ...feedItem.reply, root, parent }; - } - hydratedFeedItems.push(hydratedFeedItem); - } let hydratedFeed = { - feed: hydratedFeedItems, + feed: feed.feed.map((feedItem) => this.hydrateFeedItem(feedItem)), cursor: feed.cursor, }; const dashIndex = feedURI.lastIndexOf("-"); @@ -1009,11 +977,57 @@ export class Derived extends ReactiveStore { return notification; } - hydratePostThreadNode(node, hiddenReplyUris) { + hydrateFeedItem(feedItem) { + const postNumbering = this.dataStore.$feedPostNumbering.get( + feedItem.post.uri, + ); + const hydratedFeedItem = { + ...feedItem, + post: this.$hydratedPosts.get(feedItem.post.uri), + // NOTE: LEXICON DEVIATION + postNumbering, + }; + const reply = feedItem.reply; + if (reply) { + let root = reply.root; + if (isPostView(root)) { + root = this.$hydratedPosts.get(root.uri); + } + let parent = reply.parent; + if (isPostView(parent)) { + parent = this.$hydratedPosts.get(parent.uri); + } + const opDid = feedItem.post.author?.did; + const contextIsNumbered = + !!postNumbering && + !feedItem.reason && + root?.author?.did === opDid && + parent?.author?.did === opDid; + hydratedFeedItem.reply = { + ...reply, + root, + parent, + // NOTE: LEXICON DEVIATION + rootPostNumbering: contextIsNumbered + ? { index: 1, count: postNumbering.count } + : null, + parentPostNumbering: contextIsNumbered + ? { index: postNumbering.index - 1, count: postNumbering.count } + : null, + }; + } + return hydratedFeedItem; + } + + hydratePostThreadNode(node, hiddenReplyUris, postNumberingMap) { if (!node || isEmptyPost(node)) return node; const post = this.$hydratedPosts.get(node.post.uri); if (!post) return null; - const hydrated = { post }; + // NOTE: LEXICON DEVIATION + const hydrated = { + post, + postNumbering: postNumberingMap.get(node.post.uri) ?? null, + }; if (hiddenReplyUris.has(node.post.uri)) { // NOTE: LEXICON DEVIATION hydrated.post = { ...post, isHidden: true }; @@ -1021,7 +1035,11 @@ export class Derived extends ReactiveStore { if (node.replies) { hydrated.replies = node.replies.map((reply) => { if (reply.$type === "app.bsky.feed.defs#threadViewPost") { - return this.hydratePostThreadNode(reply, hiddenReplyUris); + return this.hydratePostThreadNode( + reply, + hiddenReplyUris, + postNumberingMap, + ); } return reply; }); @@ -1029,7 +1047,7 @@ export class Derived extends ReactiveStore { return hydrated; } - hydratePostThreadParent(parent) { + hydratePostThreadParent(parent, postNumberingMap) { if (this.dataStore.$unavailablePosts.get(parent.uri) !== null) { return createUnavailablePost(parent.uri); } @@ -1042,9 +1060,14 @@ export class Derived extends ReactiveStore { const hydratedParent = { $type: "app.bsky.feed.defs#threadViewPost", post: this.$hydratedPosts.get(parent.post.uri), + // NOTE: LEXICON DEVIATION + postNumbering: postNumberingMap.get(parent.post.uri) ?? null, }; if (parent.parent) { - hydratedParent.parent = this.hydratePostThreadParent(parent.parent); + hydratedParent.parent = this.hydratePostThreadParent( + parent.parent, + postNumberingMap, + ); } return hydratedParent; } diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index 7e878617..1e6aac84 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -18,7 +18,7 @@ import { buildProfileFromRecord, } from "/js/dataHelpers.js"; import { getLocalRefsFromDraft } from "/js/dataHelpers.js"; -import { unique } from "/js/utils.js"; +import { unique, isNil } from "/js/utils.js"; import { SignalMap, ComputedMap, ReactiveStore } from "/js/signals.js"; import { ApiError, isRecordNotFoundError } from "/js/api.js"; import { FOLLOWING_FEED_URI } from "/js/config.js"; @@ -500,6 +500,18 @@ export class Requests { return loadedReplies; } + _saveFeedPostNumbering(feed) { + for (const feedItem of feed.feed) { + const { opThreadPostIndex: index, opThreadPostCount: count } = feedItem; + if (!isNil(index) && !isNil(count)) { + this.dataStore.$feedPostNumbering.set(feedItem.post.uri, { + index, + count, + }); + } + } + } + async loadNextFeedPage({ type, uri }, { reload = false, limit = 31 } = {}) { const cursor = reload ? "" @@ -521,6 +533,7 @@ export class Requests { const postsToSave = getPostsFromFeed(feed); await this._loadPostDependencies(postsToSave); this.dataStore.setPosts(postsToSave); + this._saveFeedPostNumbering(feed); await this.events.emitAsync("feedLoaded", { feedURI: uri, feed, reload }); writePageToCollection(this.dataStore.$feeds, "feed", feed, { key: uri, @@ -852,6 +865,7 @@ export class Requests { const postsToSave = getPostsFromFeed(feed); await this._loadPostDependencies(postsToSave); this.dataStore.setPosts(postsToSave); + this._saveFeedPostNumbering(feed); // Save feed writePageToCollection(this.dataStore.$authorFeeds, "feed", feed, { key: feedURI, diff --git a/src/js/templates/largePost.template.js b/src/js/templates/largePost.template.js index 41e045d7..f459a280 100644 --- a/src/js/templates/largePost.template.js +++ b/src/js/templates/largePost.template.js @@ -23,6 +23,7 @@ import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js import { authorBadgesTemplate } from "/js/templates/labelBadges.template.js"; import { blockedPostTemplate } from "/js/templates/blockedPost.template.js"; import { whoCanReplyBadgeTemplate } from "/js/templates/whoCanReplyBadge.template.js"; +import { postNumberBadgeTemplate } from "/js/templates/postNumberBadge.template.js"; import { WhoCanReplyModal } from "/js/modals/whoCanReply.modal.js"; import { notFoundPostTemplate } from "/js/templates/notFoundPost.template.js"; import { unavailablePostTemplate } from "/js/templates/unavailablePost.template.js"; @@ -148,6 +149,7 @@ export function largePostTemplate({ showFollowButton = false, isFollowPending = false, onClickFollow = noop, + postNumbering = null, pluginService, }) { if (isBlockedPost(post)) { @@ -217,10 +219,21 @@ export function largePostTemplate({ uri: post.uri, did: post.author?.did ?? null, }} + .suffix=${postNumbering + ? postNumberBadgeTemplate({ + numbering: postNumbering, + inline: true, + }) + : null} truncate-urls > ` - : ""} + : postNumbering + ? postNumberBadgeTemplate({ + numbering: postNumbering, + inline: false, + }) + : ""} ${embed ? html`
${postEmbedTemplate({ diff --git a/src/js/templates/postFeed.template.js b/src/js/templates/postFeed.template.js index 1b489cd4..576e5742 100644 --- a/src/js/templates/postFeed.template.js +++ b/src/js/templates/postFeed.template.js @@ -80,6 +80,7 @@ function replyContextTemplate({ isAuthenticated, isUserPost: root.author?.did === currentUser?.did, replyContext: "root", + postNumbering: reply.rootPostNumbering, hiddenPostUris, postInteractionHandler, onClickShowLess, @@ -112,6 +113,7 @@ function replyContextTemplate({ showReplyToLabel: showParentReplyToLabel, replyToAuthor: grandparentAuthor, replyToBlocked, + postNumbering: reply.parentPostNumbering, hiddenPostUris, postInteractionHandler, onClickShowLess, @@ -191,6 +193,7 @@ function feedItemTemplate({ hiddenPostUris, isUserPost: currentUser?.did === post.author?.did, replyContext: showReplyContext ? "reply" : null, + postNumbering: feedItem.postNumbering, postInteractionHandler, onClickShowLess, onClickShowMore, diff --git a/src/js/templates/postNumberBadge.template.js b/src/js/templates/postNumberBadge.template.js new file mode 100644 index 00000000..110f35bf --- /dev/null +++ b/src/js/templates/postNumberBadge.template.js @@ -0,0 +1,14 @@ +import { html } from "/js/lib/lit-html.js"; + +export function postNumberBadgeTemplate({ numbering, inline }) { + const { index, count } = numbering; + const placement = inline ? "inline" : "standalone"; + return html`${index}/${count}`; +} diff --git a/src/js/templates/richText.template.js b/src/js/templates/richText.template.js index 2a66a6fd..60be0c32 100644 --- a/src/js/templates/richText.template.js +++ b/src/js/templates/richText.template.js @@ -63,11 +63,14 @@ function facetTemplate({ facet, wrappedText, truncateUrls }) { // placeholderFacetTypes: Set of facet feature $types to render as // invisible-but-space-preserving spans (used while a rich-text transform // that claims the type is still pending, to avoid flashing plaintext). +// suffix: optional inline template appended after the last token, separated +// by a space so it can wrap onto its own line. export function richTextTokensTemplate({ tokens, truncateUrls = false, renderNodeToken = () => null, placeholderFacetTypes = null, + suffix = null, }) { const isEmojiOnly = isEmojiOnlyTokens(tokens); const parts = []; @@ -122,6 +125,9 @@ export function richTextTokensTemplate({ } } }); + if (suffix) { + parts.push(" ", suffix); + } if (isEmojiOnly) { // prettier-ignore return html`
${parts}
`; diff --git a/src/js/templates/smallPost.template.js b/src/js/templates/smallPost.template.js index fa27a599..361c098d 100644 --- a/src/js/templates/smallPost.template.js +++ b/src/js/templates/smallPost.template.js @@ -19,6 +19,7 @@ import { blockedPostTemplate } from "/js/templates/blockedPost.template.js"; import { notFoundPostTemplate } from "/js/templates/notFoundPost.template.js"; import { unavailablePostTemplate } from "/js/templates/unavailablePost.template.js"; import { moderationWarningTemplate } from "/js/templates/moderationWarning.template.js"; +import { postNumberBadgeTemplate } from "/js/templates/postNumberBadge.template.js"; import "/js/components/lightbox-image-group.js"; import "/js/components/container-link.js"; import "/js/components/app-icon.js"; @@ -103,6 +104,7 @@ export function smallPostTemplate({ replyToAuthor = null, replyToBlocked = false, lazyLoadImages = false, + postNumbering = null, pluginService, }) { if (isBlockedPost(post)) { @@ -191,10 +193,21 @@ export function smallPostTemplate({ uri: post.uri, did: post.author?.did ?? null, }} + .suffix=${postNumbering + ? postNumberBadgeTemplate({ + numbering: postNumbering, + inline: true, + }) + : null} truncate-urls >
` - : ""} + : postNumbering + ? postNumberBadgeTemplate({ + numbering: postNumbering, + inline: false, + }) + : ""} ${post.embed ? html`
${postEmbedTemplate({ diff --git a/src/js/utils.js b/src/js/utils.js index a16f88aa..c8fecac8 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -84,6 +84,24 @@ export function sortBy(array, fnOrKey, { direction = "asc" } = {}) { return sorted; } +// Returns the first element with the lowest value, or null for an empty array +export function minBy(array, fnOrKey) { + let fn = fnOrKey; + if (typeof fnOrKey === "string") { + fn = (item) => item[fnOrKey]; + } + let minItem = null; + let minValue = null; + for (const item of array) { + const value = fn(item); + if (minItem === null || value < minValue) { + minItem = item; + minValue = value; + } + } + return minItem; +} + // Returns the first element with the highest value, or null for an empty array export function maxBy(array, fnOrKey) { let fn = fnOrKey; diff --git a/src/js/views/postThread.view.js b/src/js/views/postThread.view.js index 785d475b..af6aaffa 100644 --- a/src/js/views/postThread.view.js +++ b/src/js/views/postThread.view.js @@ -241,6 +241,7 @@ export default async function postThreadView({ isUserPost: currentUser?.did === post.author?.did, postInteractionHandler, replyContext: getReplyContext(i, numReplies), + postNumbering: reply.postNumbering, lazyLoadImages, pluginService, }); @@ -440,6 +441,7 @@ export default async function postThreadView({ isUserPost: currentUser?.did === parentPost.author?.did, postInteractionHandler, replyContext, + postNumbering: parent.postNumbering, ignoreMuteWarning: true, pluginService, }), @@ -472,6 +474,7 @@ export default async function postThreadView({ pluginService, isUserPost: currentUser?.did === mainPost?.author?.did, postInteractionHandler, + postNumbering: postThread.postNumbering, showFollowButton: doShowFollowButton( postAuthor, rootPost, @@ -585,6 +588,7 @@ export default async function postThreadView({ return { __isPrefill: true, post, + postNumbering: dataLayer.derived.$feedPostNumbering.get(postUri), parent: null, replies: null, }; diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index 0f2352a9..8c6a3629 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -241,6 +241,11 @@ export class MockServer { ); } + // For feed items that need more than a bare post (reply context, numbering) + setFeedItems(feedUri, feedItems) { + this.feeds.set(feedUri, feedItems); + } + addLists(lists) { this.lists.push(...lists); } diff --git a/tests/e2e/specs/views/home.view.test.js b/tests/e2e/specs/views/home.view.test.js index 69ca49ed..0b86eadb 100644 --- a/tests/e2e/specs/views/home.view.test.js +++ b/tests/e2e/specs/views/home.view.test.js @@ -6,6 +6,7 @@ import { createList, createLiveStatusView, createPost, + createFeedItem, } from "../../../shared/factories.js"; import { userProfile } from "../../testData.js"; @@ -1564,4 +1565,69 @@ test.describe("Home view", () => { await expect(page.locator("#home-view")).toBeVisible(); }); }); + + test.describe("OP thread numbering", () => { + const opDid = "did:plc:author1"; + + function opPost(rkey, { text, parent, root } = {}) { + return createPost({ + uri: `at://${opDid}/app.bsky.feed.post/${rkey}`, + text, + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + ...(parent + ? { + reply: { + parent: { uri: parent.uri, cid: parent.cid }, + root: { uri: root.uri, cid: root.cid }, + }, + } + : {}), + }); + } + + test("should number the feed post and its root and parent context rows", async ({ + page, + }) => { + const mockServer = new MockServer(); + const feed = createFeedGenerator({ + uri: "at://did:plc:creator1/app.bsky.feed.generator/threads", + displayName: "Threads", + creatorHandle: "creator1.bsky.social", + }); + const root = opPost("root1", { text: "Thread root" }); + const parent = opPost("second1", { + text: "Second post", + parent: root, + root, + }); + const post = opPost("third1", { text: "Third post", parent, root }); + mockServer.addFeedGenerators([feed]); + mockServer.setPinnedFeeds([feed.uri]); + mockServer.addPosts([root, parent, post]); + mockServer.setFeedItems(feed.uri, [ + createFeedItem({ + post, + reply: { + root: { $type: "app.bsky.feed.defs#postView", ...root }, + parent: { $type: "app.bsky.feed.defs#postView", ...parent }, + }, + opThreadPostIndex: 3, + opThreadPostCount: 4, + }), + ]); + await mockServer.setup(page); + + await login(page); + await page.goto("/"); + + const view = page.locator("#home-view"); + await view.locator(".tab-bar-button", { hasText: "Threads" }).click(); + const visibleFeed = view.locator(".feed-container:not([hidden])"); + const feedItem = visibleFeed.locator('[data-testid="feed-item"]'); + await expect(feedItem).toHaveCount(1, { timeout: 10000 }); + const badges = feedItem.locator('[data-testid="post-number-badge"]'); + await expect(badges).toHaveText(["1/4", "2/4", "3/4"]); + }); + }); }); diff --git a/tests/e2e/specs/views/postThread.view.test.js b/tests/e2e/specs/views/postThread.view.test.js index 2703e877..b585a6bb 100644 --- a/tests/e2e/specs/views/postThread.view.test.js +++ b/tests/e2e/specs/views/postThread.view.test.js @@ -1,7 +1,10 @@ import { test, expect } from "../../base.js"; import { login } from "../../helpers.js"; import { MockServer } from "../../mockServer.js"; -import { createPost } from "../../../shared/factories.js"; +import { + createPost, + createThreadViewPost, +} from "../../../shared/factories.js"; const postUri = "at://did:plc:author1/app.bsky.feed.post/abc123"; @@ -2385,4 +2388,161 @@ test.describe("Post thread view", () => { await expect(view.locator(".post-thread-reply-prompt")).not.toBeVisible(); }); }); + + test.describe("OP thread numbering", () => { + const opDid = "did:plc:author1"; + + function opPost(rkey, { text = rkey, parent, root, replyCount = 0 } = {}) { + return createPost({ + uri: `at://${opDid}/app.bsky.feed.post/${rkey}`, + text, + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + replyCount, + ...(parent + ? { + reply: { + parent: { uri: parent.uri, cid: parent.cid }, + root: { uri: root.uri, cid: root.cid }, + }, + } + : {}), + }); + } + + test("should number the anchor, its parent, and OP replies but not other replies", async ({ + page, + }) => { + const root = opPost("root1", { text: "Thread root", replyCount: 1 }); + const second = opPost("abc123", { + text: "Second post", + parent: root, + root, + replyCount: 2, + }); + 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", + authorHandle: "other.bsky.social", + authorDisplayName: "Other", + reply: { + parent: { uri: second.uri, cid: second.cid }, + root: { uri: root.uri, cid: root.cid }, + }, + }); + const mockServer = new MockServer(); + mockServer.addPosts([root, second, third, other]); + mockServer.setPostThread( + postUri, + createThreadViewPost({ + post: second, + parent: createThreadViewPost({ post: root }), + replies: [ + createThreadViewPost({ post: other, replies: [] }), + createThreadViewPost({ post: third, 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 largePost = view.locator('[data-testid="large-post"]'); + await expect( + largePost.locator('[data-testid="post-number-badge"]'), + ).toHaveText("2/3", { timeout: 10000 }); + const badges = view.locator('[data-testid="post-number-badge"]'); + await expect(badges).toHaveCount(3); + await expect(badges).toHaveText(["1/3", "2/3", "3/3"]); + + const otherReply = view.locator('[data-testid="small-post"]', { + has: page.locator("text=Someone else's reply"), + }); + await expect(otherReply).toBeVisible(); + await expect( + otherReply.locator('[data-testid="post-number-badge"]'), + ).toHaveCount(0); + }); + + test("should show a standalone badge on a media-only OP post", async ({ + page, + }) => { + const root = opPost("root1", { text: "Thread root", replyCount: 1 }); + const imageOnly = createPost({ + uri: postUri, + text: "", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + reply: { + parent: { uri: root.uri, cid: root.cid }, + root: { uri: root.uri, cid: root.cid }, + }, + embed: { + $type: "app.bsky.embed.images#view", + images: [ + { + thumb: "https://cdn.bsky.app/img/feed_thumbnail/plain/thumb.jpg", + fullsize: "https://cdn.bsky.app/img/feed_fullsize/plain/full.jpg", + alt: "A picture", + }, + ], + }, + }); + const mockServer = new MockServer(); + mockServer.addPosts([root, imageOnly]); + mockServer.setPostThread( + postUri, + createThreadViewPost({ + post: imageOnly, + parent: createThreadViewPost({ post: root }), + 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"]'); + const badge = largePost.locator('[data-testid="post-number-badge"]'); + await expect(badge).toHaveText("2/2", { timeout: 10000 }); + await expect(badge).toHaveAttribute("data-teststate", "standalone"); + }); + + test("should show no badges when the OP run continues past the fetched depth", async ({ + page, + }) => { + const root = opPost("abc123", { text: "Thread root", replyCount: 1 }); + const second = opPost("second1", { + text: "Second post", + parent: root, + root, + replyCount: 1, + }); + const mockServer = new MockServer(); + mockServer.addPosts([root, second]); + mockServer.setPostThread( + postUri, + createThreadViewPost({ + post: root, + replies: [createThreadViewPost({ post: second })], + }), + ); + 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('[data-testid="small-post"]')).toHaveCount(1, { + timeout: 10000, + }); + await expect( + view.locator('[data-testid="post-number-badge"]'), + ).toHaveCount(0); + }); + }); }); diff --git a/tests/shared/factories.js b/tests/shared/factories.js index 29e2ee9e..64bc21d6 100644 --- a/tests/shared/factories.js +++ b/tests/shared/factories.js @@ -456,12 +456,25 @@ export function createFeedItem({ feedContext = "test-context", reply, reason, + opThreadPostIndex, + opThreadPostCount, }) { return { post, ...(reply ? { reply } : {}), ...(reason ? { reason } : {}), feedContext, + ...(opThreadPostIndex !== undefined ? { opThreadPostIndex } : {}), + ...(opThreadPostCount !== undefined ? { opThreadPostCount } : {}), + }; +} + +export function createThreadViewPost({ post, parent, replies }) { + return { + $type: "app.bsky.feed.defs#threadViewPost", + post, + ...(parent ? { parent } : {}), + ...(replies ? { replies } : {}), }; } diff --git a/tests/unit/specs/components/plugin-rich-text.test.js b/tests/unit/specs/components/plugin-rich-text.test.js index 561c3047..01a89df9 100644 --- a/tests/unit/specs/components/plugin-rich-text.test.js +++ b/tests/unit/specs/components/plugin-rich-text.test.js @@ -2,6 +2,7 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { makeTestPluginService } from "../../testHelpers.js"; import "/js/components/plugin-rich-text.js"; +import { html } from "/js/lib/lit-html.js"; describe("plugin-rich-text", () => { const originalSetTimeout = globalThis.setTimeout; @@ -63,6 +64,7 @@ describe("plugin-rich-text", () => { facets = [], transformContext = makeTransformContext(), truncateUrls = false, + suffix = null, pluginService = makePluginService(), } = {}) { const element = document.createElement("plugin-rich-text"); @@ -70,6 +72,7 @@ describe("plugin-rich-text", () => { element.text = text; element.facets = facets; element.transformContext = transformContext; + element.suffix = suffix; if (truncateUrls) element.setAttribute("truncate-urls", ""); document.body.appendChild(element); return element; @@ -155,6 +158,52 @@ describe("plugin-rich-text", () => { ); }); + function badge(text) { + return html`${text}`; + } + + it("renders the suffix after the text", () => { + const element = mount({ suffix: badge("2/3") }); + const richText = element.querySelector("[data-testid='rich-text']"); + assert.deepEqual(richText.textContent, "hello 2/3"); + assert(richText.querySelector("[data-testid='suffix']") !== null); + }); + + it("keeps the suffix after a transform resolves", async () => { + const pluginService = makePluginService({ + result: [ + { type: "text", value: "use " }, + { + type: "inline", + pluginId: "p1", + node: { tag: "code", text: "npm i" }, + }, + ], + }); + const element = mount({ pluginService, suffix: badge("2/3") }); + await flushEffects(); + assert(element.querySelector("code") !== null); + assert.deepEqual( + element.querySelector("[data-testid='rich-text']").textContent, + "use npm i 2/3", + ); + }); + + it("re-renders on a suffix change without re-requesting transforms", async () => { + const pluginService = makePluginService(); + const element = mount({ pluginService }); + await flushEffects(); + assert.deepEqual(pluginService.calls.length, 1); + assert.deepEqual(element.querySelector("[data-testid='suffix']"), null); + element.suffix = badge("1/2"); + assert.deepEqual( + element.querySelector("[data-testid='suffix']").textContent, + "1/2", + ); + await flushEffects(); + assert.deepEqual(pluginService.calls.length, 1); + }); + it("keeps the base render when the request resolves null", async () => { const pluginService = makePluginService({ result: null }); const element = mount({ pluginService }); diff --git a/tests/unit/specs/dataHelpers.test.js b/tests/unit/specs/dataHelpers.test.js index a9c4e4b0..dcf8962e 100644 --- a/tests/unit/specs/dataHelpers.test.js +++ b/tests/unit/specs/dataHelpers.test.js @@ -5,6 +5,7 @@ import { avatarThumbnailUrl, buildProfileFromRecord, cdnImageUrl, + getPostNumberingForPostThread, getRKey, getIsLiked, isListFeed, @@ -57,6 +58,7 @@ import { getPostsFromFeed, } from "/js/dataHelpers.js"; import { IN_APP_LINK_DOMAINS } from "/js/config.js"; +import { createPost, createThreadViewPost } from "../../shared/factories.js"; describe("buildProfileFromRecord", () => { const did = "did:plc:me"; @@ -2542,3 +2544,209 @@ describe("getLocalRefsFromDraft", () => { assert.deepEqual(getLocalRefsFromDraft({ posts: [{ text: "hi" }] }), []); }); }); + +describe("getPostNumberingForPostThread", () => { + const opDid = "did:plc:op"; + const otherDid = "did:plc:other"; + + function threadPost(rkey, did, { parent, root, replyCount = 0 } = {}) { + return createPost({ + uri: `at://${did}/app.bsky.feed.post/${rkey}`, + text: rkey, + authorHandle: `${did}.test`, + replyCount, + ...(parent + ? { + reply: { + parent: { uri: parent.uri, cid: parent.cid }, + root: { uri: root.uri, cid: root.cid }, + }, + } + : {}), + }); + } + + function numberingOf(map) { + return Object.fromEntries( + [...map.entries()].map(([uri, numbering]) => [ + uri.split("/").pop(), + `${numbering.index}/${numbering.count}`, + ]), + ); + } + + it("numbers a root-anchored OP run above and below the anchor", () => { + const root = threadPost("p1", opDid, { replyCount: 1 }); + const second = threadPost("p2", opDid, { + parent: root, + root, + replyCount: 2, + }); + const third = threadPost("p3", opDid, { + parent: second, + root, + replyCount: 1, + }); + const otherReply = threadPost("r1", otherDid, { parent: second, root }); + const fourth = threadPost("p4", opDid, { parent: third, root }); + const thread = createThreadViewPost({ + post: second, + parent: createThreadViewPost({ post: root }), + replies: [ + createThreadViewPost({ post: otherReply, replies: [] }), + createThreadViewPost({ + post: third, + replies: [createThreadViewPost({ post: fourth, replies: [] })], + }), + ], + }); + assert.deepEqual(numberingOf(getPostNumberingForPostThread(thread)), { + p1: "1/4", + p2: "2/4", + p3: "3/4", + p4: "4/4", + }); + }); + + it("does not number an OP sub-thread that starts below another author", () => { + const root = threadPost("p1", otherDid, { replyCount: 1 }); + const opPost = threadPost("o1", opDid, { + parent: root, + root, + replyCount: 1, + }); + const opChild = threadPost("o2", opDid, { parent: opPost, root }); + const thread = createThreadViewPost({ + post: root, + replies: [ + createThreadViewPost({ + post: opPost, + replies: [createThreadViewPost({ post: opChild, replies: [] })], + }), + ], + }); + assert.deepEqual(numberingOf(getPostNumberingForPostThread(thread)), {}); + }); + + it("stops the run at a reply by another author", () => { + const root = threadPost("p1", opDid, { replyCount: 1 }); + const other = threadPost("r1", otherDid, { + parent: root, + root, + replyCount: 1, + }); + const opBelowOther = threadPost("o1", opDid, { parent: other, root }); + const thread = createThreadViewPost({ + post: root, + replies: [ + createThreadViewPost({ + post: other, + replies: [createThreadViewPost({ post: opBelowOther, replies: [] })], + }), + ], + }); + assert.deepEqual(numberingOf(getPostNumberingForPostThread(thread)), {}); + }); + + it("returns nothing for a root with no OP replies", () => { + const root = threadPost("p1", opDid); + const thread = createThreadViewPost({ post: root, replies: [] }); + assert.deepEqual(numberingOf(getPostNumberingForPostThread(thread)), {}); + }); + + it("returns nothing when the run continues past the fetched depth", () => { + const root = threadPost("p1", opDid, { replyCount: 1 }); + const second = threadPost("p2", opDid, { + parent: root, + root, + replyCount: 1, + }); + const thread = createThreadViewPost({ + post: root, + replies: [createThreadViewPost({ post: second })], + }); + assert.deepEqual(numberingOf(getPostNumberingForPostThread(thread)), {}); + }); + + it("returns nothing when a parent in the chain is blocked or missing", () => { + const root = threadPost("p1", opDid, { replyCount: 1 }); + const second = threadPost("p2", opDid, { + parent: root, + root, + replyCount: 1, + }); + const third = threadPost("p3", opDid, { parent: second, root }); + const thread = createThreadViewPost({ + post: third, + parent: createThreadViewPost({ + post: second, + parent: { $type: "app.bsky.feed.defs#blockedPost", uri: root.uri }, + }), + replies: [], + }); + assert.deepEqual(numberingOf(getPostNumberingForPostThread(thread)), {}); + }); + + it("numbers the run above a non-OP anchor only when the run provably ends", () => { + const root = threadPost("p1", opDid, { replyCount: 1 }); + const second = threadPost("p2", opDid, { + parent: root, + root, + replyCount: 1, + }); + const other = threadPost("r1", otherDid, { parent: second, root }); + const thread = createThreadViewPost({ + post: other, + parent: createThreadViewPost({ + post: second, + parent: createThreadViewPost({ post: root }), + }), + replies: [], + }); + assert.deepEqual(numberingOf(getPostNumberingForPostThread(thread)), { + p1: "1/2", + p2: "2/2", + }); + + const busySecond = { ...second, replyCount: 3 }; + const ambiguousThread = createThreadViewPost({ + post: other, + parent: createThreadViewPost({ + post: busySecond, + parent: createThreadViewPost({ post: root }), + }), + replies: [], + }); + assert.deepEqual( + numberingOf(getPostNumberingForPostThread(ambiguousThread)), + {}, + ); + }); + + it("follows the OP reply with the earliest rkey when the OP replied twice to one post", () => { + const root = threadPost("p1", opDid, { replyCount: 2 }); + const later = threadPost("3lb", opDid, { parent: root, root }); + const earlier = threadPost("3la", opDid, { parent: root, root }); + const thread = createThreadViewPost({ + post: root, + replies: [ + createThreadViewPost({ post: later, replies: [] }), + createThreadViewPost({ post: earlier, replies: [] }), + ], + }); + assert.deepEqual(numberingOf(getPostNumberingForPostThread(thread)), { + p1: "1/2", + "3la": "2/2", + }); + }); + + it("returns nothing for a tombstone thread", () => { + assert.deepEqual( + getPostNumberingForPostThread({ + $type: "app.bsky.feed.defs#notFoundPost", + uri: "at://did:plc:op/app.bsky.feed.post/gone", + }), + new Map(), + ); + }); +}); diff --git a/tests/unit/specs/dataLayer/derived.test.js b/tests/unit/specs/dataLayer/derived.test.js index 9edc297c..682ad80f 100644 --- a/tests/unit/specs/dataLayer/derived.test.js +++ b/tests/unit/specs/dataLayer/derived.test.js @@ -15,6 +15,8 @@ import { createNotification, createPost, createProfile, + createFeedItem, + createThreadViewPost, } from "../../../shared/factories.js"; import { trackDisposable } from "../../testHelpers.js"; @@ -84,8 +86,8 @@ describe("$hydratedFeeds", () => { const result = derived.$hydratedFeeds.get(feedURI); assert.deepEqual(result, { feed: [ - { post: post1, feedContext: undefined }, - { post: post2, feedContext: undefined }, + { post: post1, postNumbering: null }, + { post: post2, postNumbering: null }, ], cursor: "cursor123", }); @@ -116,6 +118,172 @@ describe("$hydratedFeeds", () => { }); }); +describe("$hydratedFeeds post numbering", () => { + const feedURI = "at://did:test/app.bsky.feed.generator/test"; + const opDid = "did:plc:op"; + + function opPost(rkey, { parent, root } = {}) { + return createPost({ + uri: `at://${opDid}/app.bsky.feed.post/${rkey}`, + text: rkey, + authorHandle: "op.test", + ...(parent + ? { + reply: { + parent: { uri: parent.uri, cid: parent.cid }, + root: { uri: root.uri, cid: root.cid }, + }, + } + : {}), + }); + } + + function seedThreadFeed(dataStore, { reason, root, parent, post } = {}) { + dataStore.setPosts([root, parent, post]); + dataStore.$feedPostNumbering.set(post.uri, { index: 3, count: 5 }); + dataStore.$feeds.set(feedURI, { + feed: [ + createFeedItem({ + post, + reply: { + root: { $type: "app.bsky.feed.defs#postView", uri: root.uri }, + parent: { $type: "app.bsky.feed.defs#postView", uri: parent.uri }, + }, + reason, + }), + ], + cursor: "", + }); + } + + it("reads the item numbering from the store and infers root and parent numbers", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + const root = opPost("p1"); + const parent = opPost("p2", { parent: root, root }); + const post = opPost("p3", { parent, root }); + seedThreadFeed(dataStore, { root, parent, post }); + + const item = derived.$hydratedFeeds.get(feedURI).feed[0]; + assert.deepEqual(item.postNumbering, { index: 3, count: 5 }); + assert.deepEqual(item.reply.rootPostNumbering, { index: 1, count: 5 }); + assert.deepEqual(item.reply.parentPostNumbering, { index: 2, count: 5 }); + }); + + it("gives the parent the root number when the post is second in the run", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + const root = opPost("p1"); + const parent = opPost("p2", { parent: root, root }); + const post = opPost("p3", { parent, root }); + seedThreadFeed(dataStore, { root, parent, post }); + dataStore.$feedPostNumbering.set(post.uri, { index: 2, count: 5 }); + + const item = derived.$hydratedFeeds.get(feedURI).feed[0]; + assert.deepEqual(item.reply.rootPostNumbering, { index: 1, count: 5 }); + assert.deepEqual(item.reply.parentPostNumbering, { index: 1, count: 5 }); + }); + + it("does not infer context numbers for reposts", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + const root = opPost("p1"); + const parent = opPost("p2", { parent: root, root }); + const post = opPost("p3", { parent, root }); + seedThreadFeed(dataStore, { + root, + parent, + post, + reason: { + $type: "app.bsky.feed.defs#reasonRepost", + by: { did: "did:plc:reposter" }, + }, + }); + + const item = derived.$hydratedFeeds.get(feedURI).feed[0]; + assert.deepEqual(item.postNumbering, { index: 3, count: 5 }); + assert.equal(item.reply.rootPostNumbering, null); + assert.equal(item.reply.parentPostNumbering, null); + }); + + it("does not infer context numbers when the root or parent is by another author", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + const root = createPost({ + uri: "at://did:plc:other/app.bsky.feed.post/p1", + text: "root", + authorHandle: "other.test", + }); + const parent = opPost("p2", { parent: root, root }); + const post = opPost("p3", { parent, root }); + seedThreadFeed(dataStore, { root, parent, post }); + + const item = derived.$hydratedFeeds.get(feedURI).feed[0]; + assert.deepEqual(item.postNumbering, { index: 3, count: 5 }); + assert.equal(item.reply.rootPostNumbering, null); + assert.equal(item.reply.parentPostNumbering, null); + }); + + it("leaves numbering null on items without it", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + const root = opPost("p1"); + const parent = opPost("p2", { parent: root, root }); + const post = opPost("p3", { parent, root }); + dataStore.setPosts([root, parent, post]); + dataStore.$feeds.set(feedURI, { + feed: [ + createFeedItem({ + post, + reply: { + root: { $type: "app.bsky.feed.defs#postView", uri: root.uri }, + parent: { $type: "app.bsky.feed.defs#postView", uri: parent.uri }, + }, + }), + ], + cursor: "", + }); + + const item = derived.$hydratedFeeds.get(feedURI).feed[0]; + assert.equal(item.postNumbering, null); + assert.equal(item.reply.rootPostNumbering, null); + assert.equal(item.reply.parentPostNumbering, null); + }); +}); + +describe("$hydratedAuthorFeeds post numbering", () => { + it("reads the item numbering from the store", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + const post = createPost({ + uri: "at://did:plc:op/app.bsky.feed.post/p1", + text: "root", + authorHandle: "op.test", + }); + dataStore.setPosts([post]); + dataStore.$feedPostNumbering.set(post.uri, { index: 1, count: 2 }); + dataStore.$authorFeeds.set("did:plc:op-posts", { + feed: [createFeedItem({ post })], + cursor: "", + }); + const item = derived.$hydratedAuthorFeeds.get("did:plc:op-posts").feed[0]; + assert.deepEqual(item.postNumbering, { index: 1, count: 2 }); + }); +}); + +describe("$feedPostNumbering", () => { + it("reads the cached feed numbering for a post", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + assert.equal(derived.$feedPostNumbering.get("uri1"), null); + dataStore.$feedPostNumbering.set("uri1", { index: 2, count: 3 }); + assert.deepEqual(derived.$feedPostNumbering.get("uri1"), { + index: 2, + count: 3, + }); + }); +}); + describe("$hydratedEmbeddedPosts", () => { it("hydrates embedded posts without exposing them as full posts", () => { const dataStore = new DataStore(createSessionState(null)); @@ -1924,6 +2092,77 @@ describe("$hydratedPostThreads", () => { }); }); +describe("$hydratedPostThreads post numbering", () => { + const opDid = "did:plc:op"; + + function opPost(rkey, { parent, root, replyCount = 0 } = {}) { + return createPost({ + uri: `at://${opDid}/app.bsky.feed.post/${rkey}`, + text: rkey, + authorHandle: "op.test", + replyCount, + ...(parent + ? { + reply: { + parent: { uri: parent.uri, cid: parent.cid }, + root: { uri: root.uri, cid: root.cid }, + }, + } + : {}), + }); + } + + it("stamps numbering on the anchor, its parents, and OP replies", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + const root = opPost("p1", { replyCount: 1 }); + const second = opPost("p2", { parent: root, root, replyCount: 2 }); + const third = opPost("p3", { parent: second, root }); + const other = createPost({ + uri: "at://did:plc:other/app.bsky.feed.post/r1", + text: "not op", + authorHandle: "other.test", + reply: { + parent: { uri: second.uri, cid: second.cid }, + root: { uri: root.uri, cid: root.cid }, + }, + }); + dataStore.setPosts([root, second, third, other]); + dataStore.$postThreads.set( + second.uri, + createThreadViewPost({ + post: second, + parent: createThreadViewPost({ post: root }), + replies: [ + createThreadViewPost({ post: other, replies: [] }), + createThreadViewPost({ post: third, replies: [] }), + ], + }), + ); + dataStore.$postThreadOthers.set(second.uri, []); + + const result = derived.$hydratedPostThreads.get(second.uri); + assert.deepEqual(result.postNumbering, { index: 2, count: 3 }); + assert.deepEqual(result.parent.postNumbering, { index: 1, count: 3 }); + assert.equal(result.replies[0].postNumbering, null); + assert.deepEqual(result.replies[1].postNumbering, { index: 3, count: 3 }); + }); + + it("leaves numbering null on a thread with no OP run", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + const root = opPost("p1", { replyCount: 1 }); + dataStore.setPosts([root]); + dataStore.$postThreads.set( + root.uri, + createThreadViewPost({ post: root, replies: [] }), + ); + dataStore.$postThreadOthers.set(root.uri, []); + const result = derived.$hydratedPostThreads.get(root.uri); + assert.equal(result.postNumbering, null); + }); +}); + describe("actor search results", () => { const did = "did:plc:found"; diff --git a/tests/unit/specs/dataLayer/requests.test.js b/tests/unit/specs/dataLayer/requests.test.js index 5f6eaf0c..d5b786c0 100644 --- a/tests/unit/specs/dataLayer/requests.test.js +++ b/tests/unit/specs/dataLayer/requests.test.js @@ -107,6 +107,30 @@ describe("loadPostThread", () => { describe("loadNextFeedPage", () => { const feedURI = "at://did:test/app.bsky.feed.generator/test"; + it("should cache post numbering from feed items that carry it", async () => { + const mockApi = { + getFeed: async () => ({ + feed: [ + { post: { uri: "post1" }, opThreadPostIndex: 2, opThreadPostCount: 3 }, + { post: { uri: "post2" } }, + { post: { uri: "post3" }, opThreadPostIndex: 1 }, + ], + cursor: "", + }), + }; + const dataStore = new DataStore(createSessionState(null)); + const requests = makeRequests(mockApi, dataStore); + + await requests.loadNextFeedPage({ type: "feed", uri: feedURI }); + + assert.deepEqual(dataStore.$feedPostNumbering.get("post1"), { + index: 2, + count: 3, + }); + assert.equal(dataStore.$feedPostNumbering.get("post2"), null); + assert.equal(dataStore.$feedPostNumbering.get("post3"), null); + }); + it("should load initial feed page", async () => { const mockFeed = { feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], diff --git a/tests/unit/specs/templates/largePost.template.test.js b/tests/unit/specs/templates/largePost.template.test.js index a03cc4a2..9eacfb17 100644 --- a/tests/unit/specs/templates/largePost.template.test.js +++ b/tests/unit/specs/templates/largePost.template.test.js @@ -520,4 +520,48 @@ describe("largePostTemplate - plugin context menu items", () => { ); container.remove(); }); + + describe("post numbering", () => { + it("should render a standalone badge above the embed when the post has no text", () => { + const imageOnlyPost = { + ...post, + record: { ...post.record, text: "" }, + embed: { + $type: "app.bsky.embed.images#view", + images: [ + { + thumb: "https://cdn.bsky.app/img/thumb.jpg", + fullsize: "https://cdn.bsky.app/img/full.jpg", + alt: "", + }, + ], + }, + }; + const container = document.createElement("div"); + render( + largePostTemplate({ + post: imageOnlyPost, + ...baseProps, + postNumbering: { index: 3, count: 3 }, + }), + container, + ); + const badge = container.querySelector( + "[data-testid='post-number-badge']", + ); + assert(badge !== null); + assert.equal(badge.textContent, "3/3"); + assert.equal(badge.getAttribute("data-teststate"), "standalone"); + assert(badge.nextElementSibling?.classList.contains("post-embed")); + }); + + it("should render no badge without numbering", () => { + const container = document.createElement("div"); + render(largePostTemplate({ post, ...baseProps }), container); + assert.equal( + container.querySelector("[data-testid='post-number-badge']"), + null, + ); + }); + }); }); diff --git a/tests/unit/specs/templates/postNumberBadge.template.test.js b/tests/unit/specs/templates/postNumberBadge.template.test.js new file mode 100644 index 00000000..6fcb8d4a --- /dev/null +++ b/tests/unit/specs/templates/postNumberBadge.template.test.js @@ -0,0 +1,34 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { postNumberBadgeTemplate } from "/js/templates/postNumberBadge.template.js"; +import { render } from "/js/lib/lit-html.js"; + +function renderBadge(props) { + const container = document.createElement("div"); + render(postNumberBadgeTemplate(props), container); + return container; +} + +describe("postNumberBadgeTemplate", () => { + it("renders index/count with an accessible label", () => { + const container = renderBadge({ + numbering: { index: 3, count: 5 }, + inline: true, + }); + const badge = container.querySelector('[data-testid="post-number-badge"]'); + assert(badge !== null); + assert.equal(badge.textContent, "3/5"); + assert.equal(badge.getAttribute("role"), "img"); + assert.equal(badge.getAttribute("aria-label"), "Post 3 of 5"); + assert.equal(badge.getAttribute("data-teststate"), "inline"); + }); + + it("marks the standalone placement", () => { + const container = renderBadge({ + numbering: { index: 1, count: 2 }, + inline: false, + }); + const badge = container.querySelector('[data-testid="post-number-badge"]'); + assert.equal(badge.getAttribute("data-teststate"), "standalone"); + }); +}); diff --git a/tests/unit/specs/templates/richText.template.test.js b/tests/unit/specs/templates/richText.template.test.js index 533303d0..080d7f92 100644 --- a/tests/unit/specs/templates/richText.template.test.js +++ b/tests/unit/specs/templates/richText.template.test.js @@ -4,7 +4,8 @@ import { richTextTemplate, richTextTokensTemplate, } from "/js/templates/richText.template.js"; -import { render } from "/js/lib/lit-html.js"; +import { render, html } from "/js/lib/lit-html.js"; +import { tokenizeRichText } from "/js/richTextHelpers.js"; describe("richTextTemplate", () => { it("should render plain text", () => { @@ -347,3 +348,32 @@ describe("richTextTokensTemplate", () => { assert.deepEqual(richText.querySelector("code"), null); }); }); + +describe("richTextTokensTemplate suffix", () => { + it("appends the suffix after the text, separated by a space", () => { + const tokens = tokenizeRichText({ text: "Hello world", facets: [] }); + const result = richTextTokensTemplate({ + tokens, + suffix: html`2/3`, + }); + const container = document.createElement("div"); + render(result, container); + const richText = container.querySelector("[data-testid='rich-text']"); + assert.equal(richText.textContent, "Hello world 2/3"); + assert(richText.querySelector("[data-testid='suffix']") !== null); + assert.equal(richText.lastElementChild.dataset.testid, "suffix"); + }); + + it("keeps the suffix inside an emoji-only wrapper", () => { + const tokens = tokenizeRichText({ text: "🎉", facets: [] }); + const result = richTextTokensTemplate({ + tokens, + suffix: html`2/3`, + }); + const container = document.createElement("div"); + render(result, container); + const richText = container.querySelector("[data-teststate='emoji-only']"); + assert(richText !== null); + assert(richText.querySelector("[data-testid='suffix']") !== null); + }); +}); diff --git a/tests/unit/specs/templates/smallPost.template.test.js b/tests/unit/specs/templates/smallPost.template.test.js index 3d152e7d..c2daf225 100644 --- a/tests/unit/specs/templates/smallPost.template.test.js +++ b/tests/unit/specs/templates/smallPost.template.test.js @@ -603,4 +603,74 @@ describe("smallPostTemplate - plugin context menu items", () => { ); container.remove(); }); + + describe("post numbering", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("should render the badge inline in the post text", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + render( + smallPostTemplate({ + post, + ...baseProps, + postNumbering: { index: 2, count: 4 }, + }), + container, + ); + const badge = container.querySelector( + ".post-text [data-testid='post-number-badge']", + ); + assert(badge !== null); + assert.equal(badge.textContent, "2/4"); + assert.equal(badge.getAttribute("data-teststate"), "inline"); + }); + + it("should render a standalone badge when the post has no text", () => { + const imageOnlyPost = { + ...post, + record: { ...post.record, text: "" }, + embed: { + $type: "app.bsky.embed.images#view", + images: [ + { + thumb: "https://cdn.bsky.app/img/thumb.jpg", + fullsize: "https://cdn.bsky.app/img/full.jpg", + alt: "", + }, + ], + }, + }; + const container = document.createElement("div"); + render( + smallPostTemplate({ + post: imageOnlyPost, + ...baseProps, + postNumbering: { index: 1, count: 4 }, + }), + container, + ); + assert(!container.querySelector(".post-text")); + const badge = container.querySelector( + "[data-testid='post-number-badge']", + ); + assert(badge !== null); + assert.equal(badge.getAttribute("data-teststate"), "standalone"); + assert( + badge.nextElementSibling?.classList.contains("post-embed"), + "badge should precede the embed", + ); + }); + + it("should render no badge without numbering", () => { + const container = document.createElement("div"); + render(smallPostTemplate({ post, ...baseProps }), container); + assert.equal( + container.querySelector("[data-testid='post-number-badge']"), + null, + ); + }); + }); });