Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pnpm-lock.yaml
.playwright-mcp/
.scratch/
dist/
coverage/
apps/capacitor/android/
Expand Down
38 changes: 38 additions & 0 deletions apps/extension/src/CaptureApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExtensionCaptureController> = {}, tab?: PanelTab): string {
const controller = {
snapshot: { status: "unselected", connections: [] },
Expand Down
2 changes: 2 additions & 0 deletions apps/extension/src/CaptureApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -78,6 +79,7 @@ export function CaptureApp({
: "Waiting for the active tab"}
</p>
<SaveBar controller={controller} />
<PostPicker controller={controller} />
</section>
)}
<PanelStatus controller={controller} />
Expand Down
80 changes: 80 additions & 0 deletions apps/extension/src/PostPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<PostPicker
controller={
{
busy,
navigated: false,
snapshot: { status: "ready" },
postPicker: { open: true, items: loaded, preselected: "101", save, close: cancel },
} as unknown as ExtensionCaptureController
}
/>,
),
);
}

it("preselects the viewport-centred post, shows author/snippet/time, and supports multi-select", () => {
show();
const inputs = host.querySelectorAll<HTMLInputElement>('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<HTMLButtonElement>("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<HTMLInputElement>("input")?.checked).toBe(false);
expect(host.querySelector<HTMLButtonElement>("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<HTMLFieldSetElement>("fieldset")?.disabled).toBe(true);
expect(host.querySelector<HTMLButtonElement>("button.primary")?.disabled).toBe(true);
});
94 changes: 94 additions & 0 deletions apps/extension/src/PostPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useState } from "react";

import type { ExtensionCaptureController } from "./capture-controller.js";

type Picker = NonNullable<ExtensionCaptureController["postPicker"]>;

export function PostPicker({
controller: c,
}: {
readonly controller: ExtensionCaptureController;
}): React.JSX.Element | null {
const picker = c.postPicker;
if (!picker) {
return null;
}
if (!picker.open) {
return (
<button
type="button"
className="secondary compact"
disabled={c.busy || c.navigated}
onClick={picker.show}
>
Choose posts…
</button>
);
}
return <OpenPostPicker picker={picker} controller={c} />;
}

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 (
<section className="post-picker tab-section" aria-labelledby="post-picker-title">
<h2 id="post-picker-title">Choose posts to save</h2>
<p className="hint">
Only posts currently loaded on the page are listed. Feeds are never saved.
</p>
<fieldset disabled={c.busy}>
<legend>Posts</legend>
{picker.items.map((item) => (
<label className="post-picker-item" key={item.id}>
<input
type="checkbox"
checked={selected.has(item.id)}
onChange={(event) =>
setSelected((current) => {
const next = new Set(current);
if (event.target.checked) {
next.add(item.id);
} else {
next.delete(item.id);
}
return next;
})
}
/>
<span>
<strong>{item.author || "Post"}</strong>
<span>{item.snippet || "Media post"}</span>
{item.published ? <time dateTime={item.published}>{item.published}</time> : null}
</span>
</label>
))}
{!picker.items.length ? <p>No posts loaded. Scroll the page to load some posts.</p> : null}
</fieldset>
<div className="save-bar">
<button
type="button"
className="primary"
disabled={
!loadedSelected.length || c.busy || c.snapshot.status !== "ready" || c.navigated
}
onClick={() => void picker.save(loadedSelected)}
>
{c.busy ? "Saving…" : `Save ${String(loadedSelected.length)} selected`}
</button>
<button type="button" className="secondary" disabled={c.busy} onClick={picker.close}>
Cancel
</button>
</div>
</section>
);
}
8 changes: 7 additions & 1 deletion apps/extension/src/SaveBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
4 changes: 4 additions & 0 deletions apps/extension/src/background-page-status.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
84 changes: 84 additions & 0 deletions apps/extension/src/background-social-status.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fakeChrome> {
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();
});
Loading