Show the author's own chain on Bluesky and Threads #1

Merged
thatguygriff merged 1 commits from threads-and-chains into main 2026-08-26 20:17:22 +00:00
16 changed files with 1184 additions and 115 deletions
+13 -2
View File
@@ -62,6 +62,10 @@ Request → `src/routes/post.ts` → `src/platforms/index.ts` maps prefix to ada
Adding a platform is one file in `src/platforms/` plus one row in the table in Adding a platform is one file in `src/platforms/` plus one row in the table in
`index.ts`. Everything downstream already handles a `Post`. `index.ts`. Everything downstream already handles a `Post`.
A `Post` is a list of `Segment`s, not a single body. Most platforms produce one
(`oneSegment` in `types.ts`); Bluesky and Threads produce the author's whole chain,
with `isAnchor` marking the post that was linked — which need not be the first.
Things worth knowing before editing: Things worth knowing before editing:
- **One Chromium, one context, persistent.** Cookies and dismissed banners accumulate on - **One Chromium, one context, persistent.** Cookies and dismissed banners accumulate on
@@ -88,7 +92,9 @@ Things worth knowing before editing:
- **Bluesky** — asks the public API directly (still through the browser context), so it - **Bluesky** — asks the public API directly (still through the browser context), so it
is the most reliable. The web app calls `getPostThreadV2` now; the page fallback is the most reliable. The web app calls `getPostThreadV2` now; the page fallback
deliberately matches only V1. deliberately matches only V1. Threads are built by walking `parent` up and the
author's own `replies` down; `depth`/`parentHeight` are what make that possible, at
the cost of dragging the whole reply tree along (a few hundred KB on a busy post).
- **X** — the `platform.twitter.com` embed calls the syndication endpoint; we catch that - **X** — the `platform.twitter.com` embed calls the syndication endpoint; we catch that
response. A quote post carries no media of its own, so the quoted post's media is used. response. A quote post carries no media of its own, so the quoted post's media is used.
- **Instagram** — the least reliable. It ships the structured payload only some of the - **Instagram** — the least reliable. It ships the structured payload only some of the
@@ -102,7 +108,12 @@ Things worth knowing before editing:
segment and are rebuilt in `buildOriginalUrl`. segment and are rebuilt in `buildOriginalUrl`.
- **Threads** — same media schema as Instagram (`src/platforms/meta-media.ts`). Its - **Threads** — same media schema as Instagram (`src/platforms/meta-media.ts`). Its
payloads are full of empty stub nodes, so the finder only accepts a node with actual payloads are full of empty stub nodes, so the finder only accepts a node with actual
candidates in it. candidates in it. The page ships the linked post, the author's follow-ups, other
people's replies and unrelated recommendations all as flat `thread_items`
containers. A follow-up is the author replying to *themselves*, which is what
separates it from a stranger's reply carrying the same `reply_to_author`. The first
post of a chain replies to nothing, so it is only reachable by walking backwards
from the one that answers it.
## Verification puzzles ## Verification puzzles
+6
View File
@@ -62,6 +62,12 @@ Each adapter layers its extraction, most structured first:
| TikTok | the post page | `__UNIVERSAL_DATA_FOR_REHYDRATION__` | | TikTok | the post page | `__UNIVERSAL_DATA_FOR_REHYDRATION__` |
| Threads | the post page | the Relay payloads in `<script type="application/json">` | | Threads | the post page | the Relay payloads in `<script type="application/json">` |
On Bluesky and Threads people write in chains, so where the linked post is part
of one, the author's own follow-ups are shown with it, in the order they were
written, with the post you actually followed marked. Other people's replies are
left out — they are a conversation, not the thing that was shared, and on a busy
post there are hundreds of them.
Media never gets linked straight at a CDN. Instagram and TikTok reject requests without Media never gets linked straight at a CDN. Instagram and TikTok reject requests without
a matching `Referer` (and sometimes cookies), and proxying keeps your browser from a matching `Referer` (and sometimes cookies), and proxying keeps your browser from
talking to the platform at all. Every asset is registered under an opaque `/m/<id>` and talking to the platform at all. Every asset is registered under an opaque `/m/<id>` and
+25
View File
@@ -247,3 +247,28 @@ main { max-width: 680px; margin: 0 auto; }
color: #e9ebee; color: #e9ebee;
background: rgba(0, 0, 0, 0.65); background: rgba(0, 0, 0, 0.65);
} }
/* ---------- threads ---------- */
.segment + .segment { border-top: 1px solid var(--line); }
/* A chain reads as one column of posts, so the media in each needs its own
top edge only when text sits above it. */
.segment .text + .media { border-top: 1px solid var(--line); }
.post[data-segments] .segment { position: relative; }
.segment__mark {
margin: 0;
padding: 8px 14px 0;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-dim);
}
.post[data-segments] .segment[data-anchor="true"] {
/* The link may point at the middle of a thread, so say which one it was
without hiding the rest. */
background: color-mix(in srgb, var(--accent, #888) 7%, transparent);
}
+63 -9
View File
@@ -1,5 +1,5 @@
import { config } from '../config.ts'; import { config } from '../config.ts';
import { ResolveError, type Media, type Post } from '../types.ts'; import { ResolveError, type Media, type Post, type Segment } from '../types.ts';
import { captureJson } from '../browser/capture.ts'; import { captureJson } from '../browser/capture.ts';
import { hostMatcher } from './url.ts'; import { hostMatcher } from './url.ts';
import type { PlatformSpec, ResolveContext } from './types.ts'; import type { PlatformSpec, ResolveContext } from './types.ts';
@@ -26,7 +26,13 @@ type BskyPost = {
indexedAt?: string; indexedAt?: string;
}; };
type ThreadResponse = { thread?: { post?: BskyPost } }; type ThreadNode = {
post?: BskyPost;
parent?: ThreadNode;
replies?: ThreadNode[];
};
type ThreadResponse = { thread?: ThreadNode };
/** `profile/<actor>/post/<rkey>` is the only shape that carries a post. */ /** `profile/<actor>/post/<rkey>` is the only shape that carries a post. */
function parsePath(pathRest: string): { actor: string; rkey: string } | undefined { function parsePath(pathRest: string): { actor: string; rkey: string } | undefined {
@@ -75,7 +81,54 @@ export function mediaFromEmbed(embed: Embed | undefined): Media[] {
return []; return [];
} }
export function toPost(post: BskyPost, originalUrl: string): Post { function segmentOf(post: BskyPost, isAnchor: boolean): Segment {
return {
...(post.record?.text ? { text: post.record.text } : {}),
media: mediaFromEmbed(post.embed),
...(post.record?.createdAt ? { postedAt: post.record.createdAt } : {}),
...(isAnchor ? { isAnchor: true } : {}),
};
}
/**
* Collect the author's own chain around the linked post: their earlier posts
* leading up to it, and their own follow-ups after it.
*
* Everyone else's replies are left out. They are a conversation, not the
* thing that was shared, and on a busy post there are hundreds of them.
*/
export function selfThread(thread: ThreadNode): Segment[] {
const anchor = thread.post;
const handle = anchor?.author?.handle;
if (!anchor || !handle) return [];
const before: Segment[] = [];
for (let node = thread.parent; node?.post?.author?.handle === handle; node = node.parent) {
// Walking up, so each is older than the last.
before.unshift(segmentOf(node.post, false));
}
const after: Segment[] = [];
let node: ThreadNode | undefined = thread;
const guard = new Set<string>();
for (;;) {
// A chain continues through the author's own reply. Where they left
// several, the earliest is the one the rest hangs off.
const next: ThreadNode | undefined = (node?.replies ?? [])
.filter((r) => r.post?.author?.handle === handle && r.post.uri && !guard.has(r.post.uri))
.sort((a, b) =>
(a.post?.record?.createdAt ?? '').localeCompare(b.post?.record?.createdAt ?? ''))[0];
if (!next?.post?.uri) break;
guard.add(next.post.uri);
after.push(segmentOf(next.post, false));
node = next;
}
return [...before, segmentOf(anchor, true), ...after];
}
export function toPost(thread: ThreadNode, originalUrl: string): Post {
const post = thread.post ?? {};
const handle = post.author?.handle ?? 'unknown'; const handle = post.author?.handle ?? 'unknown';
return { return {
platform: 'bluesky', platform: 'bluesky',
@@ -86,10 +139,8 @@ export function toPost(post: BskyPost, originalUrl: string): Post {
...(post.author?.displayName ? { displayName: post.author.displayName } : {}), ...(post.author?.displayName ? { displayName: post.author.displayName } : {}),
...(post.author?.avatar ? { avatar: { url: post.author.avatar } } : {}), ...(post.author?.avatar ? { avatar: { url: post.author.avatar } } : {}),
}, },
...(post.record?.text ? { text: post.record.text } : {}),
textPosition: 'above', textPosition: 'above',
media: mediaFromEmbed(post.embed), segments: selfThread(thread),
...(post.record?.createdAt ? { postedAt: post.record.createdAt } : {}),
}; };
} }
@@ -112,12 +163,15 @@ async function resolve({ page, originalUrl, pathRest }: ResolveContext): Promise
} }
const uri = `at://${did}/app.bsky.feed.post/${parsed.rkey}`; const uri = `at://${did}/app.bsky.feed.post/${parsed.rkey}`;
// Deep enough to follow a chain of any reasonable length. The cost is that
// the reply tree comes too -- a few hundred KB on a busy post -- and there
// is no way to ask for one author's branch.
const direct = await page.request const direct = await page.request
.get(`${API}/app.bsky.feed.getPostThread?depth=0&parentHeight=0&uri=${encodeURIComponent(uri)}`) .get(`${API}/app.bsky.feed.getPostThread?depth=12&parentHeight=12&uri=${encodeURIComponent(uri)}`)
.then((r) => (r.ok() ? (r.json() as Promise<ThreadResponse>) : undefined)) .then((r) => (r.ok() ? (r.json() as Promise<ThreadResponse>) : undefined))
.catch(() => undefined); .catch(() => undefined);
if (direct?.thread?.post) return toPost(direct.thread.post, originalUrl); if (direct?.thread?.post) return toPost(direct.thread, originalUrl);
// Fall back to loading the post the way a person would and catching the // Fall back to loading the post the way a person would and catching the
// same call as it goes past. // same call as it goes past.
@@ -127,7 +181,7 @@ async function resolve({ page, originalUrl, pathRest }: ResolveContext): Promise
const captured = captureJson<ThreadResponse>(page, (url) => url.includes('getPostThread?')); const captured = captureJson<ThreadResponse>(page, (url) => url.includes('getPostThread?'));
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' }); await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
const body = await captured.wait(config.navigationTimeoutMs); const body = await captured.wait(config.navigationTimeoutMs);
if (body?.thread?.post) return toPost(body.thread.post, originalUrl); if (body?.thread?.post) return toPost(body.thread, originalUrl);
throw new ResolveError('Bluesky returned no post for that URL', 'bluesky', originalUrl); throw new ResolveError('Bluesky returned no post for that URL', 'bluesky', originalUrl);
} }
+6 -4
View File
@@ -1,4 +1,4 @@
import { ResolveError, type Media, type Post } from '../types.ts'; import { ResolveError, oneSegment, type Media, type Post } from '../types.ts';
import { readOpenGraph } from '../browser/capture.ts'; import { readOpenGraph } from '../browser/capture.ts';
import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders } from './meta-media.ts'; import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders } from './meta-media.ts';
import { extractJsonObjectAfterKey } from './scan.ts'; import { extractJsonObjectAfterKey } from './scan.ts';
@@ -294,10 +294,12 @@ async function resolve(ctx: ResolveContext): Promise<Post> {
? { avatar: { url: scraped.avatar, fetchHeaders: metaHeaders(REFERER) } } ? { avatar: { url: scraped.avatar, fetchHeaders: metaHeaders(REFERER) } }
: {}), : {}),
}, },
...(scraped.text ? { text: scraped.text } : {}),
textPosition: 'below', textPosition: 'below',
media: scraped.media, segments: oneSegment({
...(scraped.postedAt ? { postedAt: scraped.postedAt } : {}), ...(scraped.text ? { text: scraped.text } : {}),
media: scraped.media,
...(scraped.postedAt ? { postedAt: scraped.postedAt } : {}),
}),
}; };
} }
+141 -34
View File
@@ -1,18 +1,36 @@
import { ResolveError, type Media, type Post } from '../types.ts'; import { ResolveError, type Media, type Post, type Segment } from '../types.ts';
import { readOpenGraph } from '../browser/capture.ts'; import { readOpenGraph } from '../browser/capture.ts';
import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders } from './meta-media.ts'; import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders, type MetaMediaNode } from './meta-media.ts';
import { hostMatcher } from './url.ts'; import { hostMatcher } from './url.ts';
import type { PlatformSpec, ResolveContext } from './types.ts'; import type { PlatformSpec, ResolveContext } from './types.ts';
const REFERER = 'https://www.threads.com/'; const REFERER = 'https://www.threads.com/';
/** One post as Threads ships it, alongside the media fields Instagram shares. */
type ThreadsPost = MetaMediaNode & {
code?: string;
taken_at?: number;
caption?: { text?: string };
user?: { username?: string };
text_post_app_info?: {
reply_to_author?: { username?: string };
};
};
type ThreadContainer = {
thread_items?: Array<{ post?: ThreadsPost }>;
};
function handleFromPath(pathRest: string): string | undefined { function handleFromPath(pathRest: string): string | undefined {
const match = /^(@[^/]+)/.exec(pathRest); return /^(@[^/]+)/.exec(pathRest)?.[1];
return match?.[1]; }
function codeFromPath(pathRest: string): string | undefined {
return /\/post\/([A-Za-z0-9_-]+)/.exec(`/${pathRest}`)?.[1];
} }
/** Threads ships its Relay payloads in `<script type="application/json">` /** Threads ships its Relay payloads in `<script type="application/json">`
* tags, several dozen of them, in an order that is not worth relying on. */ * tags, several dozen of them, in an order not worth relying on. */
async function inlinePayloads(ctx: ResolveContext): Promise<unknown[]> { async function inlinePayloads(ctx: ResolveContext): Promise<unknown[]> {
const raw = await ctx.page const raw = await ctx.page
.evaluate(() => .evaluate(() =>
@@ -31,60 +49,149 @@ async function inlinePayloads(ctx: ResolveContext): Promise<unknown[]> {
}); });
} }
async function fromOpenGraph(ctx: ResolveContext): Promise<{ media: Media[]; text?: string }> { /** Every post the page shipped, from the thread containers it renders. */
const og = await readOpenGraph(ctx.page); export function collectPosts(root: unknown): ThreadsPost[] {
const fetchHeaders = metaHeaders(REFERER); const seen = new Set<unknown>();
const queue: unknown[] = [root];
const posts: ThreadsPost[] = [];
if (og['og:video']) { for (let i = 0; i < queue.length; i += 1) {
return { const value = queue[i];
media: [{ if (value === null || typeof value !== 'object' || seen.has(value)) continue;
kind: 'video', seen.add(value);
url: og['og:video'],
fetchHeaders, if (Array.isArray(value)) {
...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}), queue.push(...value);
}], continue;
...(og['og:description'] ? { text: og['og:description'] } : {}), }
};
const record = value as Record<string, unknown>;
const items = (record as ThreadContainer).thread_items;
if (Array.isArray(items)) {
for (const item of items) {
if (item?.post?.code) posts.push(item.post);
}
}
queue.push(...Object.values(record));
} }
return posts;
}
/**
* The author's own chain, and nothing else.
*
* The page carries three kinds of post: the linked one, the author's
* follow-ups, and a pile of other people's replies and recommendations. A
* follow-up is the author replying to themselves, which is exactly what
* `reply_to_author` distinguishes — someone else replying to the same post
* carries the same field with a different name on it.
*/
export function selfThread(posts: ThreadsPost[], anchorCode: string | undefined): ThreadsPost[] {
const anchor = anchorCode ? posts.find((p) => p.code === anchorCode) : undefined;
const author = anchor?.user?.username;
if (!anchor || !author) return [];
// Same author, oldest first. Everyone else is a reply or a recommendation.
const mine = [...new Map(
posts.filter((p) => p.user?.username === author && p.code).map((p) => [p.code, p]),
).values()].sort((a, b) => (a.taken_at ?? 0) - (b.taken_at ?? 0));
const at = mine.findIndex((p) => p.code === anchor.code);
if (at === -1) return [anchor];
// A post continues the one before it when it is the author replying to
// themselves. Walk out from the anchor in both directions for as long as
// that holds: the link may point at the middle of a thread, and the first
// post of a thread is not a reply to anything, so it can only be reached
// backwards from the one that answers it.
const continues = (post: ThreadsPost | undefined): boolean =>
post?.text_post_app_info?.reply_to_author?.username === author;
let first = at;
while (first > 0 && continues(mine[first])) first -= 1;
let last = at;
while (last + 1 < mine.length && continues(mine[last + 1])) last += 1;
return mine.slice(first, last + 1);
}
function segmentOf(post: ThreadsPost, isAnchor: boolean): Segment {
return { return {
media: og['og:image'] ? [{ kind: 'image', url: og['og:image'], fetchHeaders }] : [], ...(post.caption?.text ? { text: post.caption.text } : {}),
...(og['og:description'] ? { text: og['og:description'] } : {}), media: mediaFromMetaNode(post, REFERER),
...(post.taken_at ? { postedAt: new Date(post.taken_at * 1000).toISOString() } : {}),
...(isAnchor ? { isAnchor: true } : {}),
}; };
} }
/** The floor, when the structured payload is not there to be read. */
async function fromOpenGraph(ctx: ResolveContext): Promise<Segment | undefined> {
const og = await readOpenGraph(ctx.page);
const fetchHeaders = metaHeaders(REFERER);
const media: Media[] = [];
if (og['og:video']) {
media.push({
kind: 'video',
url: og['og:video'],
fetchHeaders,
...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}),
});
} else if (og['og:image']) {
media.push({ kind: 'image', url: og['og:image'], fetchHeaders });
}
const text = og['og:description'];
if (media.length === 0 && !text) return undefined;
return { media, ...(text ? { text } : {}), isAnchor: true };
}
async function resolve(ctx: ResolveContext): Promise<Post> { async function resolve(ctx: ResolveContext): Promise<Post> {
const { page, originalUrl, pathRest } = ctx; const { page, originalUrl, pathRest } = ctx;
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' }); await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
const payloads = await inlinePayloads(ctx); const payloads = await inlinePayloads(ctx);
const node = findMetaMedia(payloads); const chain = selfThread(collectPosts(payloads), codeFromPath(pathRest));
let media = node ? mediaFromMetaNode(node, REFERER) : []; let segments: Segment[] = chain.map((post) =>
let text = findCaption(payloads); segmentOf(post, post.code === codeFromPath(pathRest)),
);
if (media.length === 0 || !text) { if (segments.length === 0) {
const og = await fromOpenGraph(ctx); // No thread containers. Fall back to whatever single post is readable.
if (media.length === 0) media = og.media; const node = findMetaMedia(payloads);
text ??= og.text; const caption = findCaption(payloads);
if (node ?? caption) {
segments = [{
media: node ? mediaFromMetaNode(node, REFERER) : [],
...(caption ? { text: caption } : {}),
isAnchor: true,
}];
}
} }
// A Threads post is often only words, so unlike the others an empty media if (segments.length === 0) {
// list is a perfectly good result — but nothing at all is a failure. const og = await fromOpenGraph(ctx);
if (media.length === 0 && !text) { if (og) segments = [og];
}
// A Threads post is often only words, so empty media is a fine result --
// but nothing at all is a failure.
if (segments.length === 0) {
throw new ResolveError('Threads returned nothing for that URL', 'threads', originalUrl); throw new ResolveError('Threads returned nothing for that URL', 'threads', originalUrl);
} }
const handle = handleFromPath(pathRest) ?? '@threads'; const handle = chain[0]?.user?.username ?? handleFromPath(pathRest)?.slice(1) ?? 'threads';
return { return {
platform: 'threads', platform: 'threads',
platformLabel: 'Threads', platformLabel: 'Threads',
originalUrl, originalUrl,
author: { handle }, author: { handle: `@${handle}` },
...(text ? { text } : {}),
textPosition: 'above', textPosition: 'above',
media, segments,
}; };
} }
+16 -12
View File
@@ -1,5 +1,5 @@
import type { Page } from 'playwright'; import type { Page } from 'playwright';
import { ChallengeError, ResolveError, type Media, type Post } from '../types.ts'; import { ChallengeError, ResolveError, oneSegment, type Media, type Post } from '../types.ts';
import { cookieHeaderFor } from '../browser/pool.ts'; import { cookieHeaderFor } from '../browser/pool.ts';
import { CHALLENGE_SELECTORS, park } from '../challenge/registry.ts'; import { CHALLENGE_SELECTORS, park } from '../challenge/registry.ts';
import { config } from '../config.ts'; import { config } from '../config.ts';
@@ -157,12 +157,14 @@ async function resolve(ctx: ResolveContext): Promise<Post> {
...(item.author?.nickname ? { displayName: item.author.nickname } : {}), ...(item.author?.nickname ? { displayName: item.author.nickname } : {}),
...(avatar ? { avatar: { url: avatar, fetchHeaders: await tiktokHeaders(avatar) } } : {}), ...(avatar ? { avatar: { url: avatar, fetchHeaders: await tiktokHeaders(avatar) } } : {}),
}, },
...(item.desc ? { text: item.desc } : {}),
textPosition: 'below', textPosition: 'below',
media, segments: oneSegment({
...(Number.isFinite(created) && created > 0 ...(item.desc ? { text: item.desc } : {}),
? { postedAt: new Date(created * 1000).toISOString() } media,
: {}), ...(Number.isFinite(created) && created > 0
? { postedAt: new Date(created * 1000).toISOString() }
: {}),
}),
}; };
} }
} }
@@ -190,13 +192,15 @@ async function resolve(ctx: ResolveContext): Promise<Post> {
platformLabel: 'TikTok', platformLabel: 'TikTok',
originalUrl, originalUrl,
author: { handle: handleFrom(landed) ?? handleFrom(originalUrl) ?? '@tiktok' }, author: { handle: handleFrom(landed) ?? handleFrom(originalUrl) ?? '@tiktok' },
...(og['og:description'] ? { text: og['og:description'] } : {}),
textPosition: 'below', textPosition: 'below',
media: [{ segments: oneSegment({
kind: og['og:video'] ? 'video' : 'image', ...(og['og:description'] ? { text: og['og:description'] } : {}),
url, media: [{
fetchHeaders: await tiktokHeaders(url), kind: og['og:video'] ? 'video' : 'image',
}], url,
fetchHeaders: await tiktokHeaders(url),
}],
}),
}; };
} }
+11 -9
View File
@@ -1,5 +1,5 @@
import { config } from '../config.ts'; import { config } from '../config.ts';
import { ResolveError, type Media, type Post } from '../types.ts'; import { ResolveError, oneSegment, type Media, type Post } from '../types.ts';
import { captureJson } from '../browser/capture.ts'; import { captureJson } from '../browser/capture.ts';
import { hostMatcher } from './url.ts'; import { hostMatcher } from './url.ts';
import type { PlatformSpec, ResolveContext } from './types.ts'; import type { PlatformSpec, ResolveContext } from './types.ts';
@@ -88,15 +88,17 @@ export function toPost(result: TweetResult, originalUrl: string): Post {
// `_normal` is a 48px thumbnail; `_x96` is the same file at 96. // `_normal` is a 48px thumbnail; `_x96` is the same file at 96.
...(avatar ? { avatar: { url: avatar.replace('_normal', '_x96') } } : {}), ...(avatar ? { avatar: { url: avatar.replace('_normal', '_x96') } } : {}),
}, },
...(result.text ? { text: result.text } : {}),
textPosition: 'above', textPosition: 'above',
// A quote post usually carries no media of its own — the picture being segments: oneSegment({
// talked about belongs to the post it quotes, and that is the thing ...(result.text ? { text: result.text } : {}),
// worth showing. // A quote post usually carries no media of its own — the picture being
media: mediaFromDetails( // talked about belongs to the post it quotes, and that is the thing
result.mediaDetails?.length ? result.mediaDetails : result.quoted_tweet?.mediaDetails, // worth showing.
), media: mediaFromDetails(
...(result.created_at ? { postedAt: result.created_at } : {}), result.mediaDetails?.length ? result.mediaDetails : result.quoted_tweet?.mediaDetails,
),
...(result.created_at ? { postedAt: result.created_at } : {}),
}),
}; };
} }
+32 -14
View File
@@ -1,6 +1,6 @@
import { proxyUrlFor } from '../media/registry.ts'; import { proxyUrlFor } from '../media/registry.ts';
import type { Media, Post } from '../types.ts'; import { anchorOf, type Media, type Post, type Segment } from '../types.ts';
import { html, type Raw } from './html.ts'; import { html, raw, type Raw } from './html.ts';
import { badge, layout, originalUrlBlock } from './layout.ts'; import { badge, layout, originalUrlBlock } from './layout.ts';
import { linkify } from './text.ts'; import { linkify } from './text.ts';
@@ -54,14 +54,14 @@ function renderMedia(media: Media[]): Raw {
</section>`; </section>`;
} }
function renderText(post: Post): Raw { function renderText(segment: Segment, post: Post): Raw {
if (!post.text) return html``; if (!segment.text) return html``;
return html`<div class="text">${linkify(post.text, post.platform)}</div>`; return html`<div class="text">${linkify(segment.text, post.platform)}</div>`;
} }
function renderWhen(post: Post): Raw { function renderWhen(postedAt: string | undefined): Raw {
if (!post.postedAt) return html``; if (!postedAt) return html``;
const date = new Date(post.postedAt); const date = new Date(postedAt);
if (Number.isNaN(date.getTime())) return html``; if (Number.isNaN(date.getTime())) return html``;
return html`<time datetime="${date.toISOString()}">${date.toLocaleString('en-CA', { return html`<time datetime="${date.toISOString()}">${date.toLocaleString('en-CA', {
dateStyle: 'medium', dateStyle: 'medium',
@@ -69,11 +69,31 @@ function renderWhen(post: Post): Raw {
})}</time>`; })}</time>`;
} }
/**
* One post in the chain. When there is only one, the wrapper is invisible;
* when there are several, each is separated and the one that was actually
* linked is marked, since it may not be the first.
*/
function renderSegment(segment: Segment, post: Post, threaded: boolean): Raw {
const above = post.textPosition === 'above';
return html`<section class="segment" ${threaded && segment.isAnchor ? raw('data-anchor="true"') : ''}>
${threaded && segment.isAnchor
? html`<p class="segment__mark">the post you followed</p>`
: ''}
${above ? renderText(segment, post) : ''}
${renderMedia(segment.media)}
${above ? '' : renderText(segment, post)}
</section>`;
}
export function renderPost(post: Post): string { export function renderPost(post: Post): string {
const avatar = post.author.avatar ? proxyUrlFor(post.author.avatar) : undefined; const avatar = post.author.avatar ? proxyUrlFor(post.author.avatar) : undefined;
const above = post.textPosition === 'above'; const threaded = post.segments.length > 1;
const anchor = anchorOf(post);
const body = html`<article class="post" data-platform="${post.platform}"> const body = html`<article class="post" data-platform="${post.platform}" ${
threaded ? raw(`data-segments="${post.segments.length}"`) : ''
}>
<header class="post__head"> <header class="post__head">
${badge(post.platform, post.platformLabel)} ${badge(post.platform, post.platformLabel)}
<div class="who"> <div class="who">
@@ -85,12 +105,10 @@ export function renderPost(post: Post): string {
</div> </div>
</header> </header>
${above ? renderText(post) : ''} ${post.segments.map((segment) => renderSegment(segment, post, threaded))}
${renderMedia(post.media)}
${above ? '' : renderText(post)}
<footer class="post__foot"> <footer class="post__foot">
${renderWhen(post)} ${renderWhen(anchor?.postedAt)}
${originalUrlBlock(post.originalUrl, post.platformLabel)} ${originalUrlBlock(post.originalUrl, post.platformLabel)}
</footer> </footer>
</article>`; </article>`;
+28 -4
View File
@@ -40,6 +40,22 @@ export type Author = {
avatar?: Asset; avatar?: Asset;
}; };
/**
* One post. Usually a whole `Post` is a single segment, but on the platforms
* where people write in chains — Bluesky and Threads — the author's own
* follow-ups belong with the one that was linked, and other people's replies
* do not.
*/
export type Segment = {
text?: string;
media: Media[];
/** ISO 8601. */
postedAt?: string;
/** The post the link actually pointed at. Only meaningful when a thread
* has more than one segment. */
isAnchor?: boolean;
};
/** /**
* The single shape every adapter produces and the renderer consumes. Adding * The single shape every adapter produces and the renderer consumes. Adding
* a platform means producing one of these; nothing downstream changes. * a platform means producing one of these; nothing downstream changes.
@@ -52,15 +68,23 @@ export type Post = {
* the copy button hands back. */ * the copy button hands back. */
originalUrl: string; originalUrl: string;
author: Author; author: Author;
text?: string;
/** Fixed per platform: the ones that lead with words put the text above /** Fixed per platform: the ones that lead with words put the text above
* the media, the ones that lead with pictures put it below. */ * the media, the ones that lead with pictures put it below. */
textPosition: 'above' | 'below'; textPosition: 'above' | 'below';
media: Media[]; /** In the order they were written. Never empty. */
/** ISO 8601. */ segments: Segment[];
postedAt?: string;
}; };
/** Most platforms have no notion of a chain, so their adapters use this. */
export function oneSegment(segment: Segment): Segment[] {
return [{ ...segment, isAnchor: true }];
}
/** The segment the link pointed at, or the first one. */
export function anchorOf(post: Post): Segment | undefined {
return post.segments.find((s) => s.isAnchor) ?? post.segments[0];
}
/** /**
* Thrown when a post cannot be resolved. Carries enough for the error card * Thrown when a post cannot be resolved. Carries enough for the error card
* to still be useful: which platform, and the link to hand back. * to still be useful: which platform, and the link to hand back.
+86 -11
View File
@@ -1,30 +1,35 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { test } from 'node:test'; import { test } from 'node:test';
import { mediaFromEmbed, toPost } from '../src/platforms/bluesky.ts'; import { mediaFromEmbed, selfThread, toPost } from '../src/platforms/bluesky.ts';
import { fixture } from './helpers.ts'; import { fixture } from './helpers.ts';
const URL_ = 'https://bsky.app/profile/example/post/abc'; 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', () => { test('a text-only post carries no media', () => {
const post = toPost(fixture('bluesky/text-only.json'), URL_); const post = single('text-only');
assert.equal(post.platform, 'bluesky'); assert.equal(post.platform, 'bluesky');
assert.equal(post.textPosition, 'above'); assert.equal(post.textPosition, 'above');
assert.deepEqual(post.media, []); assert.equal(post.segments.length, 1);
assert.ok(post.text); assert.deepEqual(post.segments[0]?.media, []);
assert.ok(post.segments[0]?.text);
assert.match(post.author.handle, /^@/); assert.match(post.author.handle, /^@/);
}); });
test('an image post keeps every image, in order, with alt text', () => { test('an image post keeps every image, in order, with alt text', () => {
const post = toPost(fixture('bluesky/images.json'), URL_); const media = single('images').segments[0]?.media ?? [];
assert.ok(post.media.length > 1, 'expected a multi-image post'); assert.ok(media.length > 1, 'expected a multi-image post');
assert.ok(post.media.every((m) => m.kind === 'image')); assert.ok(media.every((m) => m.kind === 'image'));
assert.ok(post.media.every((m) => m.url.startsWith('https://'))); assert.ok(media.every((m) => m.url.startsWith('https://')));
assert.ok(post.media.some((m) => m.kind === 'image' && m.alt)); assert.ok(media.some((m) => m.kind === 'image' && m.alt));
}); });
test('a video post is a direct HLS playlist with a poster', () => { test('a video post is a direct HLS playlist with a poster', () => {
const post = toPost(fixture('bluesky/video.json'), URL_); const item = single('video').segments[0]?.media[0];
const [item] = post.media;
assert.equal(item?.kind, 'video'); assert.equal(item?.kind, 'video');
assert.ok(item?.kind === 'video' && item.hls); assert.ok(item?.kind === 'video' && item.hls);
// Proxying HLS would mean rewriting the manifest, so it is linked direct. // Proxying HLS would mean rewriting the manifest, so it is linked direct.
@@ -49,3 +54,73 @@ test('a quote post shows the media of the post it quotes', () => {
}); });
assert.deepEqual(media, [{ kind: 'image', url: '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']);
});
+321
View File
@@ -0,0 +1,321 @@
{
"post": {
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7inwsws2k",
"author": {
"handle": "danielle.bsky.team",
"displayName": "danielleyuhan",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq"
},
"record": {
"createdAt": "2026-08-20T21:40:10.285Z",
"text": "We're redesigning the notifications tab! \ud83d\udd14 It's one of the most visited surfaces on the app, and a lot of you have told us it could be better. Here's a look at what we're exploring. \ud83e\uddf5"
},
"embed": {
"images": [
{
"thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreiaifo3c4pchs2qtgbyblpt3r35eqngodxzckksiti6ufrnw7e3uoq",
"fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreiaifo3c4pchs2qtgbyblpt3r35eqngodxzckksiti6ufrnw7e3uoq",
"alt": "\"All\" tab that shows all your notifications in chronological order.",
"aspectRatio": {
"height": 1704,
"width": 786
}
},
{
"thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreiezmgsafcbrgbqcaoxf4uu6qwcb4vxsjngtwpoylznut52imhdjue",
"fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreiezmgsafcbrgbqcaoxf4uu6qwcb4vxsjngtwpoylznut52imhdjue",
"alt": "\"Follows\" tab that shows you a list of people who follow you, so you never miss a follow-back.",
"aspectRatio": {
"height": 1704,
"width": 786
}
},
{
"thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreieigolasgenhs4guectwhrprc4te6el7fosn5ayzexpxjh6tk565i",
"fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreieigolasgenhs4guectwhrprc4te6el7fosn5ayzexpxjh6tk565i",
"alt": "\"Conversations\" tab that allows you to view mentions, replies, and quotes all in one place. ",
"aspectRatio": {
"height": 1704,
"width": 786
}
},
{
"thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreihtzrgrltxhxcnrzp6zkn2bkkhyd5fhu5hp3xtypf4h24q5x5ygei",
"fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreihtzrgrltxhxcnrzp6zkn2bkkhyd5fhu5hp3xtypf4h24q5x5ygei",
"alt": "\"Activity\" tab that shows all the posts from accounts you've subscribed to.",
"aspectRatio": {
"height": 1704,
"width": 786
}
}
],
"$type": "app.bsky.embed.images#view"
}
},
"replies": [
{
"post": {
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7ipjjyc2k",
"author": {
"handle": "danielle.bsky.team",
"displayName": "danielleyuhan",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq"
},
"record": {
"createdAt": "2026-08-20T21:40:10.286Z",
"text": "We went through a ton of feedback you've shared. Big themes: important stuff gets buried (replies lost under likes, missed replies from people you care about), too much noise, hard to scan, and not enough control over which notifications you see. This design is our first pass at addressing these."
}
},
"replies": [
{
"post": {
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7ipjkxk2k",
"author": {
"handle": "danielle.bsky.team",
"displayName": "danielleyuhan",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq"
},
"record": {
"createdAt": "2026-08-20T21:40:10.287Z",
"text": "The biggest change is tabs. All = everything, chronological. People you follow = just notifications from people you follow. Follows = a list of people who follow you, so you never miss a follow-back. Conversations = replies, mentions and quotes. Activity = posts from accounts you've subscribed to."
}
},
"replies": [
{
"post": {
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7ipjlws2k",
"author": {
"handle": "danielle.bsky.team",
"displayName": "danielleyuhan",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq"
},
"record": {
"createdAt": "2026-08-20T21:40:10.288Z",
"text": "Some additional UX improvements include \"today\" and \"earlier\" sections so you know what's new at a glance, and like/reply buttons that change once you've responded so you can keep track of which conversations you've already answered."
}
},
"replies": [
{
"post": {
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7ipjmw22k",
"author": {
"handle": "danielle.bsky.team",
"displayName": "danielleyuhan",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq"
},
"record": {
"createdAt": "2026-08-20T21:40:10.289Z",
"text": "Also, when someone new follows you, you\u2019ll now see \u201cmutual followers\u201d (the people you follow who follow them too). Replies show a preview of the post they're responding to, and images render smaller as thumbnails so each notification takes less room and more fit on screen."
}
},
"replies": [
{
"post": {
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7ipjmw32k",
"author": {
"handle": "danielle.bsky.team",
"displayName": "danielleyuhan",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq"
},
"record": {
"createdAt": "2026-08-20T21:40:10.290Z",
"text": "Let us know what works or what's missing. We'll read everything \ud83d\ude42"
}
},
"replies": [
{
"post": {
"uri": "at://did:plc:zeytbxgl4t3fll3efksmbj7v/app.bsky.feed.post/3mtkba2q4tc2y",
"author": {
"handle": "cageyratfish.bsky.social",
"displayName": "cageyratfish (UTC-7) \ud83d\udc1f\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:zeytbxgl4t3fll3efksmbj7v/bafkreihef5m2gi337frbs6oo6ktpcvofn4u7bbt6w3bzyva37t3d6wq6vq"
},
"record": {
"createdAt": "2026-08-20T22:11:09.193Z",
"text": "not sure if you've addressed this, but when someone is posting a long thread (say, photos from a trip, or a multi-post essay etc) the thread will break when someone else replies to one of the posts in the OP's thread, & it's kind of a PITA to go back & click around to read the whole original thread"
}
}
},
{
"post": {
"uri": "at://did:plc:xosycr2qouakkyqdw2gaj73l/app.bsky.feed.post/3mtlj5ww23a26",
"author": {
"handle": "announcer-stef.bsky.social",
"displayName": "Stef",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:xosycr2qouakkyqdw2gaj73l/bafkreiegrnevysrpkvpuiyjgx5klywt7p3k3qec7lczx2udkid6tayrnam"
},
"record": {
"createdAt": "2026-08-21T10:05:47.757Z",
"text": "PLEASE can we have a landscape format that works on tablets, iPads, etc. mine looks like this. X way ahead of you on this. Thanks "
},
"embed": {
"images": [
{
"thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:xosycr2qouakkyqdw2gaj73l/bafkreidoxfy7a22jhag75mebsrpepmyjqg62igdu27x2unracm2r27xtyy",
"fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:xosycr2qouakkyqdw2gaj73l/bafkreidoxfy7a22jhag75mebsrpepmyjqg62igdu27x2unracm2r27xtyy",
"alt": "",
"aspectRatio": {
"height": 1396,
"width": 2000
}
}
],
"$type": "app.bsky.embed.images#view"
}
}
}
]
},
{
"post": {
"uri": "at://did:plc:d3hjgygqxvboxvfr4kdr47tx/app.bsky.feed.post/3mtknbgx57k27",
"author": {
"handle": "frozenkoi.bsky.social",
"displayName": "DK, Carb Cat Herder",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:d3hjgygqxvboxvfr4kdr47tx/bafkreiemc7lvmziylyqbab5wjq44vq5r5dmbr7nlpiqsfdjyzqcxr2lhru"
},
"record": {
"createdAt": "2026-08-21T01:46:40.462Z",
"text": "Can the \"xyz account followed you\" notification be improved so that it can show if that account had recently followed you, then unfollowed, then followed again (maybe show a counter or label)? To help weed out engagement farmers."
}
}
},
{
"post": {
"uri": "at://did:plc:avvpb4ypfttlslmobsjqt7qq/app.bsky.feed.post/3mtlc3dwcuc23",
"author": {
"handle": "naarna.eurosky.social",
"displayName": "naarna (she/they)",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:avvpb4ypfttlslmobsjqt7qq/bafkreicfbuioo4uktbmrje3j3tga4h65vm32nzmipu6ql3pynxualxnebm"
},
"record": {
"createdAt": "2026-08-21T07:59:04.542Z",
"text": "The \"mutuals of a new follow\" is almost my favorite part of it \u2764\ufe0f \n\nIs it possible to add/keep the info when they followed from a starterpack, like it is with the current layout?"
}
}
}
]
},
{
"post": {
"uri": "at://did:plc:vgqkg3qfrcphyv6c7mfqqtao/app.bsky.feed.post/3mtkzbnw6zc2n",
"author": {
"handle": "qtea.me",
"displayName": "un(tea)bable \u2728",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:vgqkg3qfrcphyv6c7mfqqtao/bafkreibrurq343pl46w6ebknc4pur7q6nu76tffzjcc64s5tqht2epwxky"
},
"record": {
"createdAt": "2026-08-21T05:21:32.673Z",
"text": "Having the interaction buttons look different from the normal timeline feels a bit weird, I think mentions should still display the same as any other post. \nWould it make more sense to change the normal \"number of replies\" icon on each post to display differently if you replied to it?"
}
}
},
{
"post": {
"uri": "at://did:plc:nejscld5zlavytijxz27qhvj/app.bsky.feed.post/3mtkalmpf432j",
"author": {
"handle": "marypcbuk.bsky.social",
"displayName": "Mary Branscombe",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:nejscld5zlavytijxz27qhvj/bafkreid62dpuwwlmnj2tqgbhpsnsmvchj6tfcetdqm2wu55zbuwm7zpacu"
},
"record": {
"createdAt": "2026-08-20T21:59:43.401Z",
"text": "can the today/earlier things be either optional o collapsible or even filterable? if they're going to take up so much space I'd like them to either earn their keep by being more useful or be something I can turn off. I don't really care if an unread @ is before or after midnight"
}
},
"replies": [
{
"post": {
"uri": "at://did:plc:nejscld5zlavytijxz27qhvj/app.bsky.feed.post/3mtkan4bdat2j",
"author": {
"handle": "marypcbuk.bsky.social",
"displayName": "Mary Branscombe",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:nejscld5zlavytijxz27qhvj/bafkreid62dpuwwlmnj2tqgbhpsnsmvchj6tfcetdqm2wu55zbuwm7zpacu"
},
"record": {
"createdAt": "2026-08-20T22:00:33.272Z",
"text": "but if I can use them to filter to replies from the last time I was in Amsterdam by picking a date span, they could be way more useful"
}
}
}
]
}
]
},
{
"post": {
"uri": "at://did:plc:uax67vh5253v2aszwz6xaj5t/app.bsky.feed.post/3mtkgmbau7s26",
"author": {
"handle": "cantras.bsky.social",
"displayName": "Cantras",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:uax67vh5253v2aszwz6xaj5t/bafkreieobaep6sevdkisnhbp4wkizybpo7uydbfj2vyseewmnf7trld524"
},
"record": {
"createdAt": "2026-08-20T23:47:27.395Z",
"text": "How is \"Notifications -> Activity\" different from the default \"Following\" feed?"
}
}
},
{
"post": {
"uri": "at://did:plc:nvfznp5ls3wcf6aktlfkfkp3/app.bsky.feed.post/3mtka7rsc6c2b",
"author": {
"handle": "erinbiba.bsky.social",
"displayName": "Erin Biba",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:nvfznp5ls3wcf6aktlfkfkp3/bafkreie2rvq65x3nzicc4gxpiglwsr5bj2dztvrczenm62mast4cclmsgi"
},
"record": {
"createdAt": "2026-08-20T21:53:06.083Z",
"text": "So replies mentions and quotes is from everyone. But there\u2019s no option to isolate replies mentions and quotes from reposts and likes from mutuals and people you follow? Just trying to understand because this is very interesting but it\u2019s A LOT."
}
}
}
]
},
{
"post": {
"uri": "at://did:plc:yczfsnwl2wfh65qvjjbesm53/app.bsky.feed.post/3mtkeo4irsc2y",
"author": {
"handle": "dd9000.bsky.social",
"displayName": "ddouglas \ud83d\udc8e \ud83c\uddfa\ud83c\udde6 \ud83e\udd8b ",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:yczfsnwl2wfh65qvjjbesm53/bafkreicc6ghxs4qqdng7msddjk63t5pnrf36l5iqjaxmgwjk46ykbdimqm"
},
"record": {
"createdAt": "2026-08-20T23:12:42.035Z",
"text": "My biggest problem is the \"30\" limit. I've set notifications \"on\" for the follows who are most important to me but, repeatedly, I'll ask myself what happened to X, check X's profile, been posting almost every day. OTOH, once I've clicked thru, I'd like it to disappear from the list."
}
}
}
]
},
{
"post": {
"uri": "at://did:plc:zxwfz6clggogwwj4j5uk3yqx/app.bsky.feed.post/3mtkmvwlvsk2i",
"author": {
"handle": "elmota.bsky.social",
"displayName": "Ayyash",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:zxwfz6clggogwwj4j5uk3yqx/bafkreib3b7qqynb2ehteq3fg2mvvsc5uzgaulqg4gujf2r24s57o4fhp7y"
},
"record": {
"createdAt": "2026-08-21T01:40:14.207Z",
"text": "Really? One of the most visited? I wonder \ud83e\udd14"
}
}
},
{
"post": {
"uri": "at://did:plc:zlwrdfy5dx7sdkelp2jnd7rm/app.bsky.feed.post/3mtkh6b3re22w",
"author": {
"handle": "skeeziks.bsky.social",
"displayName": "debbi\u00a9... \ud83d\udcfa\ud83d\udc40",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:zlwrdfy5dx7sdkelp2jnd7rm/bafkreictluwq7k5yi7a4igqpgcmg5y37tg5h6kf3aubs324r45bg7r2v7m"
},
"record": {
"createdAt": "2026-08-20T23:57:31.209Z",
"text": "Show us notifications from muted accounts, please. Just because I don\u2019t want to see someone\u2019s political posts doesn\u2019t mean I don\u2019t want to see if they comment on my posts."
}
}
}
]
}
+344
View File
@@ -0,0 +1,344 @@
[
{
"code": "Dbs42N8k8uT",
"taken_at": 1786025139,
"caption": {
"text": "@nasa live is so cool! Its so fun to see Jessica and Anil do their spacewalk .. its always encouraging to see women astronauts and their work▓▓▓▓▓\nMaybe @europeanspaceagency can also have some cool live about other missions  or live viewing of Earth or other space related stuff."
},
"user": {
"username": "arielbrunandthesea"
},
"text_post_app_info": {
"reply_to_author": null
},
"image_versions2": {
"candidates": [
{
"height": 2888,
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/764774453_17972992692121062_2185374187830722827_n.jpg?stp=cp6_dst-jpg_e35_tt6&_nc_cat=109&ig_cache_key=Mzk1Nzc4NzE0NzI4MTE0MjE1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuNDAwMS5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=h0gupge1KZkQ7kNvwGvrlx1&_nc_oc=Adqsqi5M7yggTFLue6KhoE9NXrDN34jg-_QhKj6e8nrCI8a1HzExKmjFNdhu1VmBtFk&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-1.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQGLlnGkPyNAps_dhwFsaPi2Ao2Y6EuWctZwRnSPgMSrAw&oe=6A9528E0",
"width": 4001
},
{
"height": 780,
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/764774453_17972992692121062_2185374187830722827_n.jpg?stp=cp6_dst-jpg_e35_s1080x1080_tt6&_nc_cat=109&ig_cache_key=Mzk1Nzc4NzE0NzI4MTE0MjE1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuNDAwMS5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=h0gupge1KZkQ7kNvwGvrlx1&_nc_oc=Adqsqi5M7yggTFLue6KhoE9NXrDN34jg-_QhKj6e8nrCI8a1HzExKmjFNdhu1VmBtFk&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-1.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQG2s0Vm7o3KIaR_nV9DofUPXeahOWJQmL25xmeFVQrhZQ&oe=6A9528E0",
"width": 1080
}
]
}
},
{
"code": "DcKY3AAGVDP",
"taken_at": 1787015002,
"caption": {
"text": "Right now as you read this, two astronauts are spacewalking outside the International Space Station. \n\n@nasa astronaut Anil Menon and @europeanspaceagency astronaut Sophie Adenot are conducting Spacewalk 97 today, August 18, replacing a Space-to-Ground antenna on the International Space Station. \n\nLive coverage began at 7AM EDT on NASA+. \n\nHere's what a spacewalk actually involves that nobody tells you: \n\nThey suit up in 300-pound EMU spacesuits that are essentially personal spacecraft. 👇"
},
"user": {
"username": "cosmiclab.space"
},
"text_post_app_info": {
"reply_to_author": null
},
"image_versions2": {
"candidates": [
{
"height": 1402,
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/778899179_18105140402113849_8885082483546796711_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=102&ig_cache_key=Mzk2NjA5MTc1NDU0MjI4OTEwMw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTEyMi5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=nvcD17NjvvoQ7kNvwFwgUux&_nc_oc=Adr7n_HeqGcl7LtOhcp_iQHCOut2OOCKH6Go0m4VFqtBNaKbE7QREb5KWeNmHt767ro&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQFdQc8Wj4gESXjji1lma5MnS2bO3M8ZC2WccZuF3-So1A&oe=6A951ED2",
"width": 1122
},
{
"height": 900,
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/778899179_18105140402113849_8885082483546796711_n.jpg?stp=dst-jpg_e35_p720x720_tt6&_nc_cat=102&ig_cache_key=Mzk2NjA5MTc1NDU0MjI4OTEwMw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTEyMi5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=nvcD17NjvvoQ7kNvwFwgUux&_nc_oc=Adr7n_HeqGcl7LtOhcp_iQHCOut2OOCKH6Go0m4VFqtBNaKbE7QREb5KWeNmHt767ro&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQHQqpTMS28QTD5DZa7Jkc-C7jGx5P9EM7JbSasqCtlaNA&oe=6A951ED2",
"width": 720
}
]
}
},
{
"code": "DceWVx-kSzb",
"taken_at": 1787684770,
"caption": {
"text": "On the Fly\n\nMission complete.\n\nSophie Adenot and Anil Menon are safely back inside the ISS after U.S. Spacewalk 98, completing work on the stations Space-to-Ground antenna system.\n\n⏱️ Duration: 6h 30mins\n\n📸 NASA live coverage"
},
"user": {
"username": "marzano.malone"
},
"text_post_app_info": {
"reply_to_author": null
},
"image_versions2": {
"candidates": [
{
"height": 643,
"url": "https://scontent-lga3-2.cdninstagram.com/v/t51.82787-15/784597767_18115184995973316_2626836958628726231_n.jpg?stp=cp6_dst-jpg_e35_tt6&_nc_cat=105&ig_cache_key=Mzk3MTcwOTUxODI0OTEzOTk4Ng%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTEyMS5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=rh3pk2X6IJcQ7kNvwFSm8yR&_nc_oc=AdoRvUeiaPx0FLP-HYdvD1VFw3V8vhz9JBNxakBbwZtkCvAVeMF444HeHOUxBQVr6TQ&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-2.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQF5oCnKCWaNwfCG2t3O_MJmjLRmxoShpX1epNFgJ0d-Tw&oe=6A95008A",
"width": 1121
},
{
"height": 413,
"url": "https://scontent-lga3-2.cdninstagram.com/v/t51.82787-15/784597767_18115184995973316_2626836958628726231_n.jpg?stp=cp6_dst-jpg_e35_s720x720_tt6&_nc_cat=105&ig_cache_key=Mzk3MTcwOTUxODI0OTEzOTk4Ng%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTEyMS5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=rh3pk2X6IJcQ7kNvwFSm8yR&_nc_oc=AdoRvUeiaPx0FLP-HYdvD1VFw3V8vhz9JBNxakBbwZtkCvAVeMF444HeHOUxBQVr6TQ&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-2.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQFkLH93NsfFKEYwPs0lYCfKMkwivG31luMp9lOMIgp1_w&oe=6A95008A",
"width": 720
}
]
}
},
{
"code": "DbsvvGxEWt6",
"taken_at": 1786020363,
"caption": {
"text": "NASA astronauts Jessica Meir and Anil Menon are stepping outside the International Space Station for a spacewalk to work on the orbiting outpost's power system and prepare for the installation of new solar arrays. Watch live: https://youtube.com/live/thfYPsRqxmw"
},
"user": {
"username": "spaceflightnow"
},
"text_post_app_info": {
"reply_to_author": null
},
"image_versions2": {
"candidates": [
{
"height": 917,
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/766580960_17978263872115867_5021188917983091958_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=103&ig_cache_key=Mzk1Nzc0ODExNzgxODkyNzk5NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI2My5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=7i7qcUEo9LcQ7kNvwG-2BIF&_nc_oc=AdqQIfyK9Otzo3_dU7rQ3BICwY5ItQuce0dVcmy6XIXEin5FAxjybp25tUMIXyKcIvk&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-1.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQFUoVSBvUuWYZ_4sfTpJi4TFEdESpRhfc4aeWwN9nTWlQ&oe=6A951EB5",
"width": 1263
},
{
"height": 784,
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/766580960_17978263872115867_5021188917983091958_n.jpg?stp=dst-jpg_e35_s1080x1080_tt6&_nc_cat=103&ig_cache_key=Mzk1Nzc0ODExNzgxODkyNzk5NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI2My5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=7i7qcUEo9LcQ7kNvwG-2BIF&_nc_oc=AdqQIfyK9Otzo3_dU7rQ3BICwY5ItQuce0dVcmy6XIXEin5FAxjybp25tUMIXyKcIvk&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-1.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQH__KSDX1NAaIRs4AAB1AY71kHGFdAHXkdwe8b4lyVwtw&oe=6A951EB5",
"width": 1080
}
]
}
},
{
"code": "DcMILDEgFD0",
"taken_at": 1787073362,
"caption": {
"text": "On the Fly\n\nNASA astronaut Anil Menon and ESA astronaut Sophie Adenot are outside the International Space Station today, replacing a high-speed communications antenna.\n\nThe spacewalk is Adenots first — making her the first French woman to conduct an EVA.\n\n📸 NASA Live feed."
},
"user": {
"username": "marzano.malone"
},
"text_post_app_info": {
"reply_to_author": null
},
"image_versions2": {
"candidates": [
{
"height": 628,
"url": "https://scontent-lga3-2.cdninstagram.com/v/t51.82787-15/778882209_18113994055973316_8456776599937454598_n.jpg?stp=cp6_dst-jpg_e35_tt6&_nc_cat=100&ig_cache_key=Mzk2NjU4MTMxNTM4OTY0MDk0OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTE3MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=HymAcb2XfLUQ7kNvwEHOgVr&_nc_oc=AdrDS6pVpFYD6bNORpGLeIakOTjX1BTxjxVB5_THJhhmP-elZXjB6h_2zZ6auBgheYo&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-2.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQELhjoCpHZzNAA_Gk3RPSNUUe1c2lhl-pi_JeW377PuDA&oe=6A9516AE",
"width": 1170
},
{
"height": 386,
"url": "https://scontent-lga3-2.cdninstagram.com/v/t51.82787-15/778882209_18113994055973316_8456776599937454598_n.jpg?stp=cp6_dst-jpg_e35_s720x720_tt6&_nc_cat=100&ig_cache_key=Mzk2NjU4MTMxNTM4OTY0MDk0OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTE3MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=HymAcb2XfLUQ7kNvwEHOgVr&_nc_oc=AdrDS6pVpFYD6bNORpGLeIakOTjX1BTxjxVB5_THJhhmP-elZXjB6h_2zZ6auBgheYo&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-2.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQHPYN6VEAGFfhaR0SdsQ35etsdchzIHaxEunembQl7P2g&oe=6A9516AE",
"width": 720
}
]
}
},
{
"code": "DccGgKuEW-E",
"taken_at": 1787609357,
"caption": {
"text": "Home improvement looks a little different at 250 miles (400 km) above Earth.\n\nWatch live as NASA astronaut Anil Menon and @europeanspaceagency astronaut Sophie Adenot step outside the International Space Station at 7 a.m. EDT (1100 UTC) on Tuesday, Aug. 25 for their second spacewalk together, finishing installation of a new communications antenna."
},
"user": {
"username": "nasa"
},
"text_post_app_info": {
"reply_to_author": null
},
"image_versions2": {
"candidates": [
{
"height": 2001,
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=1&ig_cache_key=Mzk3MTA3NzU3MDI0NjU2OTg2MA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTYwMS5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=oqiv0FBeh0gQ7kNvwGO3kcE&_nc_oc=AdpcHKPcKduy07EfssZ4zHV4Mk8TkqtFYffmIfwL1AAZoQp_4EoX9fX0oUkamNFpFEM&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQH2smIgAIkUJ8ueESJyMPcniSpCTVDoMn6Qb_PQyuXtsg&oe=6A95173D",
"width": 1601
},
{
"height": 1350,
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=dst-jpg_e35_p1080x1080_tt6&_nc_cat=1&ig_cache_key=Mzk3MTA3NzU3MDI0NjU2OTg2MA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTYwMS5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=oqiv0FBeh0gQ7kNvwGO3kcE&_nc_oc=AdpcHKPcKduy07EfssZ4zHV4Mk8TkqtFYffmIfwL1AAZoQp_4EoX9fX0oUkamNFpFEM&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQGLewsERJ0a7RuQZg26IfpkCEMwXivDYaq0qZylWD03rQ&oe=6A95173D",
"width": 1080
}
]
}
},
{
"code": "DccGhYSkXYY",
"taken_at": 1787609367,
"caption": {
"text": "Here's where you can watch!"
},
"user": {
"username": "nasa"
},
"text_post_app_info": {
"reply_to_author": {
"username": "nasa"
}
}
},
{
"code": "DcdOzzVjUWc",
"taken_at": 1787647267,
"caption": {
"text": "Teamwork 💙"
},
"user": {
"username": "europeanspaceagency"
},
"text_post_app_info": {
"reply_to_author": {
"username": "nasa"
}
}
},
{
"code": "DceELlFApZ1",
"taken_at": 1787675249,
"caption": {
"text": "Dual units are appropriate and appreciated. Making SI (metric) units familiar to Americans is important work. Don't look back. ✌🏼❤️🌐"
},
"user": {
"username": "eyeballhatred"
},
"text_post_app_info": {
"reply_to_author": {
"username": "nasa"
}
}
},
{
"code": "DccHgwHEo_S",
"taken_at": 1787609886,
"caption": {
"text": "Next level DIY"
},
"user": {
"username": "platypus.4604274"
},
"text_post_app_info": {
"reply_to_author": {
"username": "nasa"
}
}
},
{
"code": "DcdLgJvAGr2",
"taken_at": 1787645533,
"caption": {
"text": "Talk about a high-stakes DIY project! 🛠️ clear view, zero gravity, and a 250-mile drop if you drop your wrench. Losing a screw behind the drywall is frustrating enough on Earth—I cant imagine managing tethered tools while working on the station's literal lifeline to Houston. Massive respect to Anil and Sophie! 🧑‍🚀💪"
},
"user": {
"username": "marzano.malone"
},
"text_post_app_info": {
"reply_to_author": {
"username": "nasa"
}
}
},
{
"code": "DccHHNXEnju",
"taken_at": 1787609677,
"caption": {
"text": "無事終わるのを祈ります💫"
},
"user": {
"username": "hagane_soutarou"
},
"text_post_app_info": {
"reply_to_author": {
"username": "nasa"
}
}
},
{
"code": "DcdtFOGE1q4",
"taken_at": 1787663138,
"caption": {
"text": "🚀🚀🚀🧑‍🚀🇯🇵"
},
"user": {
"username": "daoxieyukiko4"
},
"text_post_app_info": {
"reply_to_author": {
"username": "nasa"
}
}
},
{
"code": "DcdtpBbk604",
"taken_at": 1787663431,
"caption": {
"text": "🇯🇵🚀🧑‍🚀🧑‍🚀🧑‍🚀🚀🇯🇵"
},
"user": {
"username": "daoxieyukiko4"
},
"text_post_app_info": {
"reply_to_author": {
"username": "nasa"
}
}
},
{
"code": "DccR7SVjA3v",
"taken_at": 1787615346,
"caption": {
"text": "Good luck astronauts!!"
},
"user": {
"username": "kxtiebarber"
},
"text_post_app_info": {
"reply_to_author": {
"username": "nasa"
}
}
},
{
"code": "DccHe5OmEd3",
"taken_at": 1787609871,
"caption": {
"text": "🥴🥴🥴😒"
},
"user": {
"username": "pc19cp"
},
"text_post_app_info": {
"reply_to_author": {
"username": "nasa"
}
}
},
{
"code": "DcclsueCOvv",
"taken_at": 1787625713,
"caption": {
"text": "."
},
"user": {
"username": "terrence_eleven"
},
"text_post_app_info": {
"reply_to_author": {
"username": "nasa"
}
}
},
{
"code": "DccKJp0k-K-",
"taken_at": 1787611270,
"caption": {
"text": "🌕✨🌏🌟💯"
},
"user": {
"username": "sagarks007"
},
"text_post_app_info": {
"reply_to_author": {
"username": "nasa"
}
}
}
]
+14 -8
View File
@@ -4,7 +4,7 @@ import { escapeHtml, html, raw } from '../src/render/html.ts';
import { linkify } from '../src/render/text.ts'; import { linkify } from '../src/render/text.ts';
import { renderPost } from '../src/render/post.ts'; import { renderPost } from '../src/render/post.ts';
import { renderError } from '../src/render/error.ts'; import { renderError } from '../src/render/error.ts';
import type { Post } from '../src/types.ts'; import { oneSegment, type Media, type Post } from '../src/types.ts';
test('interpolations are escaped, Raw values are not', () => { test('interpolations are escaped, Raw values are not', () => {
assert.equal(html`<p>${'<script>alert(1)</script>'}</p>`.value, assert.equal(html`<p>${'<script>alert(1)</script>'}</p>`.value,
@@ -42,13 +42,19 @@ function post(overrides: Partial<Post> = {}): Post {
platformLabel: 'Instagram', platformLabel: 'Instagram',
originalUrl: 'https://www.instagram.com/p/ABC/', originalUrl: 'https://www.instagram.com/p/ABC/',
author: { handle: '@nasa' }, author: { handle: '@nasa' },
text: 'caption',
textPosition: 'below', textPosition: 'below',
media: [{ kind: 'image', url: 'https://cdn/1.jpg' }, { kind: 'image', url: 'https://cdn/2.jpg' }], segments: oneSegment({
text: 'caption',
media: [{ kind: 'image', url: 'https://cdn/1.jpg' }, { kind: 'image', url: 'https://cdn/2.jpg' }],
}),
...overrides, ...overrides,
}; };
} }
function withMedia(media: Media[], overrides: Partial<Post> = {}): Post {
return post({ segments: oneSegment({ text: 'caption', media }), ...overrides });
}
test('media is proxied, never linked straight at the CDN', () => { test('media is proxied, never linked straight at the CDN', () => {
const page = renderPost(post()); const page = renderPost(post());
assert.ok(!page.includes('https://cdn/1.jpg'), 'upstream URLs must not reach the page'); assert.ok(!page.includes('https://cdn/1.jpg'), 'upstream URLs must not reach the page');
@@ -56,9 +62,9 @@ test('media is proxied, never linked straight at the CDN', () => {
}); });
test('an HLS video is linked directly, because a proxy cannot rewrite a playlist', () => { test('an HLS video is linked directly, because a proxy cannot rewrite a playlist', () => {
const page = renderPost(post({ const page = renderPost(withMedia([
media: [{ kind: 'video', url: 'https://video.bsky.app/x/playlist.m3u8', hls: true, direct: true }], { kind: 'video', url: 'https://video.bsky.app/x/playlist.m3u8', hls: true, direct: true },
})); ]));
assert.ok(page.includes('https://video.bsky.app/x/playlist.m3u8')); assert.ok(page.includes('https://video.bsky.app/x/playlist.m3u8'));
}); });
@@ -72,12 +78,12 @@ test('text sits below the media for Instagram and above it for X', () => {
test('the layout toggle only appears when there is more than one item', () => { test('the layout toggle only appears when there is more than one item', () => {
assert.ok(renderPost(post()).includes('data-view="grid"')); assert.ok(renderPost(post()).includes('data-view="grid"'));
assert.ok(!renderPost(post({ media: [{ kind: 'image', url: 'https://cdn/1.jpg' }] })) assert.ok(!renderPost(withMedia([{ kind: 'image', url: 'https://cdn/1.jpg' }]))
.includes('data-view="grid"')); .includes('data-view="grid"'));
}); });
test('a video gets native controls and a source, not an iframe', () => { test('a video gets native controls and a source, not an iframe', () => {
const page = renderPost(post({ media: [{ kind: 'video', url: 'https://cdn/v.mp4' }] })); const page = renderPost(withMedia([{ kind: 'video', url: 'https://cdn/v.mp4' }]));
assert.ok(page.includes('<video')); assert.ok(page.includes('<video'));
assert.ok(page.includes('controls')); assert.ok(page.includes('controls'));
assert.ok(page.includes('playsinline')); assert.ok(page.includes('playsinline'));
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { selfThread } from '../src/platforms/threads.ts';
import { fixture } from './helpers.ts';
type Post = Parameters<typeof selfThread>[0][number];
// A real capture of a two-post NASA thread. The page ships eighteen posts:
// the two that belong together, other people's replies to them, and a pile of
// unrelated recommendations. Only the first two may survive.
const page = () => fixture<Post[]>('threads/thread-chain.json');
test('the author\'s own follow-up is kept, in order', () => {
const chain = selfThread(page(), 'DccGgKuEW-E');
assert.equal(chain.length, 2);
assert.deepEqual(chain.map((p) => p.code), ['DccGgKuEW-E', 'DccGhYSkXYY']);
assert.match(chain[0]?.caption?.text ?? '', /Home improvement/);
assert.match(chain[1]?.caption?.text ?? '', /where you can watch/);
});
test('every other author on the page is dropped', () => {
const chain = selfThread(page(), 'DccGgKuEW-E');
assert.ok(chain.every((p) => p.user?.username === 'nasa'),
'a post by someone else survived the filter');
assert.ok(page().length > chain.length + 10, 'the fixture should be mostly noise');
});
test('a reply from someone else to the same post is not a continuation', () => {
// This one is the trap: it carries reply_to_author = nasa, exactly like a
// real follow-up does. Only the author of the *post* separates them.
const esa = page().find((p) => p.user?.username === 'europeanspaceagency');
assert.ok(esa, 'fixture should contain a reply from another account');
assert.equal(esa?.text_post_app_info?.reply_to_author?.username, 'nasa');
assert.ok(!selfThread(page(), 'DccGgKuEW-E').includes(esa as Post));
});
test('linking the second post still returns the whole chain', () => {
const chain = selfThread(page(), 'DccGhYSkXYY');
assert.deepEqual(chain.map((p) => p.code), ['DccGgKuEW-E', 'DccGhYSkXYY']);
});
test('an unknown code yields nothing rather than guessing', () => {
assert.deepEqual(selfThread(page(), 'NoSuchCode'), []);
});
test('a lone post is a chain of one', () => {
const chain = selfThread(
[
{ code: 'A', taken_at: 1, user: { username: 'me' }, caption: { text: 'only' } },
{ code: 'B', taken_at: 2, user: { username: 'you' }, caption: { text: 'reply' },
text_post_app_info: { reply_to_author: { username: 'me' } } },
],
'A',
);
assert.deepEqual(chain.map((p) => p.code), ['A']);
});
test('the chain is ordered by when each was posted, not by payload order', () => {
const chain = selfThread(
[
{ code: 'C', taken_at: 30, user: { username: 'me' },
text_post_app_info: { reply_to_author: { username: 'me' } } },
{ code: 'A', taken_at: 10, user: { username: 'me' } },
{ code: 'B', taken_at: 20, user: { username: 'me' },
text_post_app_info: { reply_to_author: { username: 'me' } } },
],
'A',
);
assert.deepEqual(chain.map((p) => p.code), ['A', 'B', 'C']);
});
+8 -8
View File
@@ -9,16 +9,16 @@ test('a text-only post carries no media', () => {
const post = toPost(fixture('x/text-only.json'), URL_); const post = toPost(fixture('x/text-only.json'), URL_);
assert.equal(post.platform, 'x'); assert.equal(post.platform, 'x');
assert.equal(post.textPosition, 'above'); assert.equal(post.textPosition, 'above');
assert.deepEqual(post.media, []); assert.deepEqual(post.segments[0]?.media, []);
assert.equal(post.author.handle, '@jack'); assert.equal(post.author.handle, '@jack');
assert.ok(post.postedAt); assert.ok(post.segments[0]?.postedAt);
}); });
test('a photo post keeps every photo and asks for the original size', () => { test('a photo post keeps every photo and asks for the original size', () => {
const post = toPost(fixture('x/photo.json'), URL_); const media = toPost(fixture('x/photo.json'), URL_).segments[0]?.media ?? [];
assert.ok(post.media.length > 1, 'expected more than one photo'); assert.ok(media.length > 1, 'expected more than one photo');
assert.ok(post.media.every((m) => m.kind === 'image')); assert.ok(media.every((m) => m.kind === 'image'));
assert.ok(post.media.every((m) => m.url.endsWith('?name=orig'))); assert.ok(media.every((m) => m.url.endsWith('?name=orig')));
}); });
test('a video picks the highest-bitrate mp4 and ignores the streaming variants', () => { test('a video picks the highest-bitrate mp4 and ignores the streaming variants', () => {
@@ -64,6 +64,6 @@ test('a quote post shows the media of the post it quotes', () => {
}, },
URL_, URL_,
); );
assert.equal(post.media.length, 1); assert.equal(post.segments[0]?.media.length, 1);
assert.equal(post.media[0]?.url, 'https://pbs.twimg.com/q.jpg?name=orig'); assert.equal(post.segments[0]?.media[0]?.url, 'https://pbs.twimg.com/q.jpg?name=orig');
}); });