diff --git a/.prettierignore b/.prettierignore
index 843522f6..d165059c 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,5 +1,6 @@
pnpm-lock.yaml
.playwright-mcp/
+.scratch/
dist/
coverage/
apps/capacitor/android/
diff --git a/apps/extension/src/CaptureApp.test.tsx b/apps/extension/src/CaptureApp.test.tsx
index 20952ffb..1b325456 100644
--- a/apps/extension/src/CaptureApp.test.tsx
+++ b/apps/extension/src/CaptureApp.test.tsx
@@ -6,6 +6,44 @@ import { CaptureApp } from "./CaptureApp.js";
import type { ExtensionCaptureController } from "./capture-controller.js";
import type { PanelTab } from "./PanelTabs.js";
+it("shows a feed picker with the viewport post preselected, without offering to save a feed", () => {
+ const html = markup({
+ postPicker: {
+ open: true,
+ preselected: "second",
+ items: [
+ {
+ platform: "x",
+ id: "first",
+ url: "https://x.com/i/status/100",
+ author: "First author",
+ snippet: "First post",
+ top: 0,
+ bottom: 200,
+ },
+ {
+ platform: "x",
+ id: "second",
+ url: "https://x.com/i/status/101",
+ author: "Second author",
+ snippet: "Second post",
+ published: "2026-01-01T10:00:00Z",
+ top: 300,
+ bottom: 500,
+ },
+ ],
+ show: vi.fn(),
+ close: vi.fn(),
+ save: vi.fn(),
+ },
+ });
+ expect(html).toContain("Choose posts to save");
+ expect(html).toContain("Feeds are never saved");
+ expect(html).toContain("Second author");
+ expect(html).toContain("Save 1 selected");
+ expect(html.match(/checked=""/gu)).toHaveLength(1);
+});
+
function markup(changes: Partial = {}, tab?: PanelTab): string {
const controller = {
snapshot: { status: "unselected", connections: [] },
diff --git a/apps/extension/src/CaptureApp.tsx b/apps/extension/src/CaptureApp.tsx
index 36c2f155..d5214239 100644
--- a/apps/extension/src/CaptureApp.tsx
+++ b/apps/extension/src/CaptureApp.tsx
@@ -8,6 +8,7 @@ import { ExtensionHeader } from "./ExtensionHeader.js";
import { HighlightFields } from "./HighlightFields.js";
import { NotePanel } from "./NotePanel.js";
import { PanelTabContent, PanelTabs, usePanelTab, type PanelTab } from "./PanelTabs.js";
+import { PostPicker } from "./PostPicker.js";
import { SaveBar, saveReady } from "./SaveBar.js";
import { SavedHighlights } from "./SavedHighlights.js";
import { openSettings, useShortcuts } from "./shortcuts.js";
@@ -78,6 +79,7 @@ export function CaptureApp({
: "Waiting for the active tab"}
+
)}
diff --git a/apps/extension/src/PostPicker.test.tsx b/apps/extension/src/PostPicker.test.tsx
new file mode 100644
index 00000000..0ccb48c3
--- /dev/null
+++ b/apps/extension/src/PostPicker.test.tsx
@@ -0,0 +1,80 @@
+// @vitest-environment happy-dom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+
+import { PostPicker } from "./PostPicker.js";
+
+import type { ExtensionCaptureController } from "./capture-controller.js";
+import type { PostRef } from "@mdbase-reader/web-capture";
+
+let host: HTMLDivElement;
+let root: Root;
+const save = vi.fn(() => Promise.resolve());
+const cancel = vi.fn();
+const items: PostRef[] = ["100", "101", "102"].map((id) => ({
+ platform: "x",
+ id,
+ url: `https://x.com/i/status/${id}`,
+ author: "Fixture Author",
+ snippet: `Durable post ${id}`,
+ published: "2026-10-01T12:00:00Z",
+ top: 0,
+ bottom: 200,
+}));
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ save.mockClear();
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+});
+afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ vi.unstubAllGlobals();
+});
+
+function show(loaded = items, busy = false): void {
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+}
+
+it("preselects the viewport-centred post, shows author/snippet/time, and supports multi-select", () => {
+ show();
+ const inputs = host.querySelectorAll('input[type="checkbox"]');
+ expect([...inputs].map((input) => input.checked)).toEqual([false, true, false]);
+ expect(host.textContent).toContain("Fixture Author");
+ expect(host.textContent).toContain("Durable post 101");
+ expect(host.querySelector("time")?.getAttribute("datetime")).toBe(items[0]?.published);
+ act(() => inputs[0]?.click());
+ act(() => host.querySelector("button.primary")?.click());
+ expect(save).toHaveBeenCalledWith(["101", "100"]);
+});
+
+it("excludes a selected post that virtual scrolling has removed rather than saving a stale reference", () => {
+ show();
+ show([items[0]!]);
+ expect(host.querySelector("input")?.checked).toBe(false);
+ expect(host.querySelector("button.primary")?.disabled).toBe(true);
+ expect(save).not.toHaveBeenCalled();
+});
+
+it("locks selection and saving while a multi-post save is in progress", () => {
+ show(items, true);
+ expect(host.querySelector("fieldset")?.disabled).toBe(true);
+ expect(host.querySelector("button.primary")?.disabled).toBe(true);
+});
diff --git a/apps/extension/src/PostPicker.tsx b/apps/extension/src/PostPicker.tsx
new file mode 100644
index 00000000..16e13963
--- /dev/null
+++ b/apps/extension/src/PostPicker.tsx
@@ -0,0 +1,94 @@
+import { useState } from "react";
+
+import type { ExtensionCaptureController } from "./capture-controller.js";
+
+type Picker = NonNullable;
+
+export function PostPicker({
+ controller: c,
+}: {
+ readonly controller: ExtensionCaptureController;
+}): React.JSX.Element | null {
+ const picker = c.postPicker;
+ if (!picker) {
+ return null;
+ }
+ if (!picker.open) {
+ return (
+
+ );
+ }
+ return ;
+}
+
+function OpenPostPicker({
+ picker,
+ controller: c,
+}: {
+ readonly picker: Picker;
+ readonly controller: ExtensionCaptureController;
+}): React.JSX.Element {
+ // Mounted on opening only: scrolling must not discard a deliberate choice.
+ const [selected, setSelected] = useState(
+ () => new Set(picker.preselected ? [picker.preselected] : []),
+ );
+ const loadedSelected = [...selected].filter((id) => picker.items.some((item) => item.id === id));
+ return (
+
+ Choose posts to save
+
+ Only posts currently loaded on the page are listed. Feeds are never saved.
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/extension/src/SaveBar.tsx b/apps/extension/src/SaveBar.tsx
index 2c9fb159..0229c101 100644
--- a/apps/extension/src/SaveBar.tsx
+++ b/apps/extension/src/SaveBar.tsx
@@ -120,5 +120,11 @@ function submitLabel(c: ExtensionCaptureController): string {
if (c.busy) {
return c.status === "saving" || c.progress ? "Saving…" : "Please wait…";
}
- return c.capture?.kind === "pdf" ? "Save PDF" : "Save page";
+ return c.capture?.kind === "pdf"
+ ? "Save PDF"
+ : c.postPicker
+ ? "Choose posts to save"
+ : c.capture?.kind === "html" && c.capture.post
+ ? "Save post"
+ : "Save page";
}
diff --git a/apps/extension/src/background-page-status.ts b/apps/extension/src/background-page-status.ts
index 2e0e8ab5..f706d5cd 100644
--- a/apps/extension/src/background-page-status.ts
+++ b/apps/extension/src/background-page-status.ts
@@ -1,3 +1,4 @@
+import { updateSocialStatus } from "./background-social-status.js";
import { clearPageMark, markSavedPage } from "./page-badge.js";
import { pageStatusEnabledCached } from "./page-status.js";
import { panelShowsTab } from "./panel-presence.js";
@@ -73,6 +74,9 @@ export async function updatePageStatus(
if (!url?.startsWith("https://") || !(await pageStatusEnabledCached())) {
return;
}
+ if (await updateSocialStatus(tabId, url, (postUrl) => savedPage(postUrl, connect), current)) {
+ return;
+ }
const found = await savedPage(url, connect);
if (!found || !current()) {
return;
diff --git a/apps/extension/src/background-social-status.test.ts b/apps/extension/src/background-social-status.test.ts
new file mode 100644
index 00000000..9798e83f
--- /dev/null
+++ b/apps/extension/src/background-social-status.test.ts
@@ -0,0 +1,84 @@
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+
+import { updateSocialStatus } from "./background-social-status.js";
+import { resetPageMarkCache } from "./page-badge.js";
+import { fakeChrome } from "./testing/fake-chrome.js";
+
+import type { PageStatusConnect } from "./page-status-connect.js";
+import type { PostRef } from "@mdbase-reader/web-capture";
+
+beforeEach(() => resetPageMarkCache());
+afterEach(() => vi.unstubAllGlobals());
+
+function refs(): PostRef[] {
+ return ["100", "101"].map((id) => ({
+ platform: "x",
+ id,
+ url: `https://x.com/i/status/${id}`,
+ sourceUrl: "https://x.com/i/status/100",
+ author: "Fixture Author",
+ snippet: "Durable post",
+ top: 0,
+ bottom: 200,
+ }));
+}
+
+function setupPagePosts(posts: readonly PostRef[]): ReturnType {
+ const fake = fakeChrome();
+ vi.stubGlobal("chrome", {
+ ...fake.chrome,
+ scripting: { executeScript: vi.fn(() => Promise.resolve([{ result: posts }])) },
+ });
+ return fake;
+}
+
+it("matches each visible post's source identity and counts/draws only visible part annotations", async () => {
+ const fake = setupPagePosts(refs());
+ const draw = vi.fn(() => Promise.resolve());
+ const api = { drawPageQuotes: draw } as unknown as PageStatusConnect;
+ const find = vi.fn(() =>
+ Promise.resolve({
+ api,
+ quotes: [
+ { exact: "First", postUrl: "https://x.com/i/status/100" },
+ { exact: "Second", postUrl: "https://x.com/i/status/101" },
+ { exact: "Not visible", postUrl: "https://x.com/i/status/999" },
+ ],
+ }),
+ );
+ expect(await updateSocialStatus(1, "https://x.com/home", find, () => true)).toBe(true);
+ expect(find).toHaveBeenCalledOnce();
+ expect(find).toHaveBeenCalledWith("https://x.com/i/status/100");
+ expect(draw).toHaveBeenCalledWith(
+ 1,
+ [
+ expect.objectContaining({ postUrl: "https://x.com/i/status/100" }),
+ expect.objectContaining({ postUrl: "https://x.com/i/status/101" }),
+ ],
+ "https://x.com/home",
+ );
+ expect(fake.chrome.action.setBadgeText).toHaveBeenCalledWith({ tabId: 1, text: "2" });
+ expect(fake.chrome.action.setTitle).toHaveBeenLastCalledWith({
+ tabId: 1,
+ title: "2 saved posts in mdbase Reader · 2 highlights on visible posts",
+ });
+});
+
+it("does not look up a feed URL or invent a source when no posts are loaded", async () => {
+ const fake = setupPagePosts([]);
+ const find = vi.fn(() => Promise.resolve(null));
+ expect(await updateSocialStatus(1, "https://x.com/home", find, () => true)).toBe(true);
+ expect(find).not.toHaveBeenCalled();
+ expect(fake.chrome.action.setBadgeText).not.toHaveBeenCalled();
+});
+
+it("does not draw or badge stale results after navigation", async () => {
+ const fake = setupPagePosts(refs());
+ const draw = vi.fn();
+ const find = vi.fn(() =>
+ Promise.resolve({ api: { drawPageQuotes: draw } as unknown as PageStatusConnect, quotes: [] }),
+ );
+ await updateSocialStatus(1, "https://x.com/home", find, () => false);
+ expect(draw).not.toHaveBeenCalled();
+ expect(fake.chrome.action.setBadgeText).not.toHaveBeenCalled();
+});
diff --git a/apps/extension/src/background-social-status.ts b/apps/extension/src/background-social-status.ts
new file mode 100644
index 00000000..17da92e6
--- /dev/null
+++ b/apps/extension/src/background-social-status.ts
@@ -0,0 +1,75 @@
+import { normalizedSourceUrl } from "@mdbase-reader/core";
+
+import { markSavedPosts } from "./page-badge.js";
+
+import type { PageQuote } from "./page-annotations.js";
+import type { PageStatusConnect } from "./page-status-connect.js";
+import type { PostRef } from "@mdbase-reader/web-capture";
+
+async function visiblePostRefs(tabId: number): Promise {
+ try {
+ await chrome.scripting.executeScript({ target: { tabId }, files: ["social-page.js"] });
+ const [execution] = await chrome.scripting.executeScript({
+ target: { tabId },
+ func: async () => globalThis.mdbaseReaderSocialPage?.identities() ?? null,
+ });
+ return execution?.result ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/** Per-post lookup on continuously loading pages. The bridge owns the throttled observer. */
+export async function updateSocialStatus(
+ tabId: number,
+ url: string,
+ find: (url: string) => Promise<{ api: PageStatusConnect; quotes: readonly PageQuote[] } | null>,
+ current: () => boolean,
+): Promise {
+ const refs = await visiblePostRefs(tabId);
+ if (!refs) {
+ return false;
+ }
+ let api: PageStatusConnect | undefined;
+ let saved = 0;
+ const quotes = new Map();
+ const lookups = new Map>>();
+ const visible = new Set(refs.map((ref) => normalizedSourceUrl(ref.url)));
+ for (const ref of refs) {
+ const key = ref.sourceUrl ?? ref.url;
+ if (!lookups.has(key)) {
+ lookups.set(key, await find(key));
+ }
+ const found = lookups.get(key);
+ if (!current()) {
+ return true;
+ }
+ if (!found) {
+ continue;
+ }
+ api = found.api;
+ saved++;
+ for (const quote of found.quotes) {
+ const scoped = quote.postUrl ? quote : { ...quote, postUrl: ref.url };
+ if (scoped.postUrl && visible.has(normalizedSourceUrl(scoped.postUrl))) {
+ quotes.set(JSON.stringify(scoped), scoped);
+ }
+ }
+ }
+ if (current()) {
+ if (saved) {
+ await markSavedPosts(tabId, saved, quotes.size);
+ }
+ // Clear old highlights too, even when all the visible posts are unsaved.
+ if (api) {
+ await api.drawPageQuotes(tabId, [...quotes.values()], url);
+ } else {
+ await chrome.scripting.executeScript({
+ target: { tabId },
+ func: (expectedUrl: string) => globalThis.mdbaseReaderSocialPage?.draw([], expectedUrl),
+ args: [url],
+ });
+ }
+ }
+ return true;
+}
diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts
index 97881e8c..8d1d03d8 100644
--- a/apps/extension/src/background.ts
+++ b/apps/extension/src/background.ts
@@ -64,6 +64,43 @@ chrome.tabs.onUpdated.addListener((tabId, change, tab) => {
void updatePageStatus(tabId, tab.url, loadPageStatusConnect);
}
});
+const postUpdates = new Map();
+chrome.runtime.onMessage.addListener((message: unknown, sender) => {
+ if (!message || typeof message !== "object" || !("type" in message)) {
+ return;
+ }
+ if (
+ message.type !== "mdbase-reader/posts-changed" &&
+ message.type !== "mdbase-reader/posts-refresh"
+ ) {
+ return;
+ }
+ const tabId =
+ sender.tab?.id ??
+ ("tabId" in message && typeof message.tabId === "number" ? message.tabId : undefined);
+ if (tabId === undefined) {
+ return;
+ }
+ const pending = postUpdates.get(tabId);
+ if (pending) {
+ pending.dirty = true;
+ return;
+ }
+ const state = { dirty: true };
+ postUpdates.set(tabId, state);
+ void refreshPosts(tabId, state)
+ .catch(() => undefined)
+ .finally(() => postUpdates.delete(tabId));
+});
+
+async function refreshPosts(tabId: number, state: { dirty: boolean }): Promise {
+ do {
+ state.dirty = false;
+ const tab = await chrome.tabs.get(tabId);
+ await updatePageStatus(tabId, tab.url, loadPageStatusConnect);
+ } while (postUpdates.get(tabId)?.dirty);
+}
+
chrome.tabs.onRemoved.addListener((tabId) => {
// Drafts belong to a tab; free them when it closes.
void forgetTab(tabId).catch(() => undefined);
diff --git a/apps/extension/src/capture-citation.ts b/apps/extension/src/capture-citation.ts
index 1ea5dbb2..24787ea6 100644
--- a/apps/extension/src/capture-citation.ts
+++ b/apps/extension/src/capture-citation.ts
@@ -6,6 +6,7 @@ import {
} from "@mdbase-reader/core";
import {
citationForPage,
+ postCitation,
extractScholarlyMetadata,
type CitationDraft,
type CitationPreview,
@@ -21,6 +22,12 @@ export function prepareCitation(
capture: PageCapture,
signal?: AbortSignal,
): Promise {
+ if (capture.kind === "html" && capture.post) {
+ return Promise.resolve({ citation: postCitation(capture.post), origin: "page" });
+ }
+ if (capture.kind === "html" && capture.social && !capture.post) {
+ return Promise.resolve(null);
+ }
const embedded =
capture.kind === "html"
? extractScholarlyMetadata(parsedCaptureHtml(capture.html), capture.canonicalUrl)
diff --git a/apps/extension/src/capture-controller.ts b/apps/extension/src/capture-controller.ts
index 99ec2bcc..1848024c 100644
--- a/apps/extension/src/capture-controller.ts
+++ b/apps/extension/src/capture-controller.ts
@@ -13,6 +13,7 @@ import type {
SourceImportProgress,
SourceSummary,
} from "@mdbase-reader/core";
+import type { PostRef } from "@mdbase-reader/web-capture";
import type { Dispatch, SetStateAction } from "react";
export type CaptureStatus = "opening" | "ready" | "saving" | "saved" | "existing";
@@ -24,6 +25,14 @@ export interface PageHighlights {
export interface ExtensionCaptureController {
readonly snapshot: ReaderConnectSnapshot;
readonly capture: PageCapture | null;
+ readonly postPicker?: {
+ readonly open: boolean;
+ readonly items: readonly PostRef[];
+ readonly preselected: string | null;
+ readonly show: () => void;
+ readonly close: () => void;
+ readonly save: (ids: readonly string[]) => Promise;
+ };
readonly draft: CaptureDraft;
readonly setDraft: Dispatch>;
readonly draftRestored: boolean;
diff --git a/apps/extension/src/drafts.ts b/apps/extension/src/drafts.ts
index 7d54672a..6878a428 100644
--- a/apps/extension/src/drafts.ts
+++ b/apps/extension/src/drafts.ts
@@ -10,6 +10,7 @@ import { highlightColors, type CaptureDraft, type HighlightColor } from "./save-
export interface StoredDraft {
readonly draft: CaptureDraft;
readonly selection: QuoteSelector | null;
+ readonly selectionPostId?: string;
}
export const emptyDraft: CaptureDraft = {
diff --git a/apps/extension/src/extension.css b/apps/extension/src/extension.css
index 8e38368b..13dd8d0b 100644
--- a/apps/extension/src/extension.css
+++ b/apps/extension/src/extension.css
@@ -3,6 +3,36 @@
matches Reader's shell (11px meta, 12px controls, 13px body, 15px headings). Monospace is
reserved for keyboard shortcuts and codes. */
+.post-picker fieldset {
+ border: 0;
+ padding: 0;
+ max-height: 50vh;
+ overflow: auto;
+}
+.post-picker-item {
+ display: flex;
+ gap: 10px;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border);
+ cursor: pointer;
+}
+.post-picker-item > span {
+ min-width: 0;
+ display: grid;
+ gap: 4px;
+}
+.post-picker-item input {
+ flex: 0 0 auto;
+ align-self: start;
+}
+.post-picker-item time {
+ color: var(--muted);
+ font-size: 11px;
+}
+.post-picker .save-bar {
+ margin-top: 12px;
+}
+
:root {
/* Times is the last resort: Linux has Noto or DejaVu Serif, which sit better beside Atkinson. */
--serif:
diff --git a/apps/extension/src/messages.ts b/apps/extension/src/messages.ts
index b888075c..23dae6e0 100644
--- a/apps/extension/src/messages.ts
+++ b/apps/extension/src/messages.ts
@@ -10,7 +10,10 @@ export interface InvokeMessage {
readonly tabId: number;
readonly intent: CaptureIntent;
}
-export type ExtensionMessage = SelectionMessage | InvokeMessage;
+export interface PostsChangedMessage {
+ readonly type: "mdbase-reader/posts-changed";
+}
+export type ExtensionMessage = SelectionMessage | InvokeMessage | PostsChangedMessage;
/** The intent a newly opened panel should act on; consumed once. */
export function intentKey(tabId: number): string {
diff --git a/apps/extension/src/page-annotations.ts b/apps/extension/src/page-annotations.ts
index 98028e5c..ed141ec9 100644
--- a/apps/extension/src/page-annotations.ts
+++ b/apps/extension/src/page-annotations.ts
@@ -2,6 +2,9 @@ import type { Annotation, QuoteSelector } from "@mdbase-reader/core";
export interface PageQuote extends QuoteSelector {
readonly color?: string;
+ /** Canonical identity of the live post to search, never the surrounding feed. */
+ readonly postUrl?: string;
+ readonly postId?: string;
}
export interface ProjectionReport {
readonly total: number;
@@ -21,6 +24,7 @@ export type PageTextRequest =
| { readonly action: "selection" }
| {
readonly action: "draw";
+ readonly append?: boolean;
readonly quotes: readonly PageQuote[];
readonly expectedUrl?: string;
/** Scroll to the quote with this index and mark it briefly. */
@@ -48,11 +52,30 @@ const navigatedMessage =
export function annotationQuotes(annotations: readonly Annotation[]): readonly PageQuote[] {
return annotations.flatMap((annotation) =>
annotation.target?.quote
- ? [{ ...annotation.target.quote, ...(annotation.color ? { color: annotation.color } : {}) }]
+ ? [
+ {
+ ...annotation.target.quote,
+ ...quotePostId(annotation.target.html?.css),
+ ...(annotation.color ? { color: annotation.color } : {}),
+ },
+ ]
: [],
);
}
+function quotePostId(css?: string): { postId?: string } {
+ const value = /^\[data-post-id=("(?:[^"\\]|\\.)*")\]/u.exec(css ?? "")?.[1];
+ if (!value) {
+ return {};
+ }
+ try {
+ const id: unknown = JSON.parse(value);
+ return typeof id === "string" ? { postId: id } : {};
+ } catch {
+ return {};
+ }
+}
+
/**
* Draws the quotes on the tab, optionally scrolling to the one at `focus`. The page indexes
* its text, anchors the quotes and draws them in one injection, so nothing changes between.
@@ -101,6 +124,10 @@ export async function injectPageText(
): Promise {
let execution: chrome.scripting.InjectionResult | undefined;
try {
+ const social = await injectSocialQuotes(tabId, request);
+ if (social) {
+ return social;
+ }
[execution] = await chrome.scripting.executeScript({
target: { tabId },
func: pageText,
@@ -124,6 +151,29 @@ export async function injectPageText(
return execution.result;
}
+async function injectSocialQuotes(
+ tabId: number,
+ request: PageTextRequest,
+): Promise {
+ if (request.action !== "draw" && request.action !== "reveal") {
+ return null;
+ }
+ if (!request.quotes.some((quote) => quote.postUrl)) {
+ return null;
+ }
+ const [execution] = await chrome.scripting.executeScript({
+ target: { tabId },
+ func: (value: Extract) => {
+ const bridge = globalThis.mdbaseReaderSocialPage;
+ return bridge?.items()
+ ? { projection: bridge.draw(value.quotes, value.expectedUrl ?? location.href, value.focus) }
+ : null;
+ },
+ args: [request],
+ });
+ return execution?.result ?? null;
+}
+
/**
* Injected. Indexes the page's visible text, reads the selection, or anchors quotes in that
* text and draws them. Anchoring mirrors `textQuoteMatcher` in core, which Reader uses; it
@@ -131,7 +181,11 @@ export async function injectPageText(
* must be nested. page-annotations.test.ts checks that the two agree.
*/
// eslint-disable-next-line max-lines-per-function
-export function pageText(request: PageTextRequest, doc: Document = document): PageTextResult {
+export function pageText(
+ request: PageTextRequest,
+ doc: Document = document,
+ root: Element = doc.body,
+): PageTextResult {
if (
"expectedUrl" in request &&
request.expectedUrl &&
@@ -167,11 +221,11 @@ export function pageText(request: PageTextRequest, doc: Document = document): Pa
function indexDocument(): { text: string; nodes: Entry[] } {
const nodes: Entry[] = [];
let text = "";
- if (doc.body.closest(excluded)) {
+ if (root.closest(excluded)) {
return { text, nodes };
}
// Rejecting an excluded element skips its whole subtree, so no text node needs a lookup.
- const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
+ const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
return NodeFilter.FILTER_ACCEPT;
@@ -216,16 +270,24 @@ export function pageText(request: PageTextRequest, doc: Document = document): Pa
return range;
}
+ function selectedRange(): Range | null {
+ const selected = doc.defaultView?.getSelection();
+ if (!selected?.rangeCount || selected.isCollapsed) {
+ return null;
+ }
+ const range = selected.getRangeAt(0);
+ return root.contains(range.startContainer) && root.contains(range.endContainer) ? range : null;
+ }
+
/**
* Reads the selection from its own text and the context around it, without indexing the
* page. Text the index leaves out is left out here too.
*/
function selection(): QuoteSelector | null {
- const selected = doc.defaultView?.getSelection();
- if (!selected?.rangeCount || selected.isCollapsed) {
+ const range = selectedRange();
+ if (!range) {
return null;
}
- const range = selected.getRangeAt(0);
const { indexed, step } = textWalker();
const deepestLast = (node: Node): Node => (node.lastChild ? deepestLast(node.lastChild) : node);
/** The indexed text at a range boundary, moving inward when the boundary is between nodes. */
@@ -312,8 +374,8 @@ export function pageText(request: PageTextRequest, doc: Document = document): Pa
return verdict;
};
const indexed = (node: Node): node is Text =>
- node.nodeType === Node.TEXT_NODE && !isExcluded(node.parentElement);
- const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
+ node.nodeType === Node.TEXT_NODE && root.contains(node) && !isExcluded(node.parentElement);
+ const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
return indexed(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
@@ -349,7 +411,13 @@ export function pageText(request: PageTextRequest, doc: Document = document): Pa
}
/** Named after the quote itself, so a later reveal can find it without redrawing. */
function highlightName(quote: PageQuote): string {
- const key = [quote.color ?? "", quote.prefix ?? "", quote.exact, quote.suffix ?? ""].join("\0");
+ const key = [
+ quote.postUrl ?? "",
+ quote.color ?? "",
+ quote.prefix ?? "",
+ quote.exact,
+ quote.suffix ?? "",
+ ].join("\0");
let hash = 0x811c9dc5;
for (let i = 0; i < key.length; i++) {
hash = Math.imul(hash ^ key.charCodeAt(i), 0x01000193);
@@ -408,12 +476,17 @@ export function pageText(request: PageTextRequest, doc: Document = document): Pa
focused = index === focus ? { range, quote } : focused;
}
}
- for (const name of registry.keys()) {
- if (name.startsWith("mdbase-reader-")) {
- registry.delete(name);
+ const append = request.action === "draw" && request.append;
+ if (!append) {
+ for (const name of registry.keys()) {
+ if (name.startsWith("mdbase-reader-")) {
+ registry.delete(name);
+ }
}
+ doc
+ .querySelectorAll("style[data-mdbase-reader-highlights]")
+ .forEach((style) => style.remove());
}
- doc.querySelector("style[data-mdbase-reader-highlights]")?.remove();
const style = doc.createElement("style");
style.dataset["mdbaseReaderHighlights"] = "true";
style.textContent = [...decorations]
diff --git a/apps/extension/src/page-badge.ts b/apps/extension/src/page-badge.ts
index 3f410d15..e080be44 100644
--- a/apps/extension/src/page-badge.ts
+++ b/apps/extension/src/page-badge.ts
@@ -43,6 +43,18 @@ export async function markSavedPage(tabId: number, highlights: number): Promise<
});
}
+export async function markSavedPosts(
+ tabId: number,
+ posts: number,
+ highlights: number,
+): Promise {
+ await markSavedPage(tabId, posts);
+ await chrome.action.setTitle({
+ tabId,
+ title: `${String(posts)} saved post${posts === 1 ? "" : "s"} in mdbase Reader · ${String(highlights)} highlights on visible posts`,
+ });
+}
+
async function resetBadge(tabId: number): Promise {
await chrome.action.setBadgeText({ tabId, text: "" });
const { action } = chrome.runtime.getManifest() as {
diff --git a/apps/extension/src/page-capture.ts b/apps/extension/src/page-capture.ts
index b778d754..d0554629 100644
--- a/apps/extension/src/page-capture.ts
+++ b/apps/extension/src/page-capture.ts
@@ -6,12 +6,17 @@ import {
type PageQuote,
} from "./page-annotations.js";
+import type { SocialPageItems } from "./social-page.js";
import type { QuoteSelector } from "@mdbase-reader/core";
import type { LiveWebCapture } from "@mdbase-reader/web-capture";
export interface SelectedWebCapture extends LiveWebCapture {
readonly kind: "html";
readonly selection: QuoteSelector | null;
+ readonly selectionPostId?: string;
+ /** The browsing page may be a feed; provenance always belongs to the post. */
+ readonly pageUrl?: string;
+ readonly social?: SocialPageItems;
}
/** A PDF open in Chrome's viewer. Its bytes are fetched only when saving. */
@@ -31,7 +36,28 @@ type PageSnapshot =
const maximumPdfBytes = 40 * 1024 * 1024;
+export async function captureSocialTab(
+ tabId: number,
+ id?: string,
+): Promise {
+ try {
+ await chrome.scripting.executeScript({ target: { tabId }, files: ["social-page.js"] });
+ } catch {
+ return null;
+ }
+ const [execution] = await chrome.scripting.executeScript({
+ target: { tabId },
+ func: async (postId?: string) => globalThis.mdbaseReaderSocialPage?.capture(postId) ?? null,
+ args: [id],
+ });
+ return execution?.result ?? null;
+}
+
export async function captureTab(tabId: number): Promise {
+ const social = await captureSocialTab(tabId);
+ if (social) {
+ return social;
+ }
let snapshot: PageSnapshot | undefined;
try {
[{ result: snapshot } = { result: undefined }] = await chrome.scripting.executeScript({
diff --git a/apps/extension/src/page-status-connect.ts b/apps/extension/src/page-status-connect.ts
index e411099c..0f10777b 100644
--- a/apps/extension/src/page-status-connect.ts
+++ b/apps/extension/src/page-status-connect.ts
@@ -5,6 +5,7 @@ import {
restoreCollection,
} from "./connect-session.js";
import { annotationQuotes, drawPageQuotes, type PageQuote } from "./page-annotations.js";
+import { scopePostQuotes, storedSocialPost } from "./social-quotes.js";
import type {
ReaderConnectedCollection,
@@ -97,7 +98,7 @@ async function savedQuotes(
collection.collectionId,
source.id,
);
- return annotationQuotes(annotations);
+ return scopePostQuotes(annotationQuotes(annotations), storedSocialPost(source.properties));
}
export const pageStatusConnect: PageStatusConnect = {
diff --git a/apps/extension/src/save-capture.ts b/apps/extension/src/save-capture.ts
index 2b69ff4c..c00ecd67 100644
--- a/apps/extension/src/save-capture.ts
+++ b/apps/extension/src/save-capture.ts
@@ -15,6 +15,7 @@ import {
type DocumentDescriptor,
type MutationId,
type QuoteSelector,
+ type HtmlSelector,
type Source,
type SourceFileImportRequest,
type SourceImportMetadata,
@@ -22,7 +23,12 @@ import {
type SourceSummary,
} from "@mdbase-reader/core";
import { createReaderRuntimeServices, type KeyValueStorage } from "@mdbase-reader/platform";
-import { citationAuthors, citationYear, webCaptureImport } from "@mdbase-reader/web-capture";
+import {
+ citationAuthors,
+ citationYear,
+ postCitation,
+ webCaptureImport,
+} from "@mdbase-reader/web-capture";
import {
citekeyNeighbours,
@@ -83,6 +89,11 @@ export interface SaveCaptureInput {
const savedTextLimit = 4;
/** A saved source with the reading copy this save just uploaded, when it created the source. */
+interface SavedText {
+ readonly text: string;
+ readonly parts: ReadonlyMap;
+}
+
interface CreatedSource {
readonly source: Source;
readonly readable: Uint8Array | null;
@@ -93,7 +104,7 @@ export class CaptureWriter {
readonly #runtime;
readonly #annotations = new Map();
/** Keyed by the exact stored revision, so a changed copy is never matched from here. */
- readonly #savedTexts = new Map();
+ readonly #savedTexts = new Map();
constructor(private readonly journalStorage: KeyValueStorage) {
this.#runtime = createReaderRuntimeServices(journalStorage);
@@ -101,6 +112,7 @@ export class CaptureWriter {
async save(input: SaveCaptureInput): Promise {
const { session, collection, capture, draft, onSource } = input;
+ assertPostCapture(capture);
const outcomes = await session.recoverPendingMutations();
const failure = outcomes.find((outcome) => !outcome.ok);
if (failure) {
@@ -134,7 +146,13 @@ export class CaptureWriter {
void rememberSavedUrls(collection.collectionId, [capture.canonicalUrl, capture.submittedUrl]);
const annotation =
draft.highlight && capture.kind === "html" && capture.selection
- ? await this.#highlight(collection, created ?? source, capture.selection, draft)
+ ? await this.#highlight(
+ collection,
+ created ?? source,
+ capture.selection,
+ draft,
+ capture.selectionPostId,
+ )
: null;
return { source, existing, annotation, notices };
}
@@ -144,7 +162,8 @@ export class CaptureWriter {
notices: string[],
recoverExistingFiles: boolean,
): Promise {
- const { collection, capture, draft, citation } = input;
+ const { collection, capture, draft } = input;
+ const citation = captureCitation(input);
// The citekey lookup does not depend on the new source; fetch it while the page imports.
const neighbours = citation ? citekeyNeighbours(collection, citation.citation) : null;
neighbours?.catch(() => undefined);
@@ -189,6 +208,7 @@ export class CaptureWriter {
saved: SourceSummary | CreatedSource,
selection: QuoteSelector,
draft: CaptureDraft,
+ postId?: string,
): Promise {
// A source this save created is current as returned; others are read for their revision.
const created = "readable" in saved ? saved : null;
@@ -209,6 +229,7 @@ export class CaptureWriter {
comment,
draft.color,
draft.highlightTags,
+ ...(postId ? [postId] : []),
]);
const recoveryKey = `capture-annotation:${await this.#runtime.hasher.sha256(new TextEncoder().encode(key))}`;
const previous =
@@ -233,11 +254,12 @@ export class CaptureWriter {
}
}
this.#annotations.set(key, identity);
- const { document, quote } = await this.#savedTarget(
+ const { document, quote, html } = await this.#savedTarget(
collection,
source,
selection,
created?.readable ?? null,
+ postId,
);
await this.journalStorage.set(recoveryKey, JSON.stringify(identity));
const result = await createAnnotation(
@@ -268,7 +290,7 @@ export class CaptureWriter {
.filter(Boolean),
),
],
- target: { quote },
+ target: { quote, ...(html ? { html } : {}) },
body: highlightBody(quote.exact, comment),
},
);
@@ -311,14 +333,21 @@ export class CaptureWriter {
source: Source,
selection: QuoteSelector,
uploaded: Uint8Array | null,
- ): Promise<{ document: DocumentDescriptor; quote: QuoteSelector }> {
+ postId?: string,
+ ): Promise<{ document: DocumentDescriptor; quote: QuoteSelector; html?: HtmlSelector }> {
const document = source.documents.find(
(item) => ["primary", "readable"].includes(item.role) && item.mediaType === "text/html",
);
if (!document) {
throw new Error("This source has no saved HTML reading copy. Open it in Reader to annotate.");
}
- const text = await this.#savedText(collection, source, document, uploaded);
+ const savedText = await this.#savedText(collection, source, document, uploaded);
+ const text = postId ? savedText.parts.get(postId) : savedText.text;
+ if (text === undefined) {
+ throw new Error(
+ "This thread part is absent from the saved copy. Open that copy in Reader; no highlight was created.",
+ );
+ }
const match = matchTextQuote(text, selection);
if (!match || match.ambiguous) {
throw new Error(
@@ -326,7 +355,11 @@ export class CaptureWriter {
);
}
// Stored as the saved copy reads, which is where Reader anchors it.
- return { document, quote: textQuoteAt(text, match.start, match.end) };
+ return {
+ document,
+ quote: textQuoteAt(text, match.start, match.end),
+ ...(postId ? { html: { css: `[data-post-id=${JSON.stringify(postId)}]` } } : {}),
+ };
}
/** The reading copy's text at exactly `document.revision`, verified against its digest. */
@@ -335,7 +368,7 @@ export class CaptureWriter {
source: Source,
document: DocumentDescriptor,
uploaded: Uint8Array | null,
- ): Promise {
+ ): Promise {
const key = JSON.stringify([
collection.collectionId,
source.id,
@@ -354,7 +387,15 @@ export class CaptureWriter {
? uploaded
: await this.#downloadVerified(collection, document);
const parsed = new DOMParser().parseFromString(new TextDecoder().decode(bytes), "text/html");
- const text = pageText({ action: "text" }, parsed).text ?? "";
+ const text: SavedText = {
+ text: pageText({ action: "text" }, parsed).text ?? "",
+ parts: new Map(
+ [...parsed.querySelectorAll("[data-post-id]")].map((part) => [
+ part.getAttribute("data-post-id") ?? "",
+ pageText({ action: "text" }, parsed, part).text ?? "",
+ ]),
+ ),
+ };
this.#savedTexts.set(key, text);
for (const oldest of this.#savedTexts.keys()) {
if (this.#savedTexts.size <= savedTextLimit) {
@@ -383,6 +424,22 @@ export class CaptureWriter {
}
}
+function captureCitation(input: SaveCaptureInput): CitationPreview | null {
+ if (input.citation) {
+ return input.citation;
+ }
+ const capture = input.capture;
+ return capture.kind === "html" && capture.post
+ ? { citation: postCitation(capture.post), origin: "page" }
+ : null;
+}
+
+function assertPostCapture(capture: PageCapture): void {
+ if (capture.kind === "html" && capture.social && !capture.post) {
+ throw new Error("Choose individual posts; feeds cannot be saved.");
+ }
+}
+
function importRecoveryKey({ collection, capture }: SaveCaptureInput): string {
return `capture-import:${collection.collectionId}:${capture.canonicalUrl}`;
}
@@ -426,6 +483,7 @@ async function htmlRequest(capture: SelectedWebCapture): Promise {
archive: prepared.archive,
capture: prepared.capture,
metadata: prepared.metadata,
+ ...(capture.post ? { kind: "post" } : {}),
};
}
diff --git a/apps/extension/src/save-social-post.test.ts b/apps/extension/src/save-social-post.test.ts
new file mode 100644
index 00000000..0bc4e30a
--- /dev/null
+++ b/apps/extension/src/save-social-post.test.ts
@@ -0,0 +1,86 @@
+// @vitest-environment happy-dom
+import { describe, expect, it } from "vitest";
+
+import { annotationQuotes } from "./page-annotations.js";
+import { scopePostQuotes } from "./social-quotes.js";
+import { capture, fixture } from "./testing/save-capture-fixture.js";
+
+import type { SelectedWebCapture } from "./page-capture.js";
+
+function postCapture(): SelectedWebCapture {
+ const first = {
+ platform: "x" as const,
+ id: "100",
+ url: "https://x.com/i/status/100",
+ author: { id: "fixture", handle: "fixture", name: "Fixture Author" },
+ text: "Repeated passage",
+ media: [],
+ };
+ const second = { ...first, id: "101", url: "https://x.com/i/status/101", inReplyTo: "100" };
+ return {
+ ...capture,
+ html: "",
+ canonicalUrl: first.url,
+ submittedUrl: first.url,
+ selection: { exact: "Repeated passage" },
+ selectionPostId: second.id,
+ post: { ...first, threadRoot: first.id, threadPartial: true, parts: [first, second] },
+ };
+}
+
+describe("social source/highlight persistence", () => {
+ it("creates a post source and post CSL citation, with a part-specific HTML selector", async () => {
+ const f = fixture();
+ const page = postCapture();
+ const saved = await f.save({}, page);
+ expect(f.commit.mock.calls[0]?.[0]).toMatchObject({
+ kind: "post",
+ metadata: { socialPost: page.post },
+ });
+ expect(saved.source.citation?.type).toBe("post");
+ expect(saved.annotation?.target).toMatchObject({
+ quote: { exact: "Repeated passage" },
+ html: { css: '[data-post-id="101"]' },
+ });
+ const quotes = annotationQuotes([saved.annotation!]);
+ expect(scopePostQuotes(quotes, page.post)[0]?.postUrl).toBe("https://x.com/i/status/101");
+ });
+ it("does not mistake identical selections on two thread parts for a highlight retry", async () => {
+ const f = fixture();
+ const page = postCapture();
+ const first = await f.save({}, page);
+ const second = await f.save(
+ {},
+ { ...page, selectionPostId: "100" },
+ { known: first.source, lookedUp: true },
+ );
+ expect(second.source.id).toBe(first.source.id);
+ expect(second.annotation?.id).not.toBe(first.annotation?.id);
+ expect(second.annotation?.target?.html?.css).toBe('[data-post-id="100"]');
+ expect(f.commit).toHaveBeenCalledOnce();
+ expect(f.create).toHaveBeenCalledTimes(2);
+ });
+ it("refuses to anchor a highlight to a part absent from the saved revision", async () => {
+ const f = fixture();
+ await expect(f.save({}, { ...postCapture(), selectionPostId: "999" })).rejects.toThrow(
+ "part is absent",
+ );
+ expect(f.onSource).toHaveBeenCalledOnce();
+ expect(f.create).not.toHaveBeenCalled();
+ });
+ it("never sends an unselected feed to the source importer", async () => {
+ const f = fixture();
+ await expect(
+ f.save(
+ {},
+ {
+ ...capture,
+ html: "",
+ selection: null,
+ social: { mode: "multiple", items: [], nearest: null },
+ },
+ ),
+ ).rejects.toThrow("feeds cannot be saved");
+ expect(f.commit).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/extension/src/social-page.test.ts b/apps/extension/src/social-page.test.ts
new file mode 100644
index 00000000..5f805211
--- /dev/null
+++ b/apps/extension/src/social-page.test.ts
@@ -0,0 +1,149 @@
+// @vitest-environment happy-dom
+import { readFileSync } from "node:fs";
+
+import { matchTextQuote } from "@mdbase-reader/core";
+import { siteAdapters } from "@mdbase-reader/web-capture";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { pageText } from "./page-annotations.js";
+import { createSocialPageBridge, selectedPost } from "./social-page.js";
+import { scopePostQuotes } from "./social-quotes.js";
+
+const registry = new Map>();
+
+beforeEach(() => {
+ window.location.href = "https://x.com/home";
+ document.documentElement.innerHTML = new DOMParser().parseFromString(
+ readFileSync("../../packages/web-capture/src/social/fixtures/x.html", "utf8"),
+ "text/html",
+ ).documentElement.innerHTML;
+ window.getSelection()?.removeAllRanges();
+ registry.clear();
+ vi.stubGlobal(
+ "Highlight",
+ class extends Set {
+ constructor(...ranges: Range[]) {
+ super(ranges);
+ }
+ },
+ );
+ Object.defineProperty(window, "CSS", { value: { highlights: registry }, configurable: true });
+});
+afterEach(() => vi.unstubAllGlobals());
+
+function select(start: Node, end: Node = start): void {
+ const range = document.createRange();
+ range.setStart(start, 0);
+ range.setEnd(end, end.textContent?.length ?? 0);
+ window.getSelection()!.removeAllRanges();
+ window.getSelection()!.addRange(range);
+}
+
+describe("post-by-post live capture and annotation projection", () => {
+ it("does not snapshot or identify a feed as a source", async () => {
+ const capture = await createSocialPageBridge(document).capture();
+ expect(capture).toMatchObject({ social: { mode: "multiple" }, html: "", selection: null });
+ expect(capture?.post).toBeUndefined();
+ });
+ it("resolves a feed selection to its post and anchors it to the unrolled readable text", async () => {
+ const texts = document.querySelectorAll('[data-testid="tweetText"]');
+ const second = texts[2]!.firstChild!;
+ select(second);
+ const bridge = createSocialPageBridge(document);
+ expect(selectedPost(document, siteAdapters[0])?.id).toBe("101");
+ const capture = await bridge.capture();
+ expect(capture?.canonicalUrl).toBe("https://x.com/i/status/100");
+ expect(capture?.selection?.exact).toBe("The second durable post.");
+ const parsed = new DOMParser().parseFromString(capture!.html, "text/html");
+ expect(
+ matchTextQuote(pageText({ action: "text" }, parsed).text ?? "", capture!.selection!),
+ ).toMatchObject({ ambiguous: false });
+ expect(capture?.html).not.toContain("Someone else's reply");
+ expect(capture?.html).not.toContain("Not a source");
+ expect(capture?.pageUrl).toBe("https://x.com/home");
+ });
+ it("does not resolve selections that cross two posts", () => {
+ const texts = document.querySelectorAll('[data-testid="tweetText"]');
+ select(texts[0]!.firstChild!, texts[2]!.firstChild!);
+ expect(selectedPost(document, siteAdapters[0])).toBeNull();
+ });
+ it("saves only the chosen loaded post, with no network call for X", async () => {
+ const fetcher = vi.fn(() => Promise.reject(new Error("No X network API allowed")));
+ vi.stubGlobal("fetch", fetcher);
+ const capture = await createSocialPageBridge(document).capture("103");
+ expect(capture?.post?.parts).toHaveLength(1);
+ expect(capture?.post?.id).toBe("103");
+ expect(fetcher).not.toHaveBeenCalled();
+ });
+ it("never redraws a saved quote in a neighbouring post with identical text", () => {
+ const texts = document.querySelectorAll('[data-testid="tweetText"]');
+ texts[0]!.textContent = "Same durable quotation.";
+ texts[2]!.textContent = "Same durable quotation.";
+ const bridge = createSocialPageBridge(document);
+ const projection = bridge.draw(
+ [{ exact: "Same durable quotation.", postUrl: "https://x.com/i/status/101" }],
+ window.location.href,
+ );
+ expect(projection.report).toMatchObject({ shown: 1, ambiguous: 0 });
+ const range = [...registry.values()][0]?.values().next().value;
+ expect(range?.startContainer.parentElement).toBe(texts[2]);
+ });
+ it("scopes annotations on thread parts and redraws after DOM replacement", async () => {
+ const bridge = createSocialPageBridge(document);
+ const capture = await bridge.capture("100");
+ const quotes = scopePostQuotes(
+ [{ exact: "The second durable post.", postId: "101" }],
+ capture?.post,
+ );
+ expect(quotes[0]?.postUrl).toBe("https://x.com/i/status/101");
+ expect(bridge.draw(quotes, window.location.href).report.shown).toBe(1);
+ const root = document.querySelectorAll('article[data-testid="tweet"]')[1]!;
+ root.replaceWith(root.cloneNode(true));
+ expect(bridge.draw(quotes, window.location.href).report.shown).toBe(1);
+ expect([...registry.values()][0]?.values().next().value?.startContainer.isConnected).toBe(true);
+ });
+ it("does not guess which thread part owns an identical quote without explicit part evidence", async () => {
+ const bridge = createSocialPageBridge(document);
+ const capture = await bridge.capture("100");
+ const post = {
+ ...capture!.post!,
+ parts: capture!.post!.parts.map((part) => ({ ...part, text: "Identical passage" })),
+ };
+ expect(scopePostQuotes([{ exact: "Identical passage" }], post)[0]?.postUrl).toContain(
+ "unresolved-post",
+ );
+ expect(scopePostQuotes([{ exact: "Identical passage", postId: "101" }], post)[0]?.postUrl).toBe(
+ "https://x.com/i/status/101",
+ );
+ });
+
+ it("resolves an unmarked quote only when the source is a single post", () => {
+ const part = { id: "100", url: "https://x.com/i/status/100" };
+ const post = {
+ platform: "x" as const,
+ ...part,
+ author: { id: "fixture", handle: "fixture", name: "Fixture Author" },
+ threadRoot: "100",
+ threadPartial: true,
+ parts: [{ ...part, quoted: { id: "900", url: "https://x.com/i/status/900" } }],
+ };
+ expect(scopePostQuotes([{ exact: "Anything" }], post)[0]?.postUrl).toBe(part.url);
+ // A quoted post is drawn inside the post quoting it.
+ expect(scopePostQuotes([{ exact: "Quoted", postId: "900" }], post)[0]?.postUrl).toBe(part.url);
+ const thread = { ...post, parts: [part, { id: "101", url: "https://x.com/i/status/101" }] };
+ expect(scopePostQuotes([{ exact: "Anything" }], thread)[0]?.postUrl).toContain(
+ "unresolved-post",
+ );
+ });
+
+ it("preselects the middle post, not the first loaded post", () => {
+ [...document.querySelectorAll('article[data-testid="tweet"]')].forEach((element, index) => {
+ vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
+ top: index * 200,
+ bottom: index * 200 + 150,
+ } as DOMRect);
+ });
+ Object.defineProperty(window, "innerHeight", { value: 800, configurable: true });
+ expect(createSocialPageBridge(document).items()?.nearest).toBe("102");
+ });
+});
diff --git a/apps/extension/src/social-page.ts b/apps/extension/src/social-page.ts
new file mode 100644
index 00000000..576d00f5
--- /dev/null
+++ b/apps/extension/src/social-page.ts
@@ -0,0 +1,327 @@
+import { matchTextQuote, normalizedSourceUrl, textQuoteAt } from "@mdbase-reader/core";
+import {
+ adapterFor,
+ nearestPost,
+ postAddress,
+ postHtml,
+ postTitle,
+} from "@mdbase-reader/web-capture";
+
+import { pageText } from "./page-annotations.js";
+
+import type { PageProjection, PageQuote } from "./page-annotations.js";
+import type { SelectedWebCapture } from "./page-capture.js";
+import type { QuoteSelector, SocialPost } from "@mdbase-reader/core";
+import type { PostRef, SiteAdapter } from "@mdbase-reader/web-capture";
+
+export interface SocialPageItems {
+ readonly mode: "single" | "multiple";
+ readonly items: readonly PostRef[];
+ readonly nearest: string | null;
+}
+export interface SocialPageBridge {
+ items(): SocialPageItems | null;
+ refreshItems(): Promise;
+ capture(id?: string): Promise;
+ identities(): Promise;
+ draw(quotes: readonly PageQuote[], expectedUrl: string, focus?: number): PageProjection;
+}
+
+declare global {
+ var mdbaseReaderSocialPage: SocialPageBridge | undefined;
+}
+
+function socialItems(doc: Document): SocialPageItems | null {
+ const adapter = adapterFor(doc);
+ const mode = adapter?.detect(doc);
+ if (!adapter || !mode) {
+ return null;
+ }
+ const items = adapter.items(doc);
+ return { mode, items, nearest: nearestPost(items, doc.defaultView?.innerHeight ?? 800) };
+}
+
+function boundaryElement(node: Node): Element | null {
+ return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
+}
+
+/** Selections crossing post boundaries do not resolve to either post. */
+export function selectedPost(doc: Document, adapter: SiteAdapter): PostRef | null {
+ const selected = doc.getSelection();
+ if (!selected?.rangeCount || selected.isCollapsed) {
+ return null;
+ }
+ const range = selected.getRangeAt(0);
+ const start = boundaryElement(range.startContainer);
+ const end = boundaryElement(range.endContainer);
+ if (!start || !end) {
+ return null;
+ }
+ const ref = adapter.resolve(start);
+ return ref && adapter.resolve(end)?.id === ref.id ? ref : null;
+}
+
+function captureRef(
+ doc: Document,
+ adapter: SiteAdapter,
+ listing: SocialPageItems,
+ id?: string,
+): PostRef | null {
+ if (id) {
+ return listing.items.find((item) => item.id === id) ?? null;
+ }
+ const selection = selectedPost(doc, adapter);
+ if (selection) {
+ return selection;
+ }
+ const address = postAddress(doc.location.href);
+ if (listing.mode !== "single" || !address) {
+ return null;
+ }
+ // An explicit permalink can be fetched publicly; X always needs its matching live DOM.
+ return (
+ listing.items.find((item) => item.id === address.id) ??
+ (adapter.id === "x" ? null : { ...address, author: "", snippet: "", top: 0, bottom: 0 })
+ );
+}
+
+async function resolvePost(
+ adapter: SiteAdapter,
+ ref: PostRef,
+ doc: Document,
+): Promise<{ post: SocialPost; identity: { id: string; url: string } }> {
+ const post = await adapter.extract(ref, doc);
+ const identity = await adapter.identity(ref).catch((reason: unknown) => {
+ if (post.parts.length !== 1) {
+ throw reason;
+ }
+ return { id: post.id, url: post.url };
+ });
+ return { post, identity };
+}
+
+function readableSelection(
+ post: SocialPost,
+ id: string,
+ raw: QuoteSelector | null,
+): QuoteSelector | null {
+ if (!raw) {
+ return null;
+ }
+ const parsed = new DOMParser().parseFromString(postHtml(post), "text/html");
+ const part = [...parsed.querySelectorAll("[data-post-id]")].find(
+ (node) => node.getAttribute("data-post-id") === id,
+ );
+ if (!part) {
+ return null;
+ }
+ const partText = pageText({ action: "text" }, parsed, part).text ?? "";
+ const match = matchTextQuote(partText, raw);
+ if (!match || match.ambiguous) {
+ throw new Error("The selected passage is ambiguous inside this post. Select a longer passage.");
+ }
+ // Context is taken from the post's readable representation, not feed chrome or neighbours.
+ return textQuoteAt(partText, match.start, match.end);
+}
+
+async function captureSocialPage(
+ doc: Document,
+ canonical: Map,
+ id?: string,
+): Promise {
+ const adapter = adapterFor(doc);
+ await adapter?.prepare(doc);
+ const listing = socialItems(doc);
+ if (!adapter || !listing) {
+ return null;
+ }
+ const ref = captureRef(doc, adapter, listing, id);
+ const pageUrl = doc.location.href;
+ const retrievedAt = new Date().toISOString();
+ if (!ref) {
+ if (id || listing.mode === "single") {
+ throw new Error("The post is not loaded. Scroll it into view and try again.");
+ }
+ return {
+ kind: "html",
+ submittedUrl: pageUrl,
+ canonicalUrl: pageUrl,
+ retrievedAt,
+ pageTitle: "Choose posts to save",
+ html: "",
+ selection: null,
+ pageUrl,
+ social: listing,
+ };
+ }
+ const root = adapter.element(ref, doc);
+ const selected = selectedPost(doc, adapter);
+ const raw =
+ root && selected?.id === ref.id
+ ? (pageText({ action: "selection" }, doc, root).selection ?? null)
+ : null;
+ const { post, identity } = await resolvePost(adapter, ref, doc);
+ canonical.set(ref.url, identity.url);
+ return {
+ kind: "html",
+ submittedUrl: post.url,
+ canonicalUrl: post.url,
+ retrievedAt,
+ pageTitle: postTitle(post),
+ html: postHtml(post),
+ selection: readableSelection(post, identity.id, raw),
+ ...(raw ? { selectionPostId: identity.id } : {}),
+ post,
+ pageUrl,
+ social: listing,
+ };
+}
+
+function drawSocialQuotes(
+ doc: Document,
+ canonical: ReadonlyMap,
+ quotes: readonly PageQuote[],
+ expectedUrl: string,
+ focus?: number,
+): PageProjection {
+ if (doc.location.href.split("#")[0] !== expectedUrl.split("#")[0]) {
+ throw new Error("The tab navigated away.");
+ }
+ const adapter = adapterFor(doc);
+ const outcomes: PageProjection["outcomes"][number][] = quotes.map(() => "missing");
+ pageText({ action: "draw", quotes: [] }, doc);
+ // Runs on every feed mutation: one pass over the posts, and quotes grouped by post up front.
+ const byPost = new Map();
+ quotes.forEach((quote, index) => {
+ if (quote.postUrl) {
+ const url = normalizedSourceUrl(quote.postUrl);
+ byPost.set(url, [...(byPost.get(url) ?? []), { quote, index }]);
+ }
+ });
+ if (adapter?.detect(doc) && byPost.size) {
+ for (const { ref, element: root } of adapter.entries(doc)) {
+ const matching = byPost.get(normalizedSourceUrl(canonical.get(ref.url) ?? ref.url)) ?? [];
+ if (!matching.length) {
+ continue;
+ }
+ const localFocus = matching.findIndex(({ index }) => index === focus);
+ const projection = pageText(
+ {
+ action: "draw",
+ quotes: matching.map(({ quote }) => quote),
+ append: true,
+ ...(localFocus === -1 ? {} : { focus: localFocus }),
+ },
+ doc,
+ root,
+ ).projection;
+ matching.forEach(({ index }, position) => {
+ outcomes[index] = projection?.outcomes[position] ?? "missing";
+ });
+ }
+ }
+ return {
+ outcomes,
+ report: {
+ total: quotes.length,
+ shown: outcomes.filter((value) => value === "shown").length,
+ missing: outcomes.filter((value) => value === "missing").length,
+ ambiguous: outcomes.filter((value) => value === "ambiguous").length,
+ },
+ };
+}
+
+export function createSocialPageBridge(doc: Document): SocialPageBridge {
+ const identities = new Map>();
+ const canonical = new Map();
+ return {
+ items: () => socialItems(doc),
+ refreshItems: async () => {
+ await adapterFor(doc)?.prepare(doc);
+ return socialItems(doc);
+ },
+ capture: (id) => captureSocialPage(doc, canonical, id),
+ identities: async () => {
+ const adapter = adapterFor(doc);
+ await adapter?.prepare(doc).catch(() => undefined);
+ const listing = socialItems(doc);
+ if (!adapter || !listing) {
+ return null;
+ }
+ const height = doc.defaultView?.innerHeight ?? 800;
+ const visible = listing.items.filter((ref) => ref.bottom > 0 && ref.top < height);
+ return Promise.all(
+ visible.map((ref) => {
+ let pending = identities.get(ref.id);
+ if (!pending) {
+ pending = resolvePost(adapter, ref, doc).then(({ post, identity }) => {
+ canonical.set(ref.url, identity.url);
+ return { ...ref, id: identity.id, url: identity.url, sourceUrl: post.url };
+ });
+ if (identities.size >= 500) {
+ const oldest = identities.keys().next().value;
+ if (oldest) {
+ identities.delete(oldest);
+ }
+ }
+ identities.set(ref.id, pending);
+ void pending.catch(() => identities.delete(ref.id));
+ }
+ return pending.catch(() => ref);
+ }),
+ );
+ },
+ draw: (quotes, url, focus) => drawSocialQuotes(doc, canonical, quotes, url, focus),
+ };
+}
+
+function installSocialPage(): void {
+ globalThis.mdbaseReaderSocialPage = createSocialPageBridge(document);
+ let timer: ReturnType | undefined;
+ const changed = (): void => {
+ if (timer) {
+ return;
+ }
+ timer = setTimeout(() => {
+ timer = undefined;
+ if (globalThis.mdbaseReaderSocialPage?.items()) {
+ void chrome.runtime.sendMessage({ type: "mdbase-reader/posts-changed" }).catch(() => {
+ observer.disconnect();
+ document.removeEventListener("scroll", changed);
+ });
+ }
+ }, 600);
+ };
+ const observer = new MutationObserver((mutations) => {
+ if (mutations.some(postMutation)) {
+ changed();
+ }
+ });
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ attributes: true,
+ attributeFilter: ["href", "datetime"],
+ });
+ document.addEventListener("scroll", changed, { passive: true });
+}
+
+function postMutation(mutation: MutationRecord): boolean {
+ const target = boundaryElement(mutation.target);
+ if (target?.closest("[data-mdbase-reader-highlights],[data-mdbase-reader-ui]")) {
+ return false;
+ }
+ return (
+ mutation.type !== "childList" ||
+ [...mutation.addedNodes, ...mutation.removedNodes].some(
+ (node) =>
+ !(node instanceof Element) ||
+ !node.matches("[data-mdbase-reader-highlights],[data-mdbase-reader-ui]"),
+ )
+ );
+}
+
+if (typeof chrome !== "undefined" && !globalThis.mdbaseReaderSocialPage) {
+ installSocialPage();
+}
diff --git a/apps/extension/src/social-quotes.ts b/apps/extension/src/social-quotes.ts
new file mode 100644
index 00000000..ec0aced3
--- /dev/null
+++ b/apps/extension/src/social-quotes.ts
@@ -0,0 +1,41 @@
+import type { PageQuote } from "./page-annotations.js";
+import type { SocialPostReference } from "@mdbase-reader/core";
+
+const unresolvedPost = "https://reader.invalid/unresolved-post";
+
+/** A quote is drawn only inside the post it was made in, never inside its neighbour's text. */
+export function scopePostQuotes(
+ quotes: readonly PageQuote[],
+ post?: SocialPostReference,
+): readonly PageQuote[] {
+ if (!post) {
+ return quotes;
+ }
+ const [only] = post.parts;
+ return quotes.map((quote) => {
+ // A quoted post is drawn inside the post quoting it, so it resolves to that part.
+ const part = quote.postId
+ ? post.parts.find((item) => item.id === quote.postId || item.quoted?.id === quote.postId)
+ : undefined;
+ // Without a recorded post, only a single-post source is certain; a thread is not guessed.
+ const url = part?.url ?? (!quote.postId && post.parts.length === 1 ? only?.url : undefined);
+ return { ...quote, postUrl: url ?? unresolvedPost };
+ });
+}
+
+export function storedSocialPost(
+ properties?: Readonly>,
+): SocialPostReference | undefined {
+ const value = properties?.["social_post"];
+ if (
+ !value ||
+ typeof value !== "object" ||
+ !("parts" in value) ||
+ !Array.isArray(value.parts) ||
+ !("url" in value) ||
+ typeof value.url !== "string"
+ ) {
+ return undefined;
+ }
+ return value as SocialPostReference;
+}
diff --git a/apps/extension/src/use-extension-capture.test.tsx b/apps/extension/src/use-extension-capture.test.tsx
index 36a5b368..b674901b 100644
--- a/apps/extension/src/use-extension-capture.test.tsx
+++ b/apps/extension/src/use-extension-capture.test.tsx
@@ -128,6 +128,62 @@ afterEach(async () => {
vi.unstubAllGlobals();
});
+it("follows same-document navigation rather than leaving the old post in the panel", async () => {
+ await mount();
+ mocks.captureTab.mockResolvedValueOnce({
+ ...page,
+ canonicalUrl: "https://example.com/new",
+ submittedUrl: "https://example.com/new",
+ });
+ await act(async () => chromeFake.emit("updated", 1, { url: "https://example.com/new" }));
+ expect(controller.capture?.canonicalUrl).toBe("https://example.com/new");
+ expect(controller.navigated).toBe(false);
+});
+
+it("never discovers a feed snapshot and ignores a slow, superseded selection capture", async () => {
+ const feed = {
+ ...page,
+ canonicalUrl: "https://x.com/home",
+ submittedUrl: "https://x.com/home",
+ selection: null,
+ social: { mode: "multiple", items: [], nearest: null },
+ };
+ mocks.captureTab.mockResolvedValue(feed);
+ await mount();
+ expect(mocks.sourceForUrl).not.toHaveBeenCalled();
+ const first = {
+ ...feed,
+ canonicalUrl: "https://x.com/i/status/100",
+ selection: { exact: "First passage" },
+ selectionPostId: "100",
+ };
+ const latest = {
+ ...first,
+ canonicalUrl: "https://x.com/i/status/101",
+ selection: { exact: "Latest passage" },
+ selectionPostId: "101",
+ };
+ let finish!: (value: typeof first) => void;
+ mocks.captureTab
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ )
+ .mockResolvedValueOnce(latest);
+ await act(async () =>
+ chromeFake.emit("message", { type: "mdbase-reader/selection" }, { tab: { id: 1 } }),
+ );
+ await act(async () =>
+ chromeFake.emit("message", { type: "mdbase-reader/selection" }, { tab: { id: 1 } }),
+ );
+ expect(controller.capture?.canonicalUrl).toBe(latest.canonicalUrl);
+ await act(async () => finish(first));
+ expect(controller.capture?.canonicalUrl).toBe(latest.canonicalUrl);
+ expect(controller.capture?.selection?.exact).toBe("Latest passage");
+});
+
it("does not auto-save on opening or changing the destination, and remembers the choice", async () => {
await mount();
expect(controller.capture?.selection?.exact).toBe("quotation");
diff --git a/apps/extension/src/use-extension-capture.ts b/apps/extension/src/use-extension-capture.ts
index f4d8c701..b450695b 100644
--- a/apps/extension/src/use-extension-capture.ts
+++ b/apps/extension/src/use-extension-capture.ts
@@ -6,8 +6,16 @@ import { connectionUnavailableMessage } from "./connection-status.js";
import { rememberColor } from "./drafts.js";
import { annotationQuotes } from "./page-annotations.js";
import { markSavedPage } from "./page-badge.js";
-import { fetchPdf, renderAnnotations, revealAnnotation, type PageCapture } from "./page-capture.js";
+import {
+ captureSocialTab,
+ fetchPdf,
+ renderAnnotations,
+ revealAnnotation,
+ type PageCapture,
+ type SelectedWebCapture,
+} from "./page-capture.js";
import { CaptureWriter, type CaptureDraft } from "./save-capture.js";
+import { scopePostQuotes } from "./social-quotes.js";
import { rememberTags, splitTags } from "./tag-suggestions.js";
import { useActionLock } from "./use-action-lock.js";
import { useKnownTags } from "./use-known-tags.js";
@@ -30,6 +38,7 @@ import type {
AnnotationId,
SourceImportProgress,
SourceSummary,
+ SocialPost,
} from "@mdbase-reader/core";
export type { ExtensionCaptureController } from "./capture-controller.js";
@@ -52,6 +61,8 @@ export function useExtensionCapture(
const { connection, lock: connectionLock } = link;
const busy = lock.busy || connectionLock.busy;
const { capture } = page;
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const capturedPost = socialCapture(capture)?.post;
const stored = useStoredDraft(tabId, capture, page.setSelection);
const [status, setStatus] = useState("opening");
const [source, setSource] = useState(null);
@@ -107,10 +118,11 @@ export function useExtensionCapture(
capture,
);
+ const picking = isPostPickerPage(capture);
const display = useCallback(
(values: readonly Annotation[], url: string, focus?: AnnotationId) =>
- showSavedHighlights(tabId, values, url, focus),
- [tabId],
+ showSavedHighlights(tabId, values, url, focus, capturedPost),
+ [tabId, capturedPost],
);
useEffect(() => {
@@ -121,7 +133,13 @@ export function useExtensionCapture(
return;
}
void (async () => {
- const existing = await sourceForUrl(collection, pageUrl, submittedUrl ? [submittedUrl] : []);
+ const existing = picking
+ ? null
+ : await sourceForUrl(
+ collection,
+ pageUrl,
+ !capturedPost && submittedUrl ? [submittedUrl] : [],
+ );
if (epoch !== discovery.current) {
return;
}
@@ -166,7 +184,18 @@ export function useExtensionCapture(
// What the lookup said was about the page or collection being left.
setNotice(null);
};
- }, [collectionId, display, extension, pageKind, pageUrl, setNotice, submittedUrl, tabId]);
+ }, [
+ collectionId,
+ display,
+ extension,
+ pageKind,
+ pageUrl,
+ setNotice,
+ submittedUrl,
+ tabId,
+ capturedPost,
+ picking,
+ ]);
const { setDraft } = stored;
useEffect(() => {
@@ -198,8 +227,12 @@ export function useExtensionCapture(
const citation = citationFor && citationFor.page === pageUrl ? citationFor.preview : null;
const citationPending = Boolean(pageUrl) && citationFor?.page !== pageUrl;
- const save = (changes?: Partial): Promise =>
- lock.run(async () => {
+ const save = (changes?: Partial): Promise => {
+ if (socialCapture(capture)?.social?.mode === "multiple" && !changes?.highlight) {
+ setPickerOpen(true);
+ return Promise.resolve();
+ }
+ return lock.run(async () => {
const collection = extension?.session.connectedCollection();
if (!collection || !writer || !extension) {
throw new Error(
@@ -260,6 +293,56 @@ export function useExtensionCapture(
setStatus((current) => (current === "saving" ? "ready" : current));
}
}, "save");
+ };
+
+ const savePosts = (ids: readonly string[]): Promise =>
+ lock.run(async () => {
+ const collection = extension?.session.connectedCollection();
+ if (!collection || !writer || !extension) {
+ throw new Error(connectionUnavailableMessage(connection.snapshot));
+ }
+ setStatus("saving");
+ let count = 0;
+ try {
+ for (const id of ids) {
+ const post = await captureSocialTab(tabId, id);
+ if (!post?.post) {
+ throw new Error("The chosen post is not loaded anymore.");
+ }
+ const result = await writer.save({
+ session: extension.session,
+ collection,
+ capture: { ...post, selection: null },
+ draft: {
+ ...stored.draft,
+ title: post.pageTitle,
+ highlight: false,
+ comment: "",
+ note: "",
+ },
+ citation: await prepareCitation(post),
+ onProgress: setProgress,
+ onSource: () => undefined,
+ });
+ count++;
+ if (result.notices.length) {
+ setNotice(result.notices.join(" "));
+ }
+ }
+ setPickerOpen(false);
+ setNotice(`${String(count)} post${count === 1 ? "" : "s"} saved. Feeds are never saved.`);
+ await page.readPage();
+ void chrome.runtime
+ .sendMessage({ type: "mdbase-reader/posts-refresh", tabId })
+ .catch(() => undefined);
+ } catch (reason) {
+ throw new Error(
+ `${String(count)} posts confirmed before the failure. Retry checks each post for duplicates. ${problemMessage(reason)}`,
+ );
+ } finally {
+ setStatus("ready");
+ }
+ }, "save");
/**
* The list is known when it was loaded for this source, or the source is new; then a new
@@ -294,7 +377,7 @@ export function useExtensionCapture(
}
setAnnotations(values);
if (render && capture.kind === "html") {
- const shown = await display(values, capture.submittedUrl);
+ const shown = await display(values, capture.pageUrl ?? capture.submittedUrl);
if (epoch === refreshEpoch.current) {
setProjection(shown);
}
@@ -336,9 +419,9 @@ export function useExtensionCapture(
}
const redrawn = await revealAnnotation(
tabId,
- annotationQuotes(quoted),
+ scopePostQuotes(annotationQuotes(quoted), capturedPost),
index,
- capture.submittedUrl,
+ capture.pageUrl ?? capture.submittedUrl,
);
if (redrawn) {
setProjection({
@@ -354,7 +437,7 @@ export function useExtensionCapture(
const afterEdit = (next: readonly Annotation[]): void => {
setAnnotations(next);
if (capture?.kind === "html") {
- const url = capture.submittedUrl;
+ const url = capture.pageUrl ?? capture.submittedUrl;
void display(next, url)
.then(setProjection)
.catch(() => undefined);
@@ -459,6 +542,7 @@ export function useExtensionCapture(
...actions,
snapshot: connection.snapshot,
capture,
+ ...pickerProps(capture, pickerOpen, setPickerOpen, savePosts),
draft: stored.draft,
setDraft: stored.setDraft,
draftRestored: stored.restored,
@@ -466,13 +550,11 @@ export function useExtensionCapture(
problem: lock.problem ?? connectionLock.problem,
problemKind: lock.problem ? lock.problemKind : connectionLock.problemKind,
notice: lock.notice ?? connectionLock.notice,
- source,
- annotations,
+ ...visibleSourceState(pageUrl, source, annotations, projection),
citation,
citationPending,
deviceCode: connection.deviceCode,
directAccess: connection.directAccess,
- projection,
progress,
refreshing,
busy,
@@ -497,13 +579,20 @@ async function showSavedHighlights(
values: readonly Annotation[],
url: string,
focus?: AnnotationId,
+ post?: SocialPost,
): Promise {
const quoted = values.filter((annotation) => annotation.target?.quote);
- void markSavedPage(tabId, quoted.length).catch(() => undefined);
+ if (post) {
+ void chrome.runtime
+ .sendMessage({ type: "mdbase-reader/posts-refresh", tabId })
+ .catch(() => undefined);
+ } else {
+ void markSavedPage(tabId, quoted.length).catch(() => undefined);
+ }
const index = quoted.findIndex((annotation) => annotation.id === focus);
const { report, outcomes } = await renderAnnotations(
tabId,
- annotationQuotes(quoted),
+ scopePostQuotes(annotationQuotes(quoted), post),
url,
index === -1 ? undefined : index,
);
@@ -513,6 +602,52 @@ async function showSavedHighlights(
};
}
+function isPostPickerPage(capture: PageCapture | null): boolean {
+ const social = socialCapture(capture);
+ return social?.social?.mode === "multiple" && !social.post;
+}
+
+function socialCapture(capture: PageCapture | null): SelectedWebCapture | null {
+ return capture?.kind === "html" ? capture : null;
+}
+
+function capturableUrl(capture: PageCapture | null): string | undefined {
+ const html = socialCapture(capture);
+ return html?.social && !html.post ? undefined : capture?.canonicalUrl;
+}
+
+function pickerProps(
+ capture: PageCapture | null,
+ open: boolean,
+ setOpen: (value: boolean) => void,
+ save: (ids: readonly string[]) => Promise,
+): Pick {
+ const listing = socialCapture(capture)?.social;
+ return listing?.mode === "multiple"
+ ? {
+ postPicker: {
+ open,
+ items: listing.items,
+ preselected: listing.nearest,
+ show: () => setOpen(true),
+ close: () => setOpen(false),
+ save,
+ },
+ }
+ : {};
+}
+
+function visibleSourceState(
+ url: string | undefined,
+ source: SourceSummary | null,
+ annotations: readonly Annotation[],
+ projection: PageHighlights | null,
+): Pick {
+ return url
+ ? { source, annotations, projection }
+ : { source: null, annotations: [], projection: null };
+}
+
/** What identifies the panel's current page and collection, for discarding stale work. */
function pageKeys(
snapshot: ExtensionCaptureController["snapshot"],
@@ -528,11 +663,12 @@ function pageKeys(
readonly attemptKey: string;
} {
const collectionId = snapshot.status === "ready" ? snapshot.collectionId : null;
- const pageUrl = capture?.canonicalUrl;
+ const pageUrl = capturableUrl(capture);
return {
collectionId,
pageUrl,
- submittedUrl: capture?.submittedUrl,
+ submittedUrl:
+ capture?.kind === "html" ? (capture.pageUrl ?? capture.submittedUrl) : capture?.submittedUrl,
pageKind: capture?.kind,
discoveryKey: collectionId && pageUrl ? `${collectionId} ${pageUrl}` : null,
attemptKey: `${collectionId ?? ""} ${pageUrl ?? ""}`,
diff --git a/apps/extension/src/use-loaded-posts.ts b/apps/extension/src/use-loaded-posts.ts
new file mode 100644
index 00000000..696095af
--- /dev/null
+++ b/apps/extension/src/use-loaded-posts.ts
@@ -0,0 +1,41 @@
+import { useEffect, type Dispatch, type SetStateAction } from "react";
+
+import type { PageCapture } from "./page-capture.js";
+
+/** Keep the picker current as virtualised feeds load/replace posts, without re-extracting. */
+export function useLoadedPosts(
+ tabId: number,
+ setCapture: Dispatch>,
+): void {
+ useEffect(() => {
+ const changed = (message: unknown, sender: chrome.runtime.MessageSender): void => {
+ if (
+ !message ||
+ typeof message !== "object" ||
+ !("type" in message) ||
+ message.type !== "mdbase-reader/posts-changed" ||
+ sender.tab?.id !== tabId
+ ) {
+ return;
+ }
+ void chrome.scripting
+ .executeScript({
+ target: { tabId },
+ func: async () => globalThis.mdbaseReaderSocialPage?.refreshItems() ?? null,
+ })
+ .then(([result]) => {
+ const listing = result?.result;
+ if (listing) {
+ setCapture((current) =>
+ current?.kind === "html" && current.social
+ ? { ...current, social: listing }
+ : current,
+ );
+ }
+ })
+ .catch(() => undefined);
+ };
+ chrome.runtime.onMessage.addListener(changed);
+ return () => chrome.runtime.onMessage.removeListener(changed);
+ }, [tabId, setCapture]);
+}
diff --git a/apps/extension/src/use-page-capture.ts b/apps/extension/src/use-page-capture.ts
index 9e02c4ad..1e6b2f9a 100644
--- a/apps/extension/src/use-page-capture.ts
+++ b/apps/extension/src/use-page-capture.ts
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { intentKey, isExtensionMessage, type CaptureIntent } from "./messages.js";
+import { intentKey, type CaptureIntent } from "./messages.js";
import {
captureTab,
readSelection,
@@ -9,6 +9,8 @@ import {
type PageCapture,
} from "./page-capture.js";
import { pageStatusEnabled, pageStatusOrigins } from "./page-status.js";
+import { useLoadedPosts } from "./use-loaded-posts.js";
+import { usePageEvents } from "./use-page-events.js";
import type { QuoteSelector } from "@mdbase-reader/core";
@@ -28,7 +30,7 @@ export interface PageLink {
* from a click: Chrome asks only during a user gesture.
*/
readonly allowAllSites: () => Promise;
- readonly setSelection: (selection: QuoteSelector | null) => void;
+ readonly setSelection: (selection: QuoteSelector | null, postId?: string) => void;
}
/**
@@ -39,36 +41,37 @@ export interface PageLink {
*/
export function usePageCapture(tabId: number, onProblem: (message: string) => void): PageLink {
const [capture, setCapture] = useState(null);
+ useLoadedPosts(tabId, setCapture);
const [navigated, setNavigated] = useState(false);
const [following, setFollowing] = useState(false);
- // The page reports selections only while a panel shows its tab.
- useEffect(() => () => unwatchTabSelection(tabId), [tabId]);
- const [siteAccess, setSiteAccess] = useState(null);
- useEffect(() => {
- const check = (): void => {
- pageStatusEnabled()
- .then(setSiteAccess)
- .catch(() => setSiteAccess(false));
- };
- check();
- chrome.permissions.onAdded.addListener(check);
- chrome.permissions.onRemoved.addListener(check);
- return () => {
- chrome.permissions.onAdded.removeListener(check);
- chrome.permissions.onRemoved.removeListener(check);
- };
- }, []);
+ const readingEpoch = useRef(0);
+ // Invalidate pending public-API captures when leaving the tab/panel.
+ useEffect(
+ () => () => {
+ ++readingEpoch.current;
+ unwatchTabSelection(tabId);
+ },
+ [tabId],
+ );
const [invocation, setInvocation] = useState(null);
const problem = useRef(onProblem);
useEffect(() => {
problem.current = onProblem;
}, [onProblem]);
- const setSelection = useCallback((selection: QuoteSelector | null) => {
- setCapture((current) => (current?.kind === "html" ? { ...current, selection } : current));
+ const setSelection = useCallback((selection: QuoteSelector | null, postId?: string) => {
+ setCapture((current) =>
+ current?.kind === "html"
+ ? { ...current, selection, ...(postId ? { selectionPostId: postId } : {}) }
+ : current,
+ );
}, []);
const readPage = useCallback(async (): Promise => {
+ const epoch = ++readingEpoch.current;
const value = await captureTab(tabId);
+ if (epoch !== readingEpoch.current) {
+ return value;
+ }
setCapture(value);
setNavigated(false);
setFollowing(false);
@@ -77,58 +80,85 @@ export function usePageCapture(tabId: number, onProblem: (message: string) => vo
}
return value;
}, [tabId]);
+ const { siteAccess, allowAllSites } = useSiteAccess(readPage);
const followSelection = useCallback(async () => {
+ const epoch = ++readingEpoch.current;
+ if (capture?.kind === "html" && capture.social) {
+ const next = await captureTab(tabId);
+ if (epoch === readingEpoch.current) {
+ setCapture(next);
+ }
+ return;
+ }
const selection = await readSelection(tabId);
- if (selection) {
+ if (selection && epoch === readingEpoch.current) {
setSelection(selection);
}
- }, [setSelection, tabId]);
+ }, [capture, setSelection, tabId]);
+
+ usePageEvents({
+ tabId,
+ capture,
+ navigated,
+ following,
+ readPage,
+ followSelection,
+ setNavigated,
+ setFollowing,
+ setInvocation,
+ readingEpoch,
+ problem,
+ });
+
+ useInitialIntent(tabId, setInvocation);
+
+ return {
+ capture,
+ navigated,
+ following,
+ invocation,
+ readPage,
+ siteAccess,
+ allowAllSites,
+ setSelection,
+ };
+}
+function useSiteAccess(
+ readPage: PageLink["readPage"],
+): Pick {
+ const [siteAccess, setSiteAccess] = useState(null);
useEffect(() => {
- const onMessage = (message: unknown, sender: chrome.runtime.MessageSender): void => {
- if (!isExtensionMessage(message)) {
- return;
- }
- if (message.type === "mdbase-reader/selection" && sender.tab?.id === tabId) {
- followSelection().catch(() => undefined);
- }
- if (message.type === "mdbase-reader/invoke" && message.tabId === tabId) {
- const { intent } = message;
- void chrome.storage.session.remove(intentKey(tabId)).catch(() => undefined);
- // A new invocation grants activeTab, so a page not read yet can be read now.
- // The intent counts once the selection it refers to has been read.
- (navigated || !capture ? readPage() : followSelection())
- .then(() => setInvocation({ intent, at: Date.now() }))
- .catch((reason: unknown) =>
- problem.current(reason instanceof Error ? reason.message : String(reason)),
- );
- }
- };
- const onUpdated = (updatedTab: number, change: { readonly status?: string }): void => {
- if (updatedTab !== tabId) {
- return;
- }
- if (change.status === "loading") {
- setNavigated(true);
- setFollowing(true);
- }
- if (change.status === "complete" && navigated && following) {
- // A page Reader cannot read leaves the panel waiting for the toolbar button.
- readPage().catch(() => setFollowing(false));
- }
+ const check = (): void => {
+ void pageStatusEnabled()
+ .then(setSiteAccess)
+ .catch(() => setSiteAccess(false));
};
- chrome.runtime.onMessage.addListener(onMessage);
- chrome.tabs.onUpdated.addListener(onUpdated);
+ check();
+ chrome.permissions.onAdded.addListener(check);
+ chrome.permissions.onRemoved.addListener(check);
return () => {
- chrome.runtime.onMessage.removeListener(onMessage);
- chrome.tabs.onUpdated.removeListener(onUpdated);
+ chrome.permissions.onAdded.removeListener(check);
+ chrome.permissions.onRemoved.removeListener(check);
};
- }, [capture, followSelection, following, navigated, readPage, tabId]);
+ }, []);
+ const allowAllSites = useCallback(async (): Promise => {
+ const granted = await chrome.permissions.request({ origins: pageStatusOrigins });
+ if (granted) {
+ await readPage();
+ }
+ return granted;
+ }, [readPage]);
+ return { siteAccess, allowAllSites };
+}
+function useInitialIntent(
+ tabId: number,
+ setInvocation: (value: PageLink["invocation"]) => void,
+): void {
useEffect(() => {
- // The intent that opened this panel (e.g. "Highlight with a comment" from the context menu).
const key = intentKey(tabId);
- chrome.storage.session
+ void chrome.storage.session
.get(key)
.then(async ({ [key]: intent }) => {
if (intent === "highlight" || intent === "note") {
@@ -137,25 +167,5 @@ export function usePageCapture(tabId: number, onProblem: (message: string) => vo
await chrome.storage.session.remove(key);
})
.catch(() => undefined);
- }, [tabId]);
-
- const allowAllSites = useCallback(async (): Promise => {
- // The request comes first, while the click still counts as a user gesture.
- const granted = await chrome.permissions.request({ origins: pageStatusOrigins });
- if (granted) {
- await readPage();
- }
- return granted;
- }, [readPage]);
-
- return {
- capture,
- navigated,
- following,
- invocation,
- readPage,
- siteAccess,
- allowAllSites,
- setSelection,
- };
+ }, [setInvocation, tabId]);
}
diff --git a/apps/extension/src/use-page-events.ts b/apps/extension/src/use-page-events.ts
new file mode 100644
index 00000000..c79f44dc
--- /dev/null
+++ b/apps/extension/src/use-page-events.ts
@@ -0,0 +1,112 @@
+import { useEffect, type RefObject } from "react";
+
+import { intentKey, isExtensionMessage, type CaptureIntent } from "./messages.js";
+
+import type { PageCapture } from "./page-capture.js";
+
+interface PageEvents {
+ readonly tabId: number;
+ readonly capture: PageCapture | null;
+ readonly navigated: boolean;
+ readonly following: boolean;
+ readonly readPage: () => Promise;
+ readonly followSelection: () => Promise;
+ readonly setNavigated: (value: boolean) => void;
+ readonly setFollowing: (value: boolean) => void;
+ readonly setInvocation: (value: { intent: CaptureIntent; at: number }) => void;
+ readonly readingEpoch: RefObject;
+ readonly problem: RefObject<(message: string) => void>;
+}
+
+/** Follows full and same-document navigation; stale captures cannot trigger a quick save. */
+export function usePageEvents({
+ tabId,
+ capture,
+ navigated,
+ following,
+ readPage,
+ followSelection,
+ setNavigated,
+ setFollowing,
+ setInvocation,
+ readingEpoch,
+ problem,
+}: PageEvents): void {
+ useEffect(() => {
+ const onMessage = (message: unknown, sender: chrome.runtime.MessageSender): void => {
+ if (!isExtensionMessage(message)) {
+ return;
+ }
+ if (
+ message.type === "mdbase-reader/posts-changed" &&
+ sender.tab?.id === tabId &&
+ navigated &&
+ !following
+ ) {
+ setFollowing(true);
+ void readPage().catch(() => setFollowing(false));
+ }
+ if (message.type === "mdbase-reader/selection" && sender.tab?.id === tabId) {
+ void followSelection().catch(() => undefined);
+ }
+ if (message.type === "mdbase-reader/invoke" && message.tabId === tabId) {
+ const { intent } = message;
+ void chrome.storage.session.remove(intentKey(tabId)).catch(() => undefined);
+ const reading = navigated || !capture ? readPage() : followSelection();
+ const epoch = readingEpoch.current;
+ void reading
+ .then(() => {
+ if (epoch === readingEpoch.current) {
+ setInvocation({ intent, at: Date.now() });
+ }
+ })
+ .catch((reason: unknown) =>
+ problem.current(reason instanceof Error ? reason.message : String(reason)),
+ );
+ }
+ };
+ const onUpdated = (
+ updatedTab: number,
+ change: { readonly status?: string; readonly url?: string },
+ ): void => {
+ if (updatedTab !== tabId) {
+ return;
+ }
+ const capturedUrl =
+ capture?.kind === "html"
+ ? (capture.pageUrl ?? capture.submittedUrl)
+ : capture?.submittedUrl;
+ if (change.url && change.status !== "loading" && change.url !== capturedUrl) {
+ setNavigated(true);
+ setFollowing(true);
+ void readPage().catch(() => setFollowing(false));
+ }
+ if (change.status === "loading") {
+ ++readingEpoch.current;
+ setNavigated(true);
+ setFollowing(true);
+ }
+ if (change.status === "complete" && navigated && following) {
+ void readPage().catch(() => setFollowing(false));
+ }
+ };
+ chrome.runtime.onMessage.addListener(onMessage);
+ chrome.tabs.onUpdated.addListener(onUpdated);
+ return () => {
+ chrome.runtime.onMessage.removeListener(onMessage);
+ chrome.tabs.onUpdated.removeListener(onUpdated);
+ };
+ }, [
+ capture,
+ followSelection,
+ following,
+ navigated,
+ readPage,
+ tabId,
+ problem,
+ readingEpoch,
+ setFollowing,
+ setInvocation,
+ setNavigated,
+ ]);
+}
diff --git a/apps/extension/src/use-stored-draft.ts b/apps/extension/src/use-stored-draft.ts
index e1f7d7fb..7eb3d6fa 100644
--- a/apps/extension/src/use-stored-draft.ts
+++ b/apps/extension/src/use-stored-draft.ts
@@ -17,13 +17,14 @@ export interface StoredDraftState {
export function useStoredDraft(
tabId: number,
capture: PageCapture | null,
- setSelection: (selection: QuoteSelector | null) => void,
+ setSelection: (selection: QuoteSelector | null, postId?: string) => void,
): StoredDraftState {
const [draft, setDraft] = useState(emptyDraft);
const [loadedFor, setLoadedFor] = useState(null);
const [restored, setRestored] = useState(false);
const page = capture?.canonicalUrl ?? null;
const selection = capture?.kind === "html" ? capture.selection : null;
+ const selectionPostId = capture?.kind === "html" ? capture.selectionPostId : undefined;
// A new passage is what the reader wants to highlight; a cleared one is done.
const [seenSelection, setSeenSelection] = useState(selection);
@@ -46,7 +47,7 @@ export function useStoredDraft(
if (stored) {
setDraft({ ...stored.draft, title: stored.draft.title || capture.pageTitle });
if (!selection && stored.selection) {
- setSelection(stored.selection);
+ setSelection(stored.selection, stored.selectionPostId);
}
setRestored(Boolean(stored.draft.note.trim() || stored.draft.comment.trim()));
} else {
@@ -69,9 +70,17 @@ export function useStoredDraft(
if (!page || loadedFor !== page) {
return;
}
- const timer = setTimeout(() => void saveDraft(tabId, page, { draft, selection }), 250);
+ const timer = setTimeout(
+ () =>
+ void saveDraft(tabId, page, {
+ draft,
+ selection,
+ ...(selectionPostId ? { selectionPostId } : {}),
+ }),
+ 250,
+ );
return () => clearTimeout(timer);
- }, [draft, loadedFor, page, selection, tabId]);
+ }, [draft, loadedFor, page, selection, selectionPostId, tabId]);
return { draft, setDraft, restored };
}
diff --git a/apps/extension/vite.config.ts b/apps/extension/vite.config.ts
index 12b274b5..06572deb 100644
--- a/apps/extension/vite.config.ts
+++ b/apps/extension/vite.config.ts
@@ -30,6 +30,7 @@ function serviceWorker(): Plugin {
const scripts = {
background: "src/background.ts",
"page-status": "src/page-status-worker.ts",
+ "social-page": "src/social-page.ts",
};
return {
name: "mdbase-reader-extension-service-worker",
diff --git a/apps/reader/src/connect-workspace.test.ts b/apps/reader/src/connect-workspace.test.ts
index 6efec719..dd65d105 100644
--- a/apps/reader/src/connect-workspace.test.ts
+++ b/apps/reader/src/connect-workspace.test.ts
@@ -44,6 +44,43 @@ const annotation: Annotation = {
};
describe("ConnectWorkspaceGateway", () => {
+ it("reuses a saved post by canonical identity even when its capture bytes or title change", async () => {
+ const findByUrl = vi.fn().mockResolvedValue(source);
+ const imports = emptyImports();
+ const gateway = new ConnectWorkspaceGateway(
+ { findByUrl, get: vi.fn().mockResolvedValue(source) } as unknown as SourceRepository,
+ {} as AnnotationRepository,
+ { store: vi.fn() },
+ imports,
+ source.collectionId,
+ "[test] Posts",
+ createReaderRuntimeServices(new MemoryStorage()),
+ );
+ const part = {
+ platform: "bluesky" as const,
+ id: "at://did:plc:fixture/app.bsky.feed.post/first",
+ url: "https://bsky.app/profile/did:plc:fixture/post/first",
+ author: { id: "did:plc:fixture", handle: "fixture.test", name: "Fixture Author" },
+ text: "Durable post",
+ media: [],
+ };
+ const request = {
+ name: "post.html",
+ bytes: new Uint8Array(),
+ title: "New title",
+ metadata: {
+ socialPost: { ...part, threadRoot: part.id, parts: [part], threadPartial: false },
+ },
+ };
+ expect(await gateway.importSourceFile(request)).toBe(source);
+ expect(await gateway.importSourceFile({ ...request, bytes: new Uint8Array([1, 2, 3]) })).toBe(
+ source,
+ );
+ expect(findByUrl).toHaveBeenCalledWith(source.collectionId, part.url, {});
+ expect(imports.commitFile).not.toHaveBeenCalled();
+ expect(source.body).toBe("Original");
+ });
+
it("loads summaries before source bodies and saves notes revision-safely", async () => {
const controller = new AbortController();
const updateBody = vi.fn().mockResolvedValue({ ...source, body: "Updated" });
diff --git a/apps/reader/src/connect-workspace.ts b/apps/reader/src/connect-workspace.ts
index d01b3863..6d4736ca 100644
--- a/apps/reader/src/connect-workspace.ts
+++ b/apps/reader/src/connect-workspace.ts
@@ -5,6 +5,8 @@ import {
citekeyForCitation,
createSourceRecord,
importSourceFile,
+ normalizedSourceUrl,
+ sameSourceUrl,
planAnnotationDeletion,
saveSourceCitation,
transcludeAnnotation,
@@ -457,6 +459,20 @@ export class ConnectWorkspaceGateway implements ReaderWorkspaceGateway {
request: Omit,
options: SourceImportOptions = {},
): Promise {
+ const postUrl = request.metadata?.socialPost?.url;
+ if (postUrl) {
+ const found = this.sources.findByUrl
+ ? await this.sources.findByUrl(this.collectionId, postUrl, options)
+ : (await this.library(options.signal ? { signal: options.signal } : {})).sources.find(
+ (source) => source.url && sameSourceUrl(source.url, normalizedSourceUrl(postUrl)),
+ );
+ if (found) {
+ const existing = await this.source(found.id, options);
+ if (existing) {
+ return existing;
+ }
+ }
+ }
const imported = await importSourceFile(
{
imports: this.sourceImports,
diff --git a/apps/reader/src/source-addition-flow.test.ts b/apps/reader/src/source-addition-flow.test.ts
index d969e3c0..ddd8762a 100644
--- a/apps/reader/src/source-addition-flow.test.ts
+++ b/apps/reader/src/source-addition-flow.test.ts
@@ -160,6 +160,50 @@ describe("adding a source from pasted text", () => {
});
});
+describe("adding social sources", () => {
+ it.each([
+ "https://x.com/home",
+ "https://x.com/fixture",
+ "https://bsky.app/search",
+ "https://bsky.app/profile/fixture.test",
+ ])("rejects feed/profile URLs without offering citation-only capture: %s", async (url) => {
+ const deps = services();
+ await expect(addSourceFromInput(url, deps)).rejects.toThrow("cannot be saved");
+ expect(deps.capture).not.toHaveBeenCalled();
+ expect(deps.lookUp).not.toHaveBeenCalled();
+ });
+ it("explains X's live-page requirement instead of requesting undocumented APIs", async () => {
+ const deps = services();
+ await expect(
+ addSourceFromInput("https://twitter.com/fixture/status/100", deps),
+ ).rejects.toThrow("extension");
+ expect(deps.capture).not.toHaveBeenCalled();
+ });
+ it("does not offer a citation-only Mastodon profile after marker-based feed rejection", async () => {
+ const deps = services({
+ capture: vi.fn(() =>
+ Promise.reject(
+ new Error(
+ "Feeds cannot be saved as sources. Choose individual posts in the Reader extension.",
+ ),
+ ),
+ ),
+ });
+ await expect(addSourceFromInput("https://any-instance.test/@fixture", deps)).rejects.toThrow(
+ "Feeds cannot",
+ );
+ expect(deps.lookUp).not.toHaveBeenCalled();
+ expect(deps.workspace.createSource).not.toHaveBeenCalled();
+ });
+ it("does not offer a citation-only post after an API failure", async () => {
+ const deps = services();
+ await expect(
+ addSourceFromInput("https://bsky.app/profile/fixture.test/post/first", deps),
+ ).rejects.toThrow("blocked");
+ expect(deps.lookUp).not.toHaveBeenCalled();
+ });
+});
+
function source(): Source {
return {
collectionId: collectionId("reading"),
diff --git a/apps/reader/src/source-addition-flow.ts b/apps/reader/src/source-addition-flow.ts
index c5099926..2d82d0cf 100644
--- a/apps/reader/src/source-addition-flow.ts
+++ b/apps/reader/src/source-addition-flow.ts
@@ -5,6 +5,8 @@ import {
citationYear,
identifierLookup,
parseSourceInput,
+ postAddress,
+ siteAdapters,
type CitationDraft,
type SourceIdentifier,
} from "@mdbase-reader/web-capture";
@@ -67,12 +69,16 @@ async function addFromUrl(
url: string,
services: SourceAdditionServices,
): Promise {
+ const socialAddress = assertSocialInput(url);
services.onStatus("Fetching the page…");
let captured: CaptureResult;
try {
captured = await services.capture(url, { allowPdf: true, ...signalOf(services) });
} catch (reason) {
services.signal?.throwIfAborted();
+ if (socialAddress || messageOf(reason).includes("Feeds cannot be saved as sources")) {
+ throw reason;
+ }
services.onStatus("Looking up citation details instead…");
const candidate = await services.lookUp({ kind: "url", value: url }).catch(() => null);
if (candidate) {
@@ -102,13 +108,33 @@ async function addFromUrl(
capture: page.capture,
archive: page.archive,
metadata: { ...fields?.metadata, ...page.metadata, ...authorsOf(fields) },
- ...(fields?.kind && fields.kind !== "webpage" ? { kind: fields.kind } : {}),
+ ...capturedKind(page.metadata, fields?.kind),
},
services.importOptions,
);
return finish(source, preview?.citation, services);
}
+function assertSocialInput(url: string): ReturnType {
+ const address = postAddress(url);
+ if (siteAdapters.some((adapter) => adapter.matches(url)) && !address) {
+ throw new Error(
+ "Feeds, profiles and search pages cannot be saved. Choose individual posts in the Reader extension.",
+ );
+ }
+ if (address?.platform === "x") {
+ throw new Error("Save X posts from their live page using the mdbase Reader extension.");
+ }
+ return address;
+}
+
+function capturedKind(metadata: SourceImportMetadata, kind?: string): { kind?: string } {
+ if (metadata.socialPost) {
+ return { kind: "post" };
+ }
+ return kind && kind !== "webpage" ? { kind } : {};
+}
+
async function addFromIdentifier(
identifier: Exclude,
services: SourceAdditionServices,
@@ -191,7 +217,7 @@ async function finish(
return { kind: "not-added" };
}
const save = services.workspace.saveNewSourceCitation;
- if (!citation || !save) {
+ if (!citation || !save || source.citation) {
return { kind: "added", source, notices: [] };
}
// A candidate's placeholder citekey is replaced with one unique in the collection.
diff --git a/apps/reader/src/source-addition-services.test.ts b/apps/reader/src/source-addition-services.test.ts
new file mode 100644
index 00000000..1a6b1219
--- /dev/null
+++ b/apps/reader/src/source-addition-services.test.ts
@@ -0,0 +1,58 @@
+// @vitest-environment happy-dom
+import { afterEach, expect, it, vi } from "vitest";
+
+import { readerSourceAdditionServices } from "./source-addition-services.js";
+
+import type { CaptureResult } from "./web-capture-client.js";
+import type { Source } from "@mdbase-reader/core";
+import type * as WebCapture from "@mdbase-reader/web-capture";
+
+const mocks = vi.hoisted(() => ({ postFromUrl: vi.fn(), fetchCapture: vi.fn() }));
+vi.mock("@mdbase-reader/web-capture", async (original) => ({
+ ...(await original()),
+ postFromUrl: mocks.postFromUrl,
+}));
+vi.mock("./web-capture-client.js", () => ({ fetchCapture: mocks.fetchCapture }));
+const workspace = {
+ importSourceFile: () => Promise.resolve(null),
+ createSource: () => Promise.resolve(null),
+ saveNewSourceCitation: (source: Source) => Promise.resolve(source),
+};
+afterEach(() => vi.clearAllMocks());
+
+it("falls through to marker-aware HTML capture when a post-shaped path is not a Mastodon API", async () => {
+ const result = { kind: "html" } as CaptureResult;
+ mocks.postFromUrl.mockRejectedValue(new Error("Not Mastodon"));
+ mocks.fetchCapture.mockResolvedValue(result);
+ const services = readerSourceAdditionServices({ workspace, onStatus: vi.fn() });
+ const url = "https://ordinary-site.test/@fixture/123";
+ expect(await services.capture(url, { allowPdf: true })).toBe(result);
+ expect(mocks.fetchCapture).toHaveBeenCalledWith(url, { allowPdf: true });
+});
+
+it("does not turn a known Bluesky API failure into an ordinary article capture", async () => {
+ mocks.postFromUrl.mockRejectedValue(new Error("AppView unavailable"));
+ const services = readerSourceAdditionServices({ workspace, onStatus: vi.fn() });
+ await expect(
+ services.capture("https://bsky.app/profile/did:plc:fixture/post/first", { allowPdf: true }),
+ ).rejects.toThrow("AppView unavailable");
+ expect(mocks.fetchCapture).not.toHaveBeenCalled();
+});
+
+it("preserves cancellation rather than starting an HTML fallback", async () => {
+ const controller = new AbortController();
+ controller.abort(new Error("Cancelled"));
+ mocks.postFromUrl.mockRejectedValue(new Error("API request aborted"));
+ const services = readerSourceAdditionServices({
+ workspace,
+ onStatus: vi.fn(),
+ signal: controller.signal,
+ });
+ await expect(
+ services.capture("https://ordinary-site.test/@fixture/123", {
+ allowPdf: true,
+ signal: controller.signal,
+ }),
+ ).rejects.toThrow("Cancelled");
+ expect(mocks.fetchCapture).not.toHaveBeenCalled();
+});
diff --git a/apps/reader/src/source-addition-services.ts b/apps/reader/src/source-addition-services.ts
index 5cfe8a97..2ccf01f6 100644
--- a/apps/reader/src/source-addition-services.ts
+++ b/apps/reader/src/source-addition-services.ts
@@ -1,3 +1,4 @@
+import { postAddress, postFromUrl, socialPostImport } from "@mdbase-reader/web-capture";
import { lookUpCitation } from "@mdbase-reader/web-capture/citation-lookup";
import { readDocumentFileDetails } from "./document-details.js";
@@ -12,7 +13,33 @@ export function readerSourceAdditionServices(
const signal = input.signal ? { signal: input.signal } : {};
return {
...input,
- capture: (url, options) => fetchCapture(url, options),
+ capture: async (url, options) => {
+ const activeSignal = options.signal ?? input.signal;
+ const fetchOptions = activeSignal ? { signal: activeSignal } : {};
+ const address = postAddress(url);
+ if (address) {
+ const document = new DOMParser().parseFromString("", "text/html");
+ const post = await postFromUrl(url, document, fetchOptions).catch((reason: unknown) => {
+ activeSignal?.throwIfAborted();
+ if (address.platform !== "mastodon") {
+ throw reason;
+ }
+ // A /@user/ID path alone is not proof of Mastodon. The HTML capture checks markers.
+ return null;
+ });
+ if (post) {
+ return {
+ kind: "html",
+ page: await socialPostImport(
+ post,
+ { retrievedAt: new Date().toISOString() },
+ fetchOptions,
+ ),
+ };
+ }
+ }
+ return fetchCapture(url, options);
+ },
lookUp: (request) =>
lookUpCitation(request, {
clientName: `mdbase-reader (${globalThis.location.origin})`,
diff --git a/packages/connect/src/source-frontmatter.ts b/packages/connect/src/source-frontmatter.ts
index 74380738..e9df00e1 100644
--- a/packages/connect/src/source-frontmatter.ts
+++ b/packages/connect/src/source-frontmatter.ts
@@ -2,7 +2,12 @@ import { ConnectRepositoryError } from "./repository-client.js";
import { documentEntry } from "./source-files.js";
import type { CollectionFileDescriptor } from "@mdbase-dev/connect";
-import type { PlannedSourceFileImport, PlannedSourceRepresentation } from "@mdbase-reader/core";
+import type {
+ PlannedSourceFileImport,
+ PlannedSourceRepresentation,
+ SocialPost,
+ SocialPostReference,
+} from "@mdbase-reader/core";
type Descriptors = ReadonlyMap;
@@ -37,6 +42,24 @@ function metadataFields(
...(metadata.description ? { description: metadata.description } : {}),
...(metadata.language ? { language: metadata.language } : {}),
...(metadata.site ? { site: metadata.site } : {}),
+ ...(metadata.socialPost ? { social_post: socialPostReference(metadata.socialPost) } : {}),
+ };
+}
+
+function socialPostReference(post: SocialPost): SocialPostReference {
+ return {
+ platform: post.platform,
+ id: post.id,
+ url: post.url,
+ author: post.author,
+ ...(post.published ? { published: post.published } : {}),
+ threadRoot: post.threadRoot,
+ threadPartial: post.threadPartial,
+ parts: post.parts.map((part) => ({
+ id: part.id,
+ url: part.url,
+ ...(part.quoted ? { quoted: { id: part.quoted.id, url: part.quoted.url } } : {}),
+ })),
};
}
diff --git a/packages/connect/src/source-imports-web-capture.test.ts b/packages/connect/src/source-imports-web-capture.test.ts
index da2eee03..604deb6e 100644
--- a/packages/connect/src/source-imports-web-capture.test.ts
+++ b/packages/connect/src/source-imports-web-capture.test.ts
@@ -8,6 +8,53 @@ import type { ReaderConnectClient } from "./repository-client.js";
import type { PlannedSourceFileImport } from "@mdbase-reader/core";
describe("ConnectSourceImportRepository web capture provenance", () => {
+ it("persists post identity and thread shape, leaving text and media to the body", async () => {
+ const create = vi.fn(() => Promise.resolve(success(recordDocument())));
+ const repository = new ConnectSourceImportRepository(
+ { create } as unknown as ReaderConnectClient,
+ {
+ upload: vi.fn((path: string) =>
+ Promise.resolve(fileDescriptor({ path, mediaType: "text/html" })),
+ ),
+ },
+ );
+ const part = {
+ platform: "x" as const,
+ id: "100",
+ url: "https://x.com/i/status/100",
+ author: { id: "fixture", handle: "fixture", name: "Fixture Author" },
+ text: "Durable post",
+ media: [
+ { kind: "image" as const, url: "https://media.example/image.png", alt: "A blue square" },
+ ],
+ };
+ const post = { ...part, threadRoot: part.id, parts: [part], threadPartial: true };
+ const base = webCapturePlan();
+ await repository.commitFile({
+ ...base,
+ kind: "post",
+ metadata: { socialPost: post },
+ capture: { ...base.capture!, submittedUrl: part.url, canonicalUrl: part.url },
+ });
+ expect(create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ frontmatter: expect.objectContaining({
+ kind: "post",
+ url: part.url,
+ social_post: {
+ platform: "x",
+ id: "100",
+ url: part.url,
+ author: part.author,
+ threadRoot: "100",
+ threadPartial: true,
+ parts: [{ id: "100", url: part.url }],
+ },
+ }),
+ }),
+ );
+ });
+
it("preserves raw and readable web representations with capture metadata", async () => {
const create = vi.fn(() => Promise.resolve(success(recordDocument())));
const upload = vi.fn((path: string) =>
diff --git a/packages/core/src/application/source-import-ports.ts b/packages/core/src/application/source-import-ports.ts
index 2620e065..d1c281a0 100644
--- a/packages/core/src/application/source-import-ports.ts
+++ b/packages/core/src/application/source-import-ports.ts
@@ -1,5 +1,6 @@
import type { ReaderRequestOptions } from "./ports.js";
import type { CollectionId, MutationId, SourceId } from "../domain/identity.js";
+import type { SocialPost } from "../domain/social-post.js";
import type { Source, SourceSummary } from "../domain/source.js";
import type { DateTime } from "../domain/time.js";
@@ -63,6 +64,7 @@ export interface SourceCaptureArchive {
}
export interface SourceImportMetadata {
+ readonly socialPost?: SocialPost;
readonly authors?: readonly string[];
readonly published?: string;
readonly description?: string;
diff --git a/packages/core/src/application/source-import-values.ts b/packages/core/src/application/source-import-values.ts
index f9c51fce..547dd747 100644
--- a/packages/core/src/application/source-import-values.ts
+++ b/packages/core/src/application/source-import-values.ts
@@ -38,6 +38,7 @@ export function validateMetadata(metadata: SourceImportMetadata): SourceImportMe
...(description ? { description } : {}),
...(language ? { language } : {}),
...(site ? { site } : {}),
+ ...(metadata.socialPost ? { socialPost: metadata.socialPost } : {}),
};
}
diff --git a/packages/core/src/domain/social-post.ts b/packages/core/src/domain/social-post.ts
new file mode 100644
index 00000000..a9e74463
--- /dev/null
+++ b/packages/core/src/domain/social-post.ts
@@ -0,0 +1,45 @@
+export type SocialPlatform = "x" | "bluesky" | "mastodon";
+
+export interface PostMedia {
+ readonly url: string;
+ readonly alt: string;
+ readonly kind: "image" | "video" | "audio";
+}
+
+/** Portable post data; HTML is a reading representation, not the source identity. */
+export interface PostPart {
+ readonly platform: SocialPlatform;
+ readonly id: string;
+ readonly url: string;
+ readonly author: { readonly id: string; readonly handle: string; readonly name: string };
+ readonly published?: string;
+ readonly text: string;
+ readonly media: readonly PostMedia[];
+ readonly quoted?: PostPart;
+ readonly inReplyTo?: string;
+}
+
+export interface SocialPost extends PostPart {
+ readonly threadRoot: string;
+ readonly parts: readonly PostPart[];
+ /** APIs and live pages may omit deleted, private or not-yet-loaded thread parts. */
+ readonly threadPartial: boolean;
+}
+
+export interface PostPartReference {
+ readonly id: string;
+ readonly url: string;
+ readonly quoted?: { readonly id: string; readonly url: string };
+}
+
+/** What a source's frontmatter keeps: identity and thread shape. Text and media live in the body. */
+export interface SocialPostReference {
+ readonly platform: SocialPlatform;
+ readonly id: string;
+ readonly url: string;
+ readonly author: PostPart["author"];
+ readonly published?: string;
+ readonly threadRoot: string;
+ readonly threadPartial: boolean;
+ readonly parts: readonly PostPartReference[];
+}
diff --git a/packages/core/src/domain/source-url.ts b/packages/core/src/domain/source-url.ts
index ea4daa16..4850d6f3 100644
--- a/packages/core/src/domain/source-url.ts
+++ b/packages/core/src/domain/source-url.ts
@@ -9,6 +9,17 @@ const trackingParameter =
export function normalizedSourceUrl(value: string): string {
const url = new URL(value);
url.hash = "";
+ if (/^(?:(?:www|mobile)\.)?(?:x|twitter)\.com$/u.test(url.hostname)) {
+ const status = /^\/(?:[^/]+\/status|i\/web\/status|i\/status)\/(\d+)(?:\/|$)/u.exec(
+ url.pathname,
+ )?.[1];
+ if (status) {
+ return `https://x.com/i/status/${status}`;
+ }
+ }
+ if (url.hostname === "bsky.app" && /^\/profile\/[^/]+\/post\/[^/]+\/?$/u.test(url.pathname)) {
+ url.search = "";
+ }
const cached = ampCacheOrigin(url);
if (cached) {
return normalizedSourceUrl(cached);
@@ -43,6 +54,10 @@ export function sameSourceUrl(value: string, normalized: string): boolean {
*/
export function sourceUrlSearchKey(value: string): string {
const url = new URL(normalizedSourceUrl(value));
+ // Every X spelling contains /status/ID, even when the stored hostname/author is old.
+ if (url.hostname === "x.com" && /^\/i\/status\/\d+$/u.test(url.pathname)) {
+ return url.pathname.replace(/^\/i/u, "");
+ }
return `${url.hostname}${url.pathname === "/" ? "" : url.pathname}`.toLowerCase();
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index abd82936..e6f4ac8b 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -26,6 +26,7 @@ export * from "./domain/revision.js";
export type * from "./domain/search.js";
export * from "./domain/selector.js";
export * from "./domain/source.js";
+export type * from "./domain/social-post.js";
export * from "./domain/source-url.js";
export * from "./domain/text-quote-match.js";
export * from "./domain/time.js";
diff --git a/packages/renderer-html/src/html-document.ts b/packages/renderer-html/src/html-document.ts
index 9d3735ee..e83d247d 100644
--- a/packages/renderer-html/src/html-document.ts
+++ b/packages/renderer-html/src/html-document.ts
@@ -79,6 +79,12 @@ function readerStyle(document: Document): HTMLStyleElement {
html { scroll-behavior: smooth; }
body { max-width: 46rem; margin: 0 auto; padding: 5rem 3rem 9rem; font: 18px/1.72 Georgia, 'Times New Roman', serif; }
main, article { display: block; }
+ .social-post > header h1 { font-size: 2rem; margin-bottom: .5rem; }
+ .social-post header, .social-post time, .social-post figcaption, .social-post footer { font: .85em/1.5 ui-sans-serif, system-ui, sans-serif; color: color-mix(in srgb, currentColor 72%, transparent); }
+ .post-part { margin-block: 2rem; padding-top: 1.5rem; border-top: 1px solid color-mix(in srgb, currentColor 18%, transparent); }
+ .post-part header p { margin-block: 0 .25rem; }
+ .social-post figure { margin-inline: 0; }
+ .quoted-post .post-part { border-top: 0; padding-top: 0; }
h1, h2, h3, h4 { color: inherit; font-family: ui-sans-serif, system-ui, sans-serif; line-height: 1.15; letter-spacing: -0.025em; }
h1 { margin: 0 0 2.4rem; font-size: clamp(2.15rem, 6vw, 3.7rem); }
h2 { margin-top: 2.8rem; font-size: 1.65rem; }
diff --git a/packages/renderer-html/src/html-range.test.ts b/packages/renderer-html/src/html-range.test.ts
index 81f03611..b1f269ce 100644
--- a/packages/renderer-html/src/html-range.test.ts
+++ b/packages/renderer-html/src/html-range.test.ts
@@ -4,6 +4,24 @@ import { describe, expect, it } from "vitest";
import { htmlSelectionDraft, locateHtmlTarget } from "./html-range.js";
describe("HTML ranges", () => {
+ it("anchors identical post passages to their thread part, including quoted context", () => {
+ document.body.innerHTML =
+ '';
+ const second = document.querySelector('[data-post-id="101"] > p')!;
+ const range = document.createRange();
+ range.selectNodeContents(second);
+ const draft = htmlSelectionDraft({ document, range, href: "post.html", progression: 0.5 });
+ expect(draft?.target.html?.css).toBe('[data-post-id="101"]');
+ expect(locateHtmlTarget(document, draft!.target)?.startContainer.parentElement).toBe(second);
+ range.selectNodeContents(document.querySelector('[data-post-id="101"] blockquote p')!);
+ const quoted = htmlSelectionDraft({ document, range, href: "post.html", progression: 0.5 });
+ expect(quoted?.target.html?.css).toBe('[data-post-id="101"]');
+ expect(
+ locateHtmlTarget(document, quoted!.target)?.startContainer.parentElement?.closest(
+ '[data-post-id="101"]',
+ ),
+ ).not.toBeNull();
+ });
it("captures quotation context and a stable CSS selector", () => {
document.body.innerHTML = `Before selected passage after.
`;
const text = document.querySelector("p")?.firstChild;
diff --git a/packages/renderer-html/src/html-range.ts b/packages/renderer-html/src/html-range.ts
index a6a35e68..b8a8012b 100644
--- a/packages/renderer-html/src/html-range.ts
+++ b/packages/renderer-html/src/html-range.ts
@@ -127,6 +127,10 @@ function cssSelector(element: Element, body: HTMLElement): string {
if (element === body) {
return "body";
}
+ const post = postSelector(element);
+ if (post) {
+ return post;
+ }
if (element.id) {
return `#${escapeIdentifier(element.id)}`;
}
@@ -147,6 +151,20 @@ function cssSelector(element: Element, body: HTMLElement): string {
return segments[0]?.startsWith("#") ? segments.join(" > ") : `body > ${segments.join(" > ")}`;
}
+function postSelector(element: Element): string | null {
+ let part = element.closest("[data-post-id]");
+ // Quoted context belongs to the containing post, even when the quote appears twice.
+ for (
+ let parent = part?.parentElement?.closest("[data-post-id]");
+ parent;
+ parent = parent.parentElement?.closest("[data-post-id]")
+ ) {
+ part = parent;
+ }
+ const id = part?.getAttribute("data-post-id");
+ return id ? `[data-post-id=${JSON.stringify(id)}]` : null;
+}
+
function escapeIdentifier(value: string): string {
return value.replace(
/[^a-zA-Z0-9_-]/gu,
diff --git a/packages/web-capture/src/index.ts b/packages/web-capture/src/index.ts
index 7a7bcf62..5b88788e 100644
--- a/packages/web-capture/src/index.ts
+++ b/packages/web-capture/src/index.ts
@@ -1,4 +1,6 @@
export * from "./web-capture.js";
+export * from "./social/index.js";
+export { postHtml, postTitle, postCitation, socialPostImport } from "./social/import.js";
export * from "./scholarly-metadata.js";
export * from "./doi.js";
export * from "./page-citation.js";
diff --git a/packages/web-capture/src/social/api.test.ts b/packages/web-capture/src/social/api.test.ts
new file mode 100644
index 00000000..ac7d367a
--- /dev/null
+++ b/packages/web-capture/src/social/api.test.ts
@@ -0,0 +1,20 @@
+import { expect, it } from "vitest";
+
+import { boundedSignal } from "./api.js";
+
+it("keeps a request deadline even when the caller supplies a cancellation signal", async () => {
+ const caller = new AbortController();
+ const signal = boundedSignal(caller.signal, 5);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(signal.aborted).toBe(true);
+ expect(caller.signal.aborted).toBe(false);
+});
+
+it("preserves caller cancellation without waiting for the request deadline", () => {
+ const caller = new AbortController();
+ const signal = boundedSignal(caller.signal, 12_000);
+ const reason = new Error("Cancelled");
+ caller.abort(reason);
+ expect(signal.aborted).toBe(true);
+ expect(signal.reason).toBe(reason);
+});
diff --git a/packages/web-capture/src/social/api.ts b/packages/web-capture/src/social/api.ts
new file mode 100644
index 00000000..77690c03
--- /dev/null
+++ b/packages/web-capture/src/social/api.ts
@@ -0,0 +1,181 @@
+import { postAddress, type PostAddress } from "./identity.js";
+
+import type { PostMedia, PostPart } from "@mdbase-reader/core";
+
+export interface SocialFetchOptions {
+ readonly fetch?: typeof fetch;
+ readonly signal?: AbortSignal;
+}
+
+/** Public, credential-free, bounded requests only. No session or undocumented endpoints. */
+export async function publicJson(url: string, options: SocialFetchOptions): Promise {
+ const response = await (options.fetch ?? fetch)(url, {
+ credentials: "omit",
+ redirect: "error",
+ headers: { accept: "application/json" },
+ signal: boundedSignal(options.signal, 12_000),
+ });
+ if (!response.ok) {
+ throw new Error(`Post API returned HTTP ${String(response.status)}.`);
+ }
+ if (!response.body) {
+ throw new Error("Post API returned no content.");
+ }
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let length = 0;
+ for (let next = await reader.read(); !next.done; next = await reader.read()) {
+ length += next.value.length;
+ if (length > 2 * 1024 * 1024) {
+ await reader.cancel();
+ throw new Error("Post API response is too large.");
+ }
+ chunks.push(next.value);
+ }
+ const bytes = new Uint8Array(length);
+ let offset = 0;
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset);
+ offset += chunk.length;
+ }
+ return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
+}
+
+export function boundedSignal(signal: AbortSignal | undefined, milliseconds: number): AbortSignal {
+ const deadline = AbortSignal.timeout(milliseconds);
+ return signal ? AbortSignal.any([signal, deadline]) : deadline;
+}
+
+export async function blueskyIdentity(
+ address: PostAddress,
+ options: SocialFetchOptions,
+): Promise {
+ const actor = address.id.split("/")[2] ?? "";
+ if (actor.startsWith("did:")) {
+ return address;
+ }
+ const result = record(
+ await publicJson(
+ `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(actor)}`,
+ options,
+ ),
+ );
+ const did = text(result["did"]);
+ const key = address.id.split("/").at(-1);
+ const resolved =
+ did.startsWith("did:") && key ? postAddress(`at://${did}/app.bsky.feed.post/${key}`) : null;
+ if (!resolved) {
+ throw new Error("Bluesky could not resolve the author's identity.");
+ }
+ return resolved;
+}
+
+export function blueskyPart(value: unknown, depth = 0): PostPart {
+ const post = record(value);
+ const address = postAddress(text(post["uri"]));
+ if (!address?.id.startsWith("at://did:")) {
+ throw new Error("Bluesky returned an invalid post identity.");
+ }
+ const author = record(post["author"]);
+ const content = record(post["record"]);
+ const embed = record(post["embed"]);
+ const images = array(embed["images"] ?? record(embed["media"])["images"]);
+ const video = text(embed["playlist"] ?? record(embed["media"])["playlist"]);
+ const media: PostMedia[] = images.flatMap((value) => {
+ const image = record(value);
+ const url = https(text(image["fullsize"]));
+ return url ? [{ kind: "image", url, alt: text(image["alt"]) }] : [];
+ });
+ if (https(video)) {
+ media.push({ kind: "video", url: video, alt: text(embed["alt"]) });
+ }
+ const quote = record(embed["record"]);
+ const quoted = quote["uri"] ? quote : record(quote["record"]);
+ const parent = text(record(record(content["reply"])["parent"])["uri"]);
+ return {
+ platform: "bluesky",
+ id: address.id,
+ url: address.url,
+ author: {
+ id: text(author["did"]),
+ handle: text(author["handle"]),
+ name: text(author["displayName"]) || text(author["handle"]),
+ },
+ published: text(content["createdAt"]),
+ text: text(content["text"]),
+ media,
+ ...(depth === 0 && quoted["uri"]
+ ? { quoted: blueskyPart({ ...quoted, record: quoted["value"] ?? quoted["record"] }, 1) }
+ : {}),
+ ...(parent ? { inReplyTo: parent } : {}),
+ };
+}
+
+export function mastodonPart(value: unknown, document: Document, depth = 0): PostPart {
+ const post = record(value);
+ const uri = https(text(post["uri"]));
+ if (!uri) {
+ throw new Error("Mastodon returned an invalid status URI.");
+ }
+ const account = record(post["account"]);
+ const parsed = document.implementation.createHTMLDocument("");
+ parsed.body.innerHTML = text(post["content"]);
+ parsed.querySelectorAll("script,style").forEach((element) => element.remove());
+ parsed.querySelectorAll("br").forEach((element) => element.replaceWith("\n"));
+ parsed.querySelectorAll("p").forEach((element) => element.append("\n"));
+ const authorUrl = https(text(account["url"]));
+ const handle = text(account["acct"]);
+ const parent = text(post["in_reply_to_id"]);
+ const quote = record(post["quote"])["quoted_status"];
+ return {
+ platform: "mastodon",
+ id: uri,
+ url: uri,
+ author: {
+ id: authorUrl || text(account["id"]),
+ handle: handle.includes("@") ? handle : `${handle}@${new URL(uri).hostname}`,
+ name: text(account["display_name"]) || handle,
+ },
+ published: text(post["created_at"]),
+ text: [text(post["spoiler_text"]), parsed.body.textContent.trim()].filter(Boolean).join("\n\n"),
+ media: array(post["media_attachments"]).flatMap((value) => {
+ const media = record(value);
+ const url = https(text(media["url"]));
+ return url
+ ? [
+ {
+ url,
+ alt: text(media["description"]),
+ kind: attachmentKind(text(media["type"])),
+ },
+ ]
+ : [];
+ }),
+ ...(parent ? { inReplyTo: parent } : {}),
+ ...(quote && depth === 0 ? { quoted: mastodonPart(quote, document, 1) } : {}),
+ };
+}
+
+function attachmentKind(type: string): PostMedia["kind"] {
+ return type === "image" || type === "audio" ? type : "video";
+}
+
+export function record(value: unknown): Record {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+export function array(value: unknown): unknown[] {
+ return Array.isArray(value) ? value : [];
+}
+export function text(value: unknown): string {
+ return typeof value === "string" ? value : "";
+}
+export function https(value: string): string {
+ try {
+ const url = new URL(value);
+ return url.protocol === "https:" && !url.username && !url.password ? url.href : "";
+ } catch {
+ return "";
+ }
+}
diff --git a/packages/web-capture/src/social/bluesky.ts b/packages/web-capture/src/social/bluesky.ts
new file mode 100644
index 00000000..2b0b94c7
--- /dev/null
+++ b/packages/web-capture/src/social/bluesky.ts
@@ -0,0 +1,150 @@
+import {
+ array,
+ blueskyIdentity,
+ blueskyPart,
+ publicJson,
+ record,
+ type SocialFetchOptions,
+} from "./api.js";
+import { refFor, type DomSelectors, type PostRef } from "./dom.js";
+import { postAddress } from "./identity.js";
+import { domAdapter, domFallback } from "./registry.js";
+import { unrollThread } from "./thread.js";
+
+import type { PostPart } from "@mdbase-reader/core";
+
+export const blueskySelectors: DomSelectors = {
+ post: '[data-testid^="feedItem-by-"], [data-testid="postThreadItem"], [data-testid^="postThreadItem-by-"]',
+ content: '[data-testid="postText"], div[dir="auto"].r-1xnzce8',
+ permalink:
+ 'a[href*="/post/"]:not([data-testid="quotePost"] a):not([href$="/reposted-by"]):not([href$="/quotes"]):not([href$="/liked-by"])',
+ author:
+ 'a[href*="/profile/"]:not([href*="/post/"]):has([dir="auto"]), a[data-testid="profileDisplayName"]',
+ name: '[data-testid="profileDisplayName"], a[href*="/profile/"]:not([href*="/post/"]) [dir="auto"]',
+ quote: '[data-testid="quotePost"]',
+ media: '[data-testid="postImages"] img, img[alt]:not([data-testid="userAvatarImage"] img), video',
+};
+
+export const blueskyAdapter = domAdapter(
+ "bluesky",
+ (value) => {
+ try {
+ return new URL(value).hostname === "bsky.app";
+ } catch {
+ return false;
+ }
+ },
+ () => false,
+ blueskySelectors,
+ async (ref, document, options = {}) => {
+ try {
+ return await blueskyThread(ref.url, options);
+ } catch (reason) {
+ options.signal?.throwIfAborted();
+ const fallback = domFallback(blueskyAdapter, ref, document, blueskySelectors);
+ // A handle is not an identity. Even the DOM fallback must resolve it to a DID.
+ const provisional = postAddress(ref.url);
+ if (!provisional) {
+ throw reason;
+ }
+ const address = await blueskyIdentity(provisional, options);
+ const part = {
+ ...fallback,
+ id: address.id,
+ url: address.url,
+ author: { ...fallback.author, id: address.id.split("/")[2] ?? "" },
+ };
+ if (!part.text) {
+ throw reason;
+ }
+ return unrollThread(part, [], true);
+ }
+ },
+ (ref, options = {}) => {
+ const address = postAddress(ref.url);
+ if (!address) {
+ throw new Error("Invalid Bluesky post URL.");
+ }
+ return blueskyIdentity(address, options);
+ },
+ blueskyRef,
+);
+
+function blueskyRef(element: Element): PostRef | null {
+ const ordinary = refFor(element, "bluesky", blueskySelectors);
+ if (ordinary) {
+ return ordinary;
+ }
+ // The expanded post links only to its statistics, not back to its own permalink.
+ if (!element.querySelector('[data-testid="repostCount-expanded"]')) {
+ return null;
+ }
+ const address = postAddress(element.ownerDocument.URL);
+ if (address?.platform !== "bluesky") {
+ return null;
+ }
+ const rect = element.getBoundingClientRect();
+ return {
+ ...address,
+ author: element.querySelector(blueskySelectors.name)?.textContent.trim() ?? "",
+ snippet:
+ element.querySelector(blueskySelectors.content)?.textContent.trim().slice(0, 180) ?? "",
+ top: rect.top,
+ bottom: rect.bottom,
+ };
+}
+
+export async function blueskyThread(
+ url: string,
+ options: SocialFetchOptions,
+ rootFetched = false,
+): Promise> {
+ const address = postAddress(url);
+ if (address?.platform !== "bluesky") {
+ throw new Error("Not a Bluesky post URL.");
+ }
+ const identity = await blueskyIdentity(address, options);
+ const result = record(
+ await publicJson(
+ `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(identity.id)}&depth=100&parentHeight=100`,
+ options,
+ ),
+ );
+ const thread = record(result["thread"]);
+ const selected = blueskyPart(thread["post"]);
+ const parts: PostPart[] = [];
+ let partial = false;
+ const visit = (value: unknown, depth: number): void => {
+ const node = record(value);
+ if (!node["post"] || depth > 100) {
+ partial = true;
+ return;
+ }
+ const part = blueskyPart(node["post"]);
+ if (parts.some((item) => item.id === part.id)) {
+ return;
+ }
+ parts.push(part);
+ if (node["parent"]) {
+ visit(node["parent"], depth + 1);
+ }
+ for (const reply of array(node["replies"])) {
+ const author = record(record(record(reply)["post"])["author"]);
+ if (author["did"] === selected.author.id) {
+ visit(reply, depth + 1);
+ }
+ }
+ if (depth === 100) {
+ partial = true;
+ }
+ };
+ visit(thread, 0);
+ const unrolled = unrollThread(selected, parts, partial);
+ if (unrolled.id !== selected.id && !rootFetched) {
+ return blueskyThread(unrolled.url, options, true).catch(() => {
+ options.signal?.throwIfAborted();
+ return { ...unrolled, threadPartial: true };
+ });
+ }
+ return unrolled;
+}
diff --git a/packages/web-capture/src/social/dom.ts b/packages/web-capture/src/social/dom.ts
new file mode 100644
index 00000000..32a4d24a
--- /dev/null
+++ b/packages/web-capture/src/social/dom.ts
@@ -0,0 +1,162 @@
+import { postAddress, type PostAddress } from "./identity.js";
+
+import type { PostMedia, PostPart, SocialPlatform } from "@mdbase-reader/core";
+
+export interface PostRef {
+ readonly platform: SocialPlatform;
+ readonly id: string;
+ readonly url: string;
+ readonly sourceUrl?: string;
+ readonly author: string;
+ readonly snippet: string;
+ readonly published?: string;
+ readonly top: number;
+ readonly bottom: number;
+}
+
+export interface DomSelectors {
+ readonly post: string;
+ readonly content: string;
+ readonly permalink: string;
+ readonly author: string;
+ readonly name: string;
+ readonly quote: string;
+ readonly media: string;
+}
+
+export function refFor(
+ element: Element,
+ platform: SocialPlatform,
+ selectors: DomSelectors,
+): PostRef | null {
+ const address = elementAddress(element, platform, selectors);
+ if (address?.platform !== platform) {
+ return null;
+ }
+ const rect = element.getBoundingClientRect();
+ const published =
+ element.querySelector("time")?.getAttribute("datetime") ??
+ element.getAttribute("data-reader-post-published");
+ return {
+ platform,
+ id: address.id,
+ url: address.url,
+ author:
+ element.querySelector(selectors.author)?.textContent.trim() ??
+ element.getAttribute("data-reader-post-author") ??
+ "",
+ snippet: (
+ element.querySelector(selectors.content)?.textContent.trim() ??
+ element.getAttribute("data-reader-post-text") ??
+ ""
+ ).slice(0, 180),
+ ...(published ? { published } : {}),
+ top: rect.top,
+ bottom: rect.bottom,
+ };
+}
+
+function elementAddress(
+ element: Element,
+ platform: SocialPlatform,
+ selectors: DomSelectors,
+): PostAddress | null {
+ const href =
+ element.querySelector(selectors.permalink)?.getAttribute("href") ??
+ element.getAttribute("data-reader-post-url");
+ if (!href) {
+ return null;
+ }
+ const doc = element.ownerDocument;
+ const fallback =
+ platform === "bluesky"
+ ? "https://bsky.app"
+ : platform === "x"
+ ? "https://x.com"
+ : "https://example.invalid";
+ const base = doc.URL.startsWith("https://")
+ ? doc.URL
+ : (doc.querySelector('meta[property="og:url"]')?.content ?? fallback);
+ try {
+ return postAddress(new URL(href, base).href);
+ } catch {
+ return null;
+ }
+}
+
+export function domPart(
+ element: Element,
+ ref: PostRef,
+ selectors: DomSelectors,
+ depth = 0,
+): PostPart {
+ const quoteElement = element.querySelector(selectors.quote);
+ const quotedRef =
+ quoteElement && depth === 0 ? refFor(quoteElement, ref.platform, selectors) : null;
+ const reply = element.getAttribute("data-in-reply-to");
+ return {
+ platform: ref.platform,
+ id: ref.id,
+ url: ref.url,
+ author: domAuthor(element, ref, selectors),
+ ...(ref.published ? { published: ref.published } : {}),
+ text:
+ element.querySelector(selectors.content)?.textContent.trim() ??
+ element.getAttribute("data-reader-post-text") ??
+ "",
+ media: domMedia(element, selectors, quoteElement),
+ ...(quotedRef && quoteElement
+ ? { quoted: domPart(quoteElement, quotedRef, selectors, 1) }
+ : {}),
+ ...(reply ? { inReplyTo: reply } : {}),
+ };
+}
+
+function domAuthor(element: Element, ref: PostRef, selectors: DomSelectors): PostPart["author"] {
+ const node = element.querySelector(selectors.author);
+ const link = node?.closest("a") ?? node?.querySelector("a");
+ const href = link?.getAttribute("href");
+ const handle =
+ ref.platform === "mastodon"
+ ? (node?.textContent.trim() ?? ref.author).replace(/^@/u, "")
+ : (href?.split("/").filter(Boolean).at(-1) ?? ref.author);
+ return {
+ id: href ?? handle,
+ handle,
+ name: element.querySelector(selectors.name)?.textContent.trim() ?? ref.author,
+ };
+}
+
+function domMedia(element: Element, selectors: DomSelectors, quote: Element | null): PostMedia[] {
+ return [...element.querySelectorAll(selectors.media)]
+ .filter((node) => !quote?.contains(node))
+ .flatMap((node) => {
+ const source = node.getAttribute("src") ?? node.querySelector("source")?.getAttribute("src");
+ const video = node.tagName === "VIDEO";
+ const poster = video ? node.getAttribute("poster") : null;
+ const url = source?.startsWith("https://") ? source : poster;
+ if (!url?.startsWith("https://")) {
+ return [];
+ }
+ const kind = video && url === source ? ("video" as const) : ("image" as const);
+ const alt =
+ node.getAttribute("alt") ??
+ node.getAttribute("aria-label") ??
+ (poster ? "Video poster" : "");
+ return [{ url, alt, kind }];
+ });
+}
+
+export function nearestPost(items: readonly PostRef[], viewportHeight: number): string | null {
+ const middle = viewportHeight / 2;
+ return (
+ [...items]
+ .filter((item) => item.bottom > 0 && item.top < viewportHeight)
+ .sort(
+ (a, b) =>
+ Math.abs((a.top + a.bottom) / 2 - middle) - Math.abs((b.top + b.bottom) / 2 - middle),
+ )[0]?.id ??
+ items[0]?.id ??
+ null
+ );
+}
diff --git a/packages/web-capture/src/social/fixtures/README.md b/packages/web-capture/src/social/fixtures/README.md
new file mode 100644
index 00000000..ea00bfaf
--- /dev/null
+++ b/packages/web-capture/src/social/fixtures/README.md
@@ -0,0 +1,11 @@
+# Social DOM fixtures
+
+Minimal, fictionalised layout fixtures. All authors, IDs, text and media URLs are
+synthetic; no account sessions, page scripts or personal data are included.
+
+Bluesky's profile and expanded-post shapes and Mastodon's status classes were
+checked in an unauthenticated LAB Chromium browser on 2026-10-11. The Bluesky
+expanded item deliberately has no own permalink and no `postText` test ID.
+X's fixture represents its `tweet` / `User-Name` / `tweetText` DOM contract;
+there was no logged-in X browser. Its explicit reply attributes model evidence
+supplied by a page, not an assumption that every X timeline supplies those edges.
diff --git a/packages/web-capture/src/social/fixtures/bluesky-expanded.html b/packages/web-capture/src/social/fixtures/bluesky-expanded.html
new file mode 100644
index 00000000..f1bf476a
--- /dev/null
+++ b/packages/web-capture/src/social/fixtures/bluesky-expanded.html
@@ -0,0 +1,17 @@
+
+
+
+ Fixture expanded post
+
+
+
+
+
+
+
diff --git a/packages/web-capture/src/social/fixtures/bluesky.html b/packages/web-capture/src/social/fixtures/bluesky.html
new file mode 100644
index 00000000..4b5135dd
--- /dev/null
+++ b/packages/web-capture/src/social/fixtures/bluesky.html
@@ -0,0 +1,34 @@
+
+
+
+ Fixture Bluesky profile
+
+
+
+
+
+
+
+
diff --git a/packages/web-capture/src/social/fixtures/mastodon.html b/packages/web-capture/src/social/fixtures/mastodon.html
new file mode 100644
index 00000000..ef0af5a0
--- /dev/null
+++ b/packages/web-capture/src/social/fixtures/mastodon.html
@@ -0,0 +1,34 @@
+
+
+
+
+ Fixture federated timeline
+
+
+
+
+
diff --git a/packages/web-capture/src/social/fixtures/x-conversation.html b/packages/web-capture/src/social/fixtures/x-conversation.html
new file mode 100644
index 00000000..6da28722
--- /dev/null
+++ b/packages/web-capture/src/social/fixtures/x-conversation.html
@@ -0,0 +1,45 @@
+
+
+
+ Fixture X conversation
+
+
+
+
+
+
+ The opening post of the thread.
+
+
+
+
+ The focal post of the conversation.
+
+
+
+
+ The thread continues below the focal post.
+
+
+
+
+ Someone else's reply ends the thread.
+
+
+
+
+ A later reply by the author, after someone else's.
+
+
+
+
diff --git a/packages/web-capture/src/social/fixtures/x.html b/packages/web-capture/src/social/fixtures/x.html
new file mode 100644
index 00000000..fa20ee29
--- /dev/null
+++ b/packages/web-capture/src/social/fixtures/x.html
@@ -0,0 +1,51 @@
+
+
+
+ Fixture X timeline
+
+
+
+
+
+
+
+ The first durable post.
+
+

+
+
+
+
+
Quoted context, not a thread reply.
+
+
+
+
+
+ The second durable post.
+
+
+
+
+ Someone else's reply must stay out.
+
+
+
+
+ An unrelated post by the same author.
+
+
+
+
diff --git a/packages/web-capture/src/social/identity.ts b/packages/web-capture/src/social/identity.ts
new file mode 100644
index 00000000..16df5dd7
--- /dev/null
+++ b/packages/web-capture/src/social/identity.ts
@@ -0,0 +1,53 @@
+import type { SocialPlatform } from "@mdbase-reader/core";
+
+export interface PostAddress {
+ readonly platform: SocialPlatform;
+ readonly id: string;
+ readonly url: string;
+ readonly host: string;
+}
+
+/** Handle and federated-instance addresses are provisional until resolved by the API. */
+export function postAddress(value: string): PostAddress | null {
+ try {
+ if (value.startsWith("at://")) {
+ return atAddress(value);
+ }
+ const url = new URL(value);
+ if (url.protocol !== "https:" || url.username || url.password) {
+ return null;
+ }
+ const host = url.hostname.toLowerCase();
+ if (/^(?:(?:www|mobile)\.)?(?:x|twitter)\.com$/u.test(host)) {
+ const id =
+ /^\/(?:[^/]+\/status|i\/web\/status|i\/status)\/(\d+)(?:\/(?:photo|video)\/\d+)?\/?$/u.exec(
+ url.pathname,
+ )?.[1];
+ return id ? { platform: "x", id, url: `https://x.com/i/status/${id}`, host: "x.com" } : null;
+ }
+ if (host === "bsky.app") {
+ const match = /^\/profile\/([^/]+)\/post\/([^/]+)\/?$/u.exec(url.pathname);
+ return match?.[1] && match[2] ? blueskyAddress(decodeURIComponent(match[1]), match[2]) : null;
+ }
+ const id = /^\/(?:@[^/]+|users\/[^/]+\/statuses)\/(\d+)\/?$/u.exec(url.pathname)?.[1];
+ return id
+ ? { platform: "mastodon", id, url: `${url.origin}${url.pathname.replace(/\/$/u, "")}`, host }
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+function atAddress(value: string): PostAddress | null {
+ const match = /^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/?#]+)$/u.exec(value);
+ return match?.[1] && match[2] ? blueskyAddress(match[1], match[2]) : null;
+}
+
+function blueskyAddress(actor: string, key: string): PostAddress {
+ return {
+ platform: "bluesky",
+ id: `at://${actor}/app.bsky.feed.post/${key}`,
+ url: `https://bsky.app/profile/${actor}/post/${key}`,
+ host: "bsky.app",
+ };
+}
diff --git a/packages/web-capture/src/social/import.ts b/packages/web-capture/src/social/import.ts
new file mode 100644
index 00000000..ab253788
--- /dev/null
+++ b/packages/web-capture/src/social/import.ts
@@ -0,0 +1,194 @@
+import { dateTime, type PostMedia, type PostPart, type SocialPost } from "@mdbase-reader/core";
+
+import { cslDate, type CitationDraft } from "../csl-values.js";
+
+import { boundedSignal, type SocialFetchOptions } from "./api.js";
+
+import type { CapturedWebDocument, WebCaptureImport } from "../web-capture.js";
+
+export function postCitation(post: SocialPost): CitationDraft {
+ return {
+ type: "post",
+ title: postTitle(post),
+ URL: post.url,
+ author: [{ literal: post.author.name || post.author.handle }],
+ "container-title":
+ post.platform === "x" ? "X" : post.platform === "bluesky" ? "Bluesky" : "Mastodon",
+ ...(post.published ? { issued: cslDate(post.published) ?? { literal: post.published } } : {}),
+ };
+}
+
+export function postTitle(post: SocialPost): string {
+ return `${post.author.name || post.author.handle}: ${post.text.replace(/\s+/gu, " ").slice(0, 120) || "Post"}`;
+}
+
+/** Only the chosen post/thread is archived; never the surrounding feed or account DOM. */
+export function postHtml(post: SocialPost): string {
+ return `${escape(postTitle(post))}${post.parts.map(partHtml).join("")}${post.threadPartial ? "" : ""}`;
+}
+
+function partHtml(part: PostPart): string {
+ return `${part.text
+ .split(/\n\n+/u)
+ .map((paragraph) => `
${escape(paragraph).replaceAll("\n", "
")}
`)
+ .join(
+ "",
+ )}
${part.media.map(mediaHtml).join("")}${part.quoted ? `${partHtml(part.quoted)}
` : ""}`;
+}
+
+/** The archive keeps every original media URL; the readable copy drops external links. */
+function mediaHtml(media: PostMedia): string {
+ const caption = escape(mediaCaption(media));
+ return media.kind === "image"
+ ? `
${caption}`
+ : `${caption}`;
+}
+
+function mediaCaption(media: PostMedia): string {
+ if (media.alt) {
+ return media.alt;
+ }
+ return media.kind === "image"
+ ? "Image"
+ : `${media.kind === "audio" ? "Audio" : "Video"} (original URL kept in the archived copy)`;
+}
+
+export async function socialPostImport(
+ post: SocialPost,
+ capture: Pick,
+ options: SocialFetchOptions = {},
+): Promise {
+ const { prepareHtmlDocument } = await import("@mdbase-reader/renderer-html");
+ const archive = postHtml(post);
+ const parsed = new DOMParser().parseFromString(archive, "text/html");
+ // Reading copies are self-contained (the renderer allows only data: images), so images
+ // are embedded, but scaled and re-encoded first to keep the copy light to sync and render.
+ let remaining = mediaBudget.total;
+ for (const [index, image] of [...parsed.querySelectorAll("img")].entries()) {
+ try {
+ if (index >= mediaBudget.images || remaining <= 0) {
+ throw new Error("Media budget reached");
+ }
+ const data = await embeddedImage(image.src, Math.min(remaining, mediaBudget.image), options);
+ remaining -= data.size;
+ image.src = data.url;
+ } catch {
+ options.signal?.throwIfAborted();
+ const caption = image.parentElement?.querySelector("figcaption");
+ if (caption) {
+ caption.append(" — image unavailable offline; the archived copy keeps its original URL.");
+ }
+ image.remove();
+ }
+ }
+ const stem = `${post.platform}-${
+ post.id
+ .split("/")
+ .at(-1)
+ ?.replace(/[^a-zA-Z0-9_-]/gu, "") ?? "post"
+ }`;
+ return {
+ name: `${stem}.readable.html`,
+ title: postTitle(post),
+ bytes: new TextEncoder().encode(prepareHtmlDocument(parsed.documentElement.outerHTML)),
+ archive: { name: `${stem}.archive.html`, bytes: new TextEncoder().encode(archive) },
+ capture: {
+ submittedUrl: post.url,
+ canonicalUrl: post.url,
+ retrievedAt: dateTime(capture.retrievedAt),
+ },
+ metadata: {
+ socialPost: post,
+ authors: [post.author.name || post.author.handle],
+ ...(post.published ? { published: post.published } : {}),
+ site: post.platform,
+ description: post.text.slice(0, 2_000),
+ },
+ scholarly: { citation: postCitation(post) },
+ };
+}
+
+const mediaBudget = {
+ images: 20,
+ /** Largest original accepted for scaling. */
+ download: 10 * 1024 * 1024,
+ /** Largest embedded image, after scaling. */
+ image: 1536 * 1024,
+ total: 6 * 1024 * 1024,
+};
+const maximumDimension = 1600;
+
+async function embeddedImage(
+ url: string,
+ maximum: number,
+ options: SocialFetchOptions,
+): Promise<{ url: string; size: number }> {
+ const original = await downloadImage(url, options);
+ const image = await scaledImage(original).catch(() => original);
+ if (image.size > maximum) {
+ throw new Error("Image too large");
+ }
+ const bytes = new Uint8Array(await image.arrayBuffer());
+ let binary = "";
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
+ }
+ return { url: `data:${image.type};base64,${btoa(binary)}`, size: bytes.length };
+}
+
+async function downloadImage(url: string, options: SocialFetchOptions): Promise {
+ const response = await (options.fetch ?? fetch)(url, {
+ credentials: "omit",
+ redirect: "error",
+ signal: boundedSignal(options.signal, 8_000),
+ });
+ const type = response.headers.get("content-type")?.split(";")[0];
+ if (!response.ok || !type || !/^image\/(png|jpeg|webp|gif|avif)$/u.test(type) || !response.body) {
+ throw new Error("Image unavailable");
+ }
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let size = 0;
+ for (let next = await reader.read(); !next.done; next = await reader.read()) {
+ size += next.value.length;
+ if (size > mediaBudget.download) {
+ await reader.cancel();
+ throw new Error("Image too large");
+ }
+ chunks.push(next.value);
+ }
+ return new Blob(chunks, { type });
+}
+
+/** Large images are scaled down and re-encoded; animated GIFs are kept so they still play. */
+async function scaledImage(image: Blob): Promise {
+ if (
+ image.type === "image/gif" ||
+ typeof createImageBitmap !== "function" ||
+ typeof OffscreenCanvas !== "function"
+ ) {
+ return image;
+ }
+ const bitmap = await createImageBitmap(image);
+ try {
+ const scale = Math.min(1, maximumDimension / Math.max(bitmap.width, bitmap.height));
+ const canvas = new OffscreenCanvas(
+ Math.max(1, Math.round(bitmap.width * scale)),
+ Math.max(1, Math.round(bitmap.height * scale)),
+ );
+ canvas.getContext("2d")?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
+ const encoded = await canvas.convertToBlob({ type: "image/webp", quality: 0.82 });
+ // Browsers without WebP encoding return PNG, which is rarely smaller than the original.
+ return encoded.type === "image/webp" && encoded.size < image.size ? encoded : image;
+ } finally {
+ bitmap.close();
+ }
+}
+
+function escape(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
diff --git a/packages/web-capture/src/social/index.ts b/packages/web-capture/src/social/index.ts
new file mode 100644
index 00000000..b6281250
--- /dev/null
+++ b/packages/web-capture/src/social/index.ts
@@ -0,0 +1,42 @@
+import { blueskyAdapter, blueskyThread } from "./bluesky.js";
+import { postAddress } from "./identity.js";
+import { mastodonAdapter, mastodonThread } from "./mastodon.js";
+import { xAdapter } from "./x.js";
+
+import type { SocialFetchOptions } from "./api.js";
+import type { SiteAdapter } from "./registry.js";
+import type { SocialPost } from "@mdbase-reader/core";
+
+export { postAddress } from "./identity.js";
+export { nearestPost } from "./dom.js";
+export { unrollThread } from "./thread.js";
+export type { PostRef } from "./dom.js";
+export type { SiteAdapter, PostDetection } from "./registry.js";
+export type { SocialFetchOptions } from "./api.js";
+
+export const siteAdapters = [xAdapter, blueskyAdapter, mastodonAdapter] as const;
+
+export function adapterFor(document: Document, url = document.URL): SiteAdapter | null {
+ return siteAdapters.find((adapter) => adapter.detect(document, url) !== null) ?? null;
+}
+
+/** A URL-only Mastodon probe is confirmed by the status API, not assumed from its host. */
+export function postFromUrl(
+ url: string,
+ document: Document,
+ options: SocialFetchOptions = {},
+): Promise {
+ const address = postAddress(url);
+ if (!address) {
+ return Promise.resolve(null);
+ }
+ if (address.platform === "x") {
+ throw new Error(
+ "Save X posts from their live page using the mdbase Reader extension. Reader cannot retrieve X posts without that page.",
+ );
+ }
+ if (address.platform === "bluesky") {
+ return blueskyThread(url, options);
+ }
+ return mastodonThread(url, document, options);
+}
diff --git a/packages/web-capture/src/social/mastodon-profile.ts b/packages/web-capture/src/social/mastodon-profile.ts
new file mode 100644
index 00000000..78d0317e
--- /dev/null
+++ b/packages/web-capture/src/social/mastodon-profile.ts
@@ -0,0 +1,104 @@
+import { array, publicJson, record, text } from "./api.js";
+
+import type { SocialFetchOptions } from "./api.js";
+
+const pending = new WeakMap>();
+
+/** Current Mastodon anonymous profiles render text-only placeholders with no permalink.
+ * Match those loaded placeholders to the official account API, never to unseen API items. */
+export function prepareMastodonProfile(
+ document: Document,
+ options: SocialFetchOptions = {},
+): Promise {
+ const wrappers = [
+ ...document.querySelectorAll(".status__wrapper:not([data-reader-post-url])"),
+ ].filter(
+ (element) =>
+ element.children.length === 2 &&
+ [...element.children].every((child) => child.tagName === "SPAN"),
+ );
+ const account = /^\/@([^/]+)\/?$/u.exec(document.location.pathname)?.[1];
+ if (!wrappers.length || !account) {
+ return Promise.resolve();
+ }
+ const existing = pending.get(document);
+ if (existing) {
+ return existing;
+ }
+ const work = hydrate(
+ document.location.origin,
+ decodeURIComponent(account),
+ wrappers,
+ options,
+ ).finally(() => pending.delete(document));
+ pending.set(document, work);
+ return work;
+}
+
+async function hydrate(
+ origin: string,
+ account: string,
+ wrappers: readonly Element[],
+ options: SocialFetchOptions,
+): Promise {
+ const result = record(
+ await publicJson(
+ `${origin}/api/v1/accounts/lookup?acct=${encodeURIComponent(account)}`,
+ options,
+ ),
+ );
+ const id = text(result["id"]);
+ if (!/^\d+$/u.test(id)) {
+ throw new Error("Mastodon could not resolve this profile.");
+ }
+ const statuses: Record[] = [];
+ let maximum = "";
+ for (let page = 0; page < 3; page++) {
+ const batch = array(
+ await publicJson(
+ `${origin}/api/v1/accounts/${id}/statuses?limit=40${maximum ? `&max_id=${maximum}` : ""}`,
+ options,
+ ),
+ ).map(record);
+ statuses.push(...batch);
+ if (batch.length < 40 || statuses.length >= wrappers.length + 40) {
+ break;
+ }
+ maximum = text(batch.at(-1)?.["id"]);
+ if (!/^\d+$/u.test(maximum)) {
+ break;
+ }
+ }
+ for (const wrapper of wrappers) {
+ const author = wrapper.children[0]?.textContent.trim();
+ const content = wrapper.children[1]?.textContent.trim();
+ const matches = statuses.filter(
+ (status) =>
+ text(status["content"]).trim() === content &&
+ text(record(status["account"])["display_name"]).trim() === author,
+ );
+ if (matches.length !== 1) {
+ continue;
+ }
+ const status = matches[0];
+ if (!status) {
+ continue;
+ }
+ const statusId = text(status["id"]);
+ const acct = text(record(status["account"])["acct"]);
+ if (!/^\d+$/u.test(statusId) || !acct) {
+ continue;
+ }
+ wrapper.setAttribute(
+ "data-reader-post-url",
+ `${origin}/@${encodeURIComponent(acct)}/${statusId}`,
+ );
+ wrapper.setAttribute("data-uri", text(status["uri"]));
+ const parsed = wrapper.ownerDocument.implementation.createHTMLDocument("");
+ parsed.body.innerHTML = text(status["content"]);
+ parsed.querySelectorAll("script,style").forEach((element) => element.remove());
+ wrapper.setAttribute("data-reader-post-text", parsed.body.textContent);
+ wrapper.setAttribute("data-reader-post-author", acct);
+ wrapper.setAttribute("data-reader-post-published", text(status["created_at"]));
+ }
+}
diff --git a/packages/web-capture/src/social/mastodon.ts b/packages/web-capture/src/social/mastodon.ts
new file mode 100644
index 00000000..a6e763d6
--- /dev/null
+++ b/packages/web-capture/src/social/mastodon.ts
@@ -0,0 +1,116 @@
+import { array, mastodonPart, publicJson, record, text, type SocialFetchOptions } from "./api.js";
+import { postAddress } from "./identity.js";
+import { prepareMastodonProfile } from "./mastodon-profile.js";
+import { domAdapter, domFallback } from "./registry.js";
+import { unrollThread } from "./thread.js";
+
+import type { DomSelectors } from "./dom.js";
+
+export const mastodonSelectors: DomSelectors = {
+ post: ".status, .detailed-status, .entry:has(.status__content), .status__wrapper[data-reader-post-url]",
+ content: ".status__content, .e-content",
+ permalink: "a.status__relative-time, a.detailed-status__datetime, a.u-url",
+ author: ".display-name__account, a.status__display-name",
+ name: ".display-name__html",
+ quote: ".quoted-status, .status__quote",
+ media: ".media-gallery img, .attachment-list img, .video-player video",
+};
+
+export function mastodonMarker(document: Document): boolean {
+ return Boolean(
+ document.querySelector(
+ 'meta[name="application-name"][content="Mastodon"], #mastodon, .mastodon, .detailed-status, .status__content',
+ ),
+ );
+}
+
+export const mastodonAdapter = domAdapter(
+ "mastodon",
+ () => false,
+ mastodonMarker,
+ mastodonSelectors,
+ async (ref, document, options = {}) => {
+ try {
+ return await mastodonThread(ref.url, document, options);
+ } catch (reason) {
+ options.signal?.throwIfAborted();
+ // Do not guess a remote status's identity from a local instance's numerical ID.
+ const fallback = domFallback(mastodonAdapter, ref, document, mastodonSelectors);
+ const element = mastodonAdapter.element(ref, document);
+ const uri =
+ element?.getAttribute("data-uri") ??
+ element?.querySelector('a[rel="original"]')?.href;
+ if (!uri?.startsWith("https://")) {
+ throw reason;
+ }
+ const part = { ...fallback, id: uri, url: uri };
+ return unrollThread(part, [], true);
+ }
+ },
+ async (ref, options = {}) => {
+ const address = postAddress(ref.url);
+ if (!address) {
+ throw new Error("Invalid Mastodon post URL.");
+ }
+ const status = record(
+ await publicJson(`https://${address.host}/api/v1/statuses/${address.id}`, options),
+ );
+ const uri = text(status["uri"]);
+ if (!uri.startsWith("https://")) {
+ throw new Error("Invalid Mastodon identity.");
+ }
+ return { id: uri, url: uri };
+ },
+);
+
+mastodonAdapter.prepare = prepareMastodonProfile;
+
+export async function mastodonThread(
+ url: string,
+ document: Document,
+ options: SocialFetchOptions,
+ rootFetched = false,
+): Promise> {
+ const address = postAddress(url);
+ if (address?.platform !== "mastodon") {
+ throw new Error("Not a Mastodon status URL.");
+ }
+ const origin = new URL(url).origin;
+ const status = record(await publicJson(`${origin}/api/v1/statuses/${address.id}`, options));
+ const selected = mastodonPart(status, document);
+ const completeness = { partial: false };
+ const context = await publicJson(
+ `${origin}/api/v1/statuses/${address.id}/context`,
+ options,
+ ).catch(() => {
+ options.signal?.throwIfAborted();
+ completeness.partial = true;
+ return {};
+ });
+ const statuses = [
+ status,
+ ...array(record(context)["ancestors"]),
+ ...array(record(context)["descendants"]),
+ ].map(record);
+ const identities = new Map(statuses.map((item) => [text(item["id"]), text(item["uri"])]));
+ const parts = statuses.map((item) => {
+ const part = mastodonPart(item, document);
+ return {
+ ...part,
+ ...(part.inReplyTo ? { inReplyTo: identities.get(part.inReplyTo) ?? part.inReplyTo } : {}),
+ };
+ });
+ // Mastodon's context endpoint can be server-capped. Preserve this uncertainty.
+ const unrolled = unrollThread(
+ parts.find((part) => part.id === selected.id) ?? selected,
+ parts,
+ completeness.partial || statuses.length >= 60,
+ );
+ if (unrolled.id !== selected.id && !rootFetched) {
+ return mastodonThread(unrolled.url, document, options, true).catch(() => {
+ options.signal?.throwIfAborted();
+ return { ...unrolled, threadPartial: true };
+ });
+ }
+ return unrolled;
+}
diff --git a/packages/web-capture/src/social/registry.ts b/packages/web-capture/src/social/registry.ts
new file mode 100644
index 00000000..77c88fc7
--- /dev/null
+++ b/packages/web-capture/src/social/registry.ts
@@ -0,0 +1,97 @@
+import { domPart, refFor, type DomSelectors, type PostRef } from "./dom.js";
+import { postAddress } from "./identity.js";
+import { unrollThread } from "./thread.js";
+
+import type { SocialFetchOptions } from "./api.js";
+import type { SocialPost } from "@mdbase-reader/core";
+
+export type PostDetection = "single" | "multiple" | null;
+
+/** A loaded post and its element, found in one pass over the page. */
+export interface PostEntry {
+ readonly ref: PostRef;
+ readonly element: Element;
+}
+
+export interface SiteAdapter {
+ readonly id: PostRef["platform"];
+ matches(url: string): boolean;
+ prepare(document: Document, options?: SocialFetchOptions): Promise;
+ identity(ref: PostRef, options?: SocialFetchOptions): Promise<{ id: string; url: string }>;
+ detect(document: Document, url?: string): PostDetection;
+ /** Every loaded post with its element. Prefer this to `items` plus `element` per post. */
+ entries(document: Document): PostEntry[];
+ items(document: Document): PostRef[];
+ resolve(element: Element): PostRef | null;
+ element(ref: PostRef, document: Document): Element | null;
+ extract(ref: PostRef, document: Document, options?: SocialFetchOptions): Promise;
+}
+
+export function domAdapter(
+ id: SiteAdapter["id"],
+ matches: SiteAdapter["matches"],
+ marker: (document: Document) => boolean,
+ selectors: DomSelectors,
+ extract?: SiteAdapter["extract"],
+ identity?: SiteAdapter["identity"],
+ refOf: (element: Element) => PostRef | null = (element) => refFor(element, id, selectors),
+): SiteAdapter {
+ const entries = (document: Document): PostEntry[] => {
+ const seen = new Set();
+ return [...document.querySelectorAll(selectors.post)].flatMap((element) => {
+ const ref = refOf(element);
+ if (!ref || seen.has(ref.id)) {
+ return [];
+ }
+ seen.add(ref.id);
+ return [{ ref, element }];
+ });
+ };
+ const adapter: SiteAdapter = {
+ id,
+ matches,
+ prepare: () => Promise.resolve(),
+ identity: identity ?? ((ref) => Promise.resolve({ id: ref.id, url: ref.url })),
+ detect: (document, url = document.URL) => {
+ if (!matches(url) && !marker(document)) {
+ return null;
+ }
+ return postAddress(url)?.platform === id ? "single" : "multiple";
+ },
+ entries,
+ items: (document) => entries(document).map(({ ref }) => ref),
+ resolve: (element) => {
+ const post = element.closest(selectors.post);
+ return post ? refOf(post) : null;
+ },
+ element: (ref, document) =>
+ entries(document).find((entry) => entry.ref.id === ref.id)?.element ?? null,
+ extract:
+ extract ??
+ ((ref, document) => {
+ const loaded = entries(document);
+ const element = loaded.find((entry) => entry.ref.id === ref.id)?.element;
+ if (!element) {
+ throw new Error("This post is no longer loaded. Scroll it into view and try again.");
+ }
+ const available = loaded.map((entry) => domPart(entry.element, entry.ref, selectors));
+ return Promise.resolve(unrollThread(domPart(element, ref, selectors), available, true));
+ }),
+ };
+ return adapter;
+}
+
+export function domFallback(
+ adapter: SiteAdapter,
+ ref: PostRef,
+ document: Document,
+ selectors: DomSelectors,
+): SocialPost {
+ const element = adapter.element(ref, document);
+ if (!element) {
+ throw new Error(
+ "Could not retrieve this post. Open its permalink in the extension and try again.",
+ );
+ }
+ return unrollThread(domPart(element, ref, selectors), [], true);
+}
diff --git a/packages/web-capture/src/social/social.test.ts b/packages/web-capture/src/social/social.test.ts
new file mode 100644
index 00000000..3eda052d
--- /dev/null
+++ b/packages/web-capture/src/social/social.test.ts
@@ -0,0 +1,460 @@
+// @vitest-environment happy-dom
+import { readFileSync } from "node:fs";
+
+import { normalizedSourceUrl } from "@mdbase-reader/core";
+import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
+
+import { webCaptureImport } from "../web-capture.js";
+
+import { blueskyPart, mastodonPart, publicJson } from "./api.js";
+import { blueskyAdapter } from "./bluesky.js";
+import { nearestPost } from "./dom.js";
+import { postAddress } from "./identity.js";
+import { socialPostImport } from "./import.js";
+import { mastodonAdapter } from "./mastodon.js";
+import { unrollThread } from "./thread.js";
+import { xAdapter } from "./x.js";
+
+import { adapterFor, siteAdapters } from "./index.js";
+
+import type { PostPart } from "@mdbase-reader/core";
+
+function fixture(name: string, url: string): Document {
+ window.location.href = url;
+ const html = readFileSync(`src/social/fixtures/${name}.html`, "utf8");
+ document.documentElement.innerHTML = new DOMParser().parseFromString(
+ html,
+ "text/html",
+ ).documentElement.innerHTML;
+ return document;
+}
+
+function bskyPost(
+ key: string,
+ author = "did:plc:fixture",
+ parent?: string,
+): Record & { uri: string } {
+ return {
+ uri: `at://${author}/app.bsky.feed.post/${key}`,
+ author: { did: author, handle: "fixture.test", displayName: "Fixture Author" },
+ record: {
+ text: `Post ${key}`,
+ createdAt: "2026-01-01T10:00:00Z",
+ ...(parent ? { reply: { parent: { uri: parent } } } : {}),
+ },
+ };
+}
+
+function status(
+ id: string,
+ uri: string,
+ parent?: string,
+ author = "fixture",
+): Record {
+ return {
+ id,
+ uri,
+ url: uri.replace("/users/fixture/statuses/", "/@fixture/"),
+ account: {
+ id: author,
+ url: `https://home.example/@${author}`,
+ acct: `${author}@home.example`,
+ display_name: "Fixture Author",
+ },
+ content: `Post ${id}
`,
+ created_at: "2026-01-01T10:00:00Z",
+ in_reply_to_id: parent ?? null,
+ media_attachments: [],
+ };
+}
+
+function json(value: unknown): Response {
+ return new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } });
+}
+
+function requestUrl(input: string | URL | Request): string {
+ return typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+}
+
+function mockApi(handler: (url: string) => Response): Mock {
+ return vi.fn((input) => Promise.resolve(handler(requestUrl(input))));
+}
+
+beforeEach(() => window.getSelection()?.removeAllRanges());
+
+describe("post identity", () => {
+ it.each([
+ "https://x.com/fixture/status/100?s=20&t=tracking#photo",
+ "https://twitter.com/other/status/100",
+ "https://mobile.twitter.com/fixture/status/100/photo/1",
+ "https://mobile.x.com/i/web/status/100",
+ "https://x.com/i/status/100",
+ ])("normalises X variant %s by status ID, not author or tracking", (url) => {
+ expect(postAddress(url)).toMatchObject({ id: "100", url: "https://x.com/i/status/100" });
+ expect(normalizedSourceUrl(url)).toBe("https://x.com/i/status/100");
+ });
+ it("keeps an AT URI and DID permalink identical", () => {
+ expect(postAddress("at://did:plc:fixture/app.bsky.feed.post/first")).toEqual(
+ postAddress("https://bsky.app/profile/did:plc:fixture/post/first?ref=test"),
+ );
+ });
+ it.each([
+ "https://x.com/home",
+ "https://x.com/fixture/status/100/quotes",
+ "https://bsky.app/profile/fixture.test",
+ "https://bsky.app/search",
+ "https://bad.example/@fixture/no-id",
+ "https://x.com.evil.example/fixture/status/100",
+ "https://user:secret@x.com/fixture/status/100",
+ "http://x.com/fixture/status/100",
+ ])("does not make a post identity from %s", (url) => expect(postAddress(url)).toBeNull());
+ it("recognises Mastodon address forms without trusting an instance hostname", () => {
+ expect(postAddress("https://home.example/users/fixture/statuses/100?ref=test")).toMatchObject({
+ platform: "mastodon",
+ id: "100",
+ });
+ expect(postAddress("https://local.example/@fixture@home.example/555")).toMatchObject({
+ platform: "mastodon",
+ id: "555",
+ });
+ });
+});
+
+describe("saved DOM contracts", () => {
+ it.each([
+ ["x", "https://x.com/home", xAdapter, 4],
+ ["bluesky", "https://bsky.app/profile/fixture.test", blueskyAdapter, 2],
+ ["mastodon", "https://local.example/public/local", mastodonAdapter, 2],
+ ] as const)(
+ "detects, lists and resolves %s loaded posts, not their feed",
+ (name, url, adapter, count) => {
+ const doc = fixture(name, url);
+ expect(adapter.detect(doc)).toBe("multiple");
+ expect(adapterFor(doc)).toBe(adapter);
+ const refs = adapter.items(doc);
+ expect(refs).toHaveLength(count);
+ const root = adapter.element(refs[0]!, doc)!;
+ expect(adapter.resolve(root.querySelector("time")!)).toEqual(refs[0]);
+ expect(adapter.resolve(doc.body)).toBeNull();
+ expect(refs[0]?.snippet).toContain("first durable post");
+ window.location.href = refs[0]!.url;
+ expect(adapter.detect(doc)).toBe("single");
+ },
+ );
+ it("handles Bluesky's expanded item without a self-permalink or postText marker", () => {
+ const doc = fixture("bluesky-expanded", "https://bsky.app/profile/fixture.test/post/first");
+ const refs = blueskyAdapter.items(doc);
+ expect(refs).toHaveLength(1);
+ expect(refs[0]).toMatchObject({
+ snippet: "The first durable post.",
+ id: "at://fixture.test/app.bsky.feed.post/first",
+ });
+ expect(blueskyAdapter.resolve(doc.querySelector(".r-1xnzce8")!)).toEqual(refs[0]);
+ });
+ it("falls through for ordinary sites, including arbitrary numerical paths", () => {
+ window.location.href = "https://ordinary.example/@user/100";
+ document.body.innerHTML = "An ordinary article";
+ document.head.innerHTML = "";
+ expect(siteAdapters.every((adapter) => adapter.detect(document) === null)).toBe(true);
+ });
+ it("extracts X content, media, quoted context and explicit self replies from DOM only", async () => {
+ const doc = fixture("x", "https://x.com/home");
+ const post = await xAdapter.extract(xAdapter.items(doc)[1]!, doc);
+ expect(post.id).toBe("100");
+ expect(post.parts.map((part) => part.id)).toEqual(["100", "101"]);
+ expect(post.quoted?.text).toContain("Quoted context");
+ expect(post.media[0]).toMatchObject({ alt: "A blue square" });
+ expect(post.author).toMatchObject({ handle: "fixture", name: "Fixture Author" });
+ expect(post.threadPartial).toBe(true);
+ });
+ it("unrolls an X thread from the author's run around the focal post, without reply markup", async () => {
+ const doc = fixture("x-conversation", "https://x.com/fixture/status/100");
+ const focal = xAdapter.items(doc).find((item) => item.id === "100")!;
+ const post = await xAdapter.extract(focal, doc);
+ expect(post.threadRoot).toBe("99");
+ expect(post.parts.map((part) => part.id)).toEqual(["99", "100", "101"]);
+ expect(post.threadPartial).toBe(true);
+ });
+ it("never joins X posts into a thread on a feed", async () => {
+ const doc = fixture("x-conversation", "https://x.com/home");
+ const ref = xAdapter.items(doc).find((item) => item.id === "100")!;
+ expect((await xAdapter.extract(ref, doc)).parts.map((part) => part.id)).toEqual(["100"]);
+ });
+});
+
+describe("public API adapters", () => {
+ it("resolves Bluesky handles to DID identity and unrolls only same-author replies via AppView", async () => {
+ const doc = fixture("bluesky", "https://bsky.app/profile/fixture.test");
+ const root = bskyPost("first");
+ const fetcher = mockApi((url) =>
+ url.includes("resolveHandle")
+ ? json({ did: "did:plc:fixture" })
+ : json({
+ thread: {
+ post: root,
+ replies: [
+ { post: bskyPost("second", "did:plc:fixture", root.uri) },
+ { post: bskyPost("other", "did:plc:other", root.uri) },
+ ],
+ },
+ }),
+ );
+ const post = await blueskyAdapter.extract(blueskyAdapter.items(doc)[0]!, doc, {
+ fetch: fetcher,
+ });
+ expect(post.id).toBe(root.uri);
+ expect(post.parts.map((part) => part.text)).toEqual(["Post first", "Post second"]);
+ expect(post.url).toBe("https://bsky.app/profile/did:plc:fixture/post/first");
+ expect(
+ fetcher.mock.calls.every(([url]) =>
+ requestUrl(url).startsWith("https://public.api.bsky.app/xrpc/"),
+ ),
+ ).toBe(true);
+ });
+ it("uses a resolved-DID Bluesky DOM fallback when AppView thread retrieval fails", async () => {
+ const doc = fixture("bluesky", "https://bsky.app/profile/fixture.test");
+ const fetcher = mockApi((url) =>
+ url.includes("resolveHandle")
+ ? json({ did: "did:plc:fixture" })
+ : new Response("", { status: 503 }),
+ );
+ const post = await blueskyAdapter.extract(blueskyAdapter.items(doc)[0]!, doc, {
+ fetch: fetcher,
+ });
+ expect(post.id).toBe("at://did:plc:fixture/app.bsky.feed.post/first");
+ expect(post.text).toBe("The first durable post.");
+ expect(post.threadPartial).toBe(true);
+ });
+ it("normalises federated Mastodon local IDs and home URI forms to one record", async () => {
+ const doc = fixture("mastodon", "https://local.example/public/local");
+ const uri = "https://home.example/users/fixture/statuses/100";
+ const fetcher = mockApi((url) =>
+ url.endsWith("/context")
+ ? json({
+ ancestors: [],
+ descendants: [
+ status("556", "https://home.example/users/fixture/statuses/101", "555"),
+ status("557", "https://local.example/users/other/statuses/557", "555", "other"),
+ ],
+ })
+ : json(status("555", uri)),
+ );
+ const post = await mastodonAdapter.extract(mastodonAdapter.items(doc)[0]!, doc, {
+ fetch: fetcher,
+ });
+ expect(post.id).toBe(uri);
+ expect(post.url).toBe(uri);
+ expect(post.parts.map((part) => part.text)).toEqual(["Post 555", "Post 556"]);
+ expect(post.parts[1]?.inReplyTo).toBe(uri);
+ expect(
+ fetcher.mock.calls.some(
+ ([url]) => requestUrl(url) === "https://local.example/api/v1/statuses/555/context",
+ ),
+ ).toBe(true);
+ });
+ it("Mastodon DOM fallback requires a home URI rather than guessing from an instance ID", async () => {
+ const doc = fixture("mastodon", "https://local.example/public/local");
+ const fetcher = mockApi(() => new Response("", { status: 503 }));
+ const post = await mastodonAdapter.extract(mastodonAdapter.items(doc)[0]!, doc, {
+ fetch: fetcher,
+ });
+ expect(post.url).toBe("https://home.example/users/fixture/statuses/100");
+ await expect(
+ mastodonAdapter.extract(mastodonAdapter.items(doc)[1]!, doc, { fetch: fetcher }),
+ ).rejects.toThrow("HTTP 503");
+ });
+});
+
+describe("Mastodon anonymous profile placeholders", () => {
+ it("maps only loaded placeholders to official status identities; unseen API posts are not picker items", async () => {
+ const doc = fixture("mastodon", "https://local.example/@fixture");
+ doc.body.innerHTML =
+ 'Fixture Author<p>Post 555</p>
';
+ const known = status("555", "https://home.example/users/fixture/statuses/100");
+ const fetcher = mockApi((url) =>
+ url.includes("accounts/lookup")
+ ? json({ id: "20" })
+ : json([known, status("777", "https://home.example/users/fixture/statuses/777")]),
+ );
+ expect(mastodonAdapter.items(doc)).toHaveLength(0);
+ await mastodonAdapter.prepare(doc, { fetch: fetcher });
+ const items = mastodonAdapter.items(doc);
+ expect(items).toHaveLength(1);
+ expect(items[0]).toMatchObject({
+ id: "555",
+ snippet: "Post 555",
+ author: "fixture@home.example",
+ });
+ expect(mastodonAdapter.resolve(doc.querySelector("span")!)?.id).toBe("555");
+ });
+ it("does not guess when two API statuses have the same author and text", async () => {
+ const doc = fixture("mastodon", "https://local.example/@fixture");
+ doc.body.innerHTML =
+ 'Fixture Author<p>Post 555</p>
';
+ const first = status("555", "https://home.example/users/fixture/statuses/100");
+ const fetcher = mockApi((url) =>
+ url.includes("accounts/lookup") ? json({ id: "20" }) : json([first, { ...first, id: "556" }]),
+ );
+ await mastodonAdapter.prepare(doc, { fetch: fetcher });
+ expect(mastodonAdapter.items(doc)).toHaveLength(0);
+ });
+});
+
+const part = (id: string, author: string, parent?: string): PostPart => ({
+ platform: "x",
+ id,
+ url: `https://x.com/i/status/${id}`,
+ text: `Part ${id}`,
+ author: { id: author, handle: author, name: author },
+ media: [],
+ ...(parent ? { inReplyTo: parent } : {}),
+});
+
+describe("threads and picker", () => {
+ it("unrolls ancestors and self-reply branches but excludes other authors and unrelated posts", () => {
+ const root = part("1", "author");
+ const middle = part("2", "author", "1");
+ expect(
+ unrollThread(
+ middle,
+ [root, middle, part("3", "author", "2"), part("4", "other", "2"), part("5", "author")],
+ false,
+ ).parts.map((item) => item.id),
+ ).toEqual(["1", "2", "3"]);
+ });
+ it("terminates cyclic reply graphs", () => {
+ expect(unrollThread(part("1", "a", "2"), [part("2", "a", "1")], true).parts).toHaveLength(2);
+ });
+ it("preselects the visible post closest to viewport centre", () => {
+ const refs = xAdapter.items(fixture("x", "https://x.com/home"));
+ expect(
+ nearestPost(
+ refs.map((ref, index) => ({ ...ref, top: index * 300 - 100, bottom: index * 300 + 100 })),
+ 800,
+ ),
+ ).toBe("101");
+ expect(nearestPost([], 800)).toBeNull();
+ });
+ it("embeds Bluesky quoted-post context from official embed data", () => {
+ const quoted = { ...bskyPost("quoted", "did:plc:quoted"), value: { text: "Quoted context" } };
+ const post = blueskyPart({ ...bskyPost("first"), embed: { record: quoted } });
+ expect(post.quoted?.text).toBe("Quoted context");
+ });
+});
+
+describe("portable post imports and capture safety", () => {
+ it("does not mistake another site's generic initial-state script for a Mastodon page", () => {
+ const parsed = new DOMParser().parseFromString(
+ 'Ordinary article',
+ "text/html",
+ );
+ expect(mastodonAdapter.detect(parsed, "https://ordinary-site.test/@fixture/123")).toBeNull();
+ expect(adapterFor(parsed, "https://ordinary-site.test/article")).toBeNull();
+ });
+ it("retains Mastodon audio attachment URLs/types without pretending to archive playback", async () => {
+ const root = mastodonPart(
+ {
+ uri: "https://any-instance.test/users/fixture/statuses/100",
+ content: "Audio note
",
+ account: {
+ url: "https://any-instance.test/@fixture",
+ acct: "fixture",
+ display_name: "Fixture Author",
+ },
+ media_attachments: [
+ { type: "audio", url: "https://media.example/note.mp3", description: "" },
+ ],
+ },
+ document,
+ );
+ expect(root.media[0]?.kind).toBe("audio");
+ const prepared = await socialPostImport(unrollThread(root, [], false), {
+ retrievedAt: "2026-01-01T10:00:00Z",
+ });
+ expect(new TextDecoder().decode(prepared.bytes)).toContain(
+ "Audio (original URL kept in the archived copy)",
+ );
+ expect(prepared.metadata.socialPost?.media[0]?.url).toBe("https://media.example/note.mp3");
+ expect(new TextDecoder().decode(prepared.archive.bytes)).toContain(
+ 'href="https://media.example/note.mp3"',
+ );
+ });
+ it("drops an image that cannot be made small enough to embed, keeping its caption", async () => {
+ const root = {
+ ...part("1", "author"),
+ media: [
+ { kind: "image" as const, url: "https://media.example/huge.png", alt: "A huge image" },
+ ],
+ };
+ const prepared = await socialPostImport(
+ unrollThread(root, [], false),
+ { retrievedAt: "2026-01-01T10:00:00Z" },
+ {
+ fetch: mockApi(
+ () =>
+ new Response(new Uint8Array(2 * 1024 * 1024), {
+ headers: { "content-type": "image/png" },
+ }),
+ ),
+ },
+ );
+ const readable = new TextDecoder().decode(prepared.bytes);
+ expect(readable).not.toContain("data:image");
+ expect(readable).toContain("A huge image — image unavailable offline");
+ expect(new TextDecoder().decode(prepared.archive.bytes)).toContain(
+ 'src="https://media.example/huge.png"',
+ );
+ });
+ it("stores safe readable HTML, a post-only archive and portable metadata with offline images", async () => {
+ const root = {
+ ...part("1", "author"),
+ text: '',
+ media: [
+ { kind: "image" as const, url: "https://media.example/one.png", alt: "A blue square" },
+ ],
+ };
+ const prepared = await socialPostImport(
+ unrollThread(root, [], false),
+ { retrievedAt: "2026-01-01T10:00:00Z" },
+ {
+ fetch: mockApi(
+ () =>
+ new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/png" } }),
+ ),
+ },
+ );
+ const readable = new TextDecoder().decode(prepared.bytes);
+ expect(readable).toContain("data:image/png;base64,");
+ expect(readable).not.toContain("