CI / Typecheck, test, build (pull_request) Successful in 46s
People write in chains on both, and a link into one arrives pointing at a single post out of several. Showing only that post loses the thing that was being said. Other people's replies are a different matter: they are a conversation rather than the thing that was shared, and on a busy post there are hundreds of them. A Post is now a list of Segments instead of one body. Most platforms produce exactly one and say so through oneSegment(); the two that thread produce the whole chain, with isAnchor marking the post that was actually linked, which need not be the first. Bluesky walks parent upward and the author's own replies downward, stopping at the first post by anyone else. That needs depth and parentHeight on getPostThread, which drags the entire reply tree along -- a few hundred KB on a popular post -- because there is no way to ask the API for one author's branch. Threads is harder to read. The page ships the linked post, the author's follow-ups, other people's replies and a pile of unrelated recommendations, all as flat thread_items containers with no nesting to go on. What separates a follow-up from a stranger's reply is that a follow-up is the author replying to themselves; a reply from someone else carries the same reply_to_author with a different name on it. The first post of a chain replies to nothing at all, so it is reachable only by walking backwards from the post that answers it -- a test caught that, when linking the second post of a thread returned just the one post. Fixtures for both are real captures. The Bluesky one keeps two of every level's outside replies rather than pruning them away, because a filter is only worth testing against the thing it is supposed to exclude. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
127 lines
5.2 KiB
TypeScript
127 lines
5.2 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { mediaFromEmbed, selfThread, toPost } from '../src/platforms/bluesky.ts';
|
|
import { fixture } from './helpers.ts';
|
|
|
|
const URL_ = 'https://bsky.app/profile/example/post/abc';
|
|
|
|
/** The three single-post fixtures are captured `thread.post` objects. */
|
|
function single(name: string) {
|
|
return toPost({ post: fixture(`bluesky/${name}.json`) }, URL_);
|
|
}
|
|
|
|
test('a text-only post carries no media', () => {
|
|
const post = single('text-only');
|
|
assert.equal(post.platform, 'bluesky');
|
|
assert.equal(post.textPosition, 'above');
|
|
assert.equal(post.segments.length, 1);
|
|
assert.deepEqual(post.segments[0]?.media, []);
|
|
assert.ok(post.segments[0]?.text);
|
|
assert.match(post.author.handle, /^@/);
|
|
});
|
|
|
|
test('an image post keeps every image, in order, with alt text', () => {
|
|
const media = single('images').segments[0]?.media ?? [];
|
|
assert.ok(media.length > 1, 'expected a multi-image post');
|
|
assert.ok(media.every((m) => m.kind === 'image'));
|
|
assert.ok(media.every((m) => m.url.startsWith('https://')));
|
|
assert.ok(media.some((m) => m.kind === 'image' && m.alt));
|
|
});
|
|
|
|
test('a video post is a direct HLS playlist with a poster', () => {
|
|
const item = single('video').segments[0]?.media[0];
|
|
assert.equal(item?.kind, 'video');
|
|
assert.ok(item?.kind === 'video' && item.hls);
|
|
// Proxying HLS would mean rewriting the manifest, so it is linked direct.
|
|
assert.ok(item?.direct);
|
|
assert.ok(item?.kind === 'video' && item.poster);
|
|
});
|
|
|
|
test('a quote post with media reaches through the nested embed', () => {
|
|
const media = mediaFromEmbed({
|
|
$type: 'app.bsky.embed.recordWithMedia#view',
|
|
media: { $type: 'app.bsky.embed.images#view', images: [{ fullsize: 'https://example/i.jpg' }] },
|
|
});
|
|
assert.deepEqual(media, [{ kind: 'image', url: 'https://example/i.jpg' }]);
|
|
});
|
|
|
|
test('a quote post shows the media of the post it quotes', () => {
|
|
const media = mediaFromEmbed({
|
|
$type: 'app.bsky.embed.record#view',
|
|
record: {
|
|
embeds: [{ $type: 'app.bsky.embed.images#view', images: [{ fullsize: 'https://cdn/q.jpg' }] }],
|
|
},
|
|
});
|
|
assert.deepEqual(media, [{ kind: 'image', url: 'https://cdn/q.jpg' }]);
|
|
});
|
|
|
|
// The chain fixture is a real thread. Other people's replies were trimmed to
|
|
// two per level for size, but deliberately kept: the whole point is that they
|
|
// are there in the payload and must not appear in the output.
|
|
test("the author's own chain is followed, in order, and marked", () => {
|
|
const post = toPost(fixture('bluesky/thread-chain.json'), URL_);
|
|
|
|
assert.equal(post.segments.length, 6, 'anchor plus five follow-ups');
|
|
assert.equal(post.segments[0]?.isAnchor, true, 'the linked post leads the chain here');
|
|
assert.equal(post.segments.filter((s) => s.isAnchor).length, 1);
|
|
|
|
const times = post.segments.map((s) => s.postedAt ?? '');
|
|
assert.deepEqual([...times].sort(), times, 'segments must be in the order written');
|
|
|
|
assert.match(post.segments[0]?.text ?? '', /redesigning the notifications tab/);
|
|
assert.match(post.segments[5]?.text ?? '', /Let us know what works/);
|
|
});
|
|
|
|
test("other people's replies never appear in the thread", () => {
|
|
const chain = selfThread(fixture('bluesky/thread-chain.json'));
|
|
const texts = chain.map((s) => s.text ?? '').join('\n');
|
|
|
|
// All present in the fixture as replies to the same posts.
|
|
for (const stranger of ['Really? One of the most visited?', 'Show us notifications from muted', 'WHY ARE YOU NOT USING DARK MODE']) {
|
|
assert.ok(!texts.includes(stranger), `a reply leaked into the thread: ${stranger}`);
|
|
}
|
|
});
|
|
|
|
test('a post with no thread around it is a single segment', () => {
|
|
const chain = selfThread({
|
|
post: {
|
|
uri: 'at://x/app.bsky.feed.post/1',
|
|
author: { handle: 'someone.bsky.social' },
|
|
record: { text: 'alone' },
|
|
},
|
|
replies: [
|
|
{ post: { uri: 'at://y/1', author: { handle: 'other.bsky.social' }, record: { text: 'hi' } } },
|
|
],
|
|
});
|
|
assert.equal(chain.length, 1);
|
|
assert.equal(chain[0]?.isAnchor, true);
|
|
});
|
|
|
|
test('a linked post mid-thread keeps the posts that came before it', () => {
|
|
const chain = selfThread({
|
|
post: { uri: 'at://a/2', author: { handle: 'me.bsky.social' }, record: { text: 'second' } },
|
|
parent: {
|
|
post: { uri: 'at://a/1', author: { handle: 'me.bsky.social' }, record: { text: 'first' } },
|
|
},
|
|
replies: [
|
|
{ post: { uri: 'at://a/3', author: { handle: 'me.bsky.social' }, record: { text: 'third' } } },
|
|
{ post: { uri: 'at://b/1', author: { handle: 'other.bsky.social' }, record: { text: 'nope' } } },
|
|
],
|
|
});
|
|
assert.deepEqual(chain.map((s) => s.text), ['first', 'second', 'third']);
|
|
assert.deepEqual(chain.map((s) => s.isAnchor), [undefined, true, undefined]);
|
|
});
|
|
|
|
test('the walk up stops at the first post by someone else', () => {
|
|
const chain = selfThread({
|
|
post: { uri: 'at://a/2', author: { handle: 'me.bsky.social' }, record: { text: 'mine' } },
|
|
parent: {
|
|
post: { uri: 'at://b/1', author: { handle: 'other.bsky.social' }, record: { text: 'theirs' } },
|
|
parent: {
|
|
post: { uri: 'at://a/0', author: { handle: 'me.bsky.social' }, record: { text: 'older' } },
|
|
},
|
|
},
|
|
});
|
|
assert.deepEqual(chain.map((s) => s.text), ['mine']);
|
|
});
|