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,
+ );
+ });
+ });
});