diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 98778ff166..8a62448a06 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -595,8 +595,8 @@ The source of truth is ordinary Markdown in one root level-two `Todos` section: ordinary Markdown renders it as plain text. Canvases without it keep their existing meaning: `[ ]` is To do and `[x]`/`[X]` is Done. -Only top-level unordered checkbox items in that section are shown. Nested lists, -quotes and fenced examples are not tasks in this view. Doing and checkbox edits +Unordered checkbox items in that section are shown, including nested tasks. Nested tasks remain under their root task’s status group, with collapsible subtrees. +Quotes and fenced examples are not tasks in this view. Doing and checkbox edits change one source byte; additions insert below the heading without rewriting other content. Duplicate Todos sections block editing until corrected in Canvas. Disabling removes diff --git a/src/bundled/todos/TodoChoice.tsx b/src/bundled/todos/TodoChoice.tsx index e166c7b948..3f62142e94 100644 --- a/src/bundled/todos/TodoChoice.tsx +++ b/src/bundled/todos/TodoChoice.tsx @@ -1,4 +1,5 @@ import type { ReactElement } from "react"; +import { Button } from "../../shared/design-system/ui/Button"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow"; import { @@ -16,6 +17,7 @@ export function TodoChoice({ id, label, title, + text, icon, variant = "ghost", menuTitle, @@ -27,6 +29,7 @@ export function TodoChoice({ id: string; label: string; title: string; + text?: ReactElement | string | undefined; icon: ReactElement; variant?: "ghost" | "avatar"; menuTitle: string; @@ -44,16 +47,31 @@ export function TodoChoice({ + text ? ( + + ) : ( + + ) } /> diff --git a/src/bundled/todos/Todos.module.css b/src/bundled/todos/Todos.module.css index 66c8df4133..d090a76a86 100644 --- a/src/bundled/todos/Todos.module.css +++ b/src/bundled/todos/Todos.module.css @@ -49,16 +49,16 @@ } .row { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: 1.5rem auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); - padding-block: var(--space-3); + padding-block: var(--space-2); border-bottom: 1px solid var(--border-standard); } .row:first-child { padding-block-start: var(--space-2); } -.row:last-child { +.list > li:last-child > .row { padding-block-end: 0; border-bottom: 0; } @@ -108,3 +108,24 @@ flex: 1 1 16rem; margin: 0; } + +.children { + list-style: none; + margin: 0; + padding-inline-start: var(--space-5); +} +.disclosure { + display: flex; + align-items: center; + width: 1.5rem; +} +.label a { + color: var(--text-link); +} + +.assigneeName { + max-width: 6rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/bundled/todos/TodosPanel.test.tsx b/src/bundled/todos/TodosPanel.test.tsx index f4abf48ae8..1d6b1eeab7 100644 --- a/src/bundled/todos/TodosPanel.test.tsx +++ b/src/bundled/todos/TodosPanel.test.tsx @@ -66,6 +66,7 @@ function fixture() { people, context, close: vi.fn(), + onOpenLink: vi.fn(() => true), active: () => active, }; return { @@ -94,6 +95,41 @@ const saved = () => waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Saved in Canvas"), ); +it("nests tasks across statuses without repeating parent titles and saves only the child checkbox", async () => { + const f = fixture(); + const content = + "## Todos\n\n- [/] Ship\n - [ ] Build\n - [x] Test\n - [ ] Publish\n\n## Keep\nUntouched.\n"; + f.canvas.read.mockResolvedValue({ ...head, content }); + render(); + const child = await screen.findByRole("checkbox", { name: "Build" }); + const grandchild = screen.getByRole("checkbox", { name: "Test" }); + const childRow = child.closest("li"), + grandchildRow = grandchild.closest("li"); + if (!childRow || !grandchildRow) throw new Error("Expected nested todo rows"); + expect(childRow.contains(grandchildRow)).toBe(true); + expect(within(childRow).queryByText("Ship")).not.toBeInTheDocument(); + const collapse = screen.getByRole("button", { name: "Collapse Ship" }); + await userEvent.setup().click(collapse); + expect( + screen.queryByRole("checkbox", { name: "Build" }), + ).not.toBeInTheDocument(); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Expand Ship" })); + await userEvent + .setup() + .click(screen.getByRole("checkbox", { name: "Build" })); + await saved(); + expect(f.canvas.save).toHaveBeenCalledWith( + context.channelId, + content.replace("[ ] Build", "[x] Build"), + head.id, + ); + expect(screen.getByRole("checkbox", { name: "Ship" })).toHaveAttribute( + "aria-checked", + "mixed", + ); +}); it("adds and checks tasks, saves exact base, preserves other Markdown and restores saved content", async () => { const f = fixture(), user = userEvent.setup(); @@ -184,7 +220,7 @@ it("keeps failed save changes through close/reopen and confirms destructive refr expect( screen.queryByRole("button", { name: /^Retry$/ }), ).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Refresh todos" })); + await user.click(screen.getByRole("button", { name: "Refresh" })); expect(await screen.findByRole("alertdialog")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Keep editing" })); view.unmount(); @@ -396,9 +432,9 @@ it("keeps exact assignees through rename, failed-save recovery and member remova }, }; const select = () => - within(screen.getByRole("group", { name: "Assignee for First" })).getByRole( - "button", - ); + within( + screen.getByRole("group", { name: /^Assignee for First/ }), + ).getByRole("button"); const view = render(); await screen.findByRole("checkbox", { name: "First" }); f.canvas.save.mockRejectedValueOnce(new Error("Offline")); @@ -520,9 +556,9 @@ it("shows saved identity when profiles fail, disables missing-roster assignments f.canvas.read.mockResolvedValue({ ...head, content: saved }); const view = render(); const select = () => - within(screen.getByRole("group", { name: "Assignee for First" })).getByRole( - "button", - ); + within( + screen.getByRole("group", { name: /^Assignee for First/ }), + ).getByRole("button"); await screen.findByRole("checkbox", { name: "First" }); expect(select()).toHaveAttribute( "aria-description", @@ -670,7 +706,7 @@ it("scopes live naming to channel members rather than out-of-channel namesakes", f.canvas.read.mockResolvedValue({ ...head, content }); const view = render(); await screen.findByRole("checkbox", { name: "First" }); - const trigger = screen.getByRole("button", { name: "Assignee for First" }); + const trigger = screen.getByRole("button", { name: /^Assignee for First/ }); expect(trigger).toHaveAttribute( "aria-description", expect.stringContaining("Alex"), @@ -855,7 +891,7 @@ it("updates the assigned avatar from live profiles through the session media own }); const view = render(); await screen.findByRole("checkbox", { name: "First" }); - const trigger = screen.getByRole("button", { name: "Assignee for First" }); + const trigger = screen.getByRole("button", { name: /^Assignee for First/ }); expect(trigger.querySelector("img")).toHaveAttribute( "src", "/media/first.png", @@ -903,3 +939,148 @@ it("preserves actionable save failure details", async () => { "Keep Canvas below 24 KiB", ); }); + +it("renders task Markdown outside checkbox labels and shows a legacy named assignee", async () => { + const f = fixture(); + f.canvas.read.mockResolvedValue({ + ...head, + content: + "## Todos\n\n- [ ] **Ship** [release](https://example.com) · Assignee: John\n", + }); + render(); + const link = await screen.findByRole("link", { name: "release" }); + expect(link.closest("label")).toBeNull(); + expect(screen.getByText("Ship").tagName).toBe("STRONG"); + expect(screen.queryByText(/Assignee: John/)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Assignee for/ }), + ).toHaveTextContent("John"); + expect(screen.getByRole("button", { name: /Assignee for/ })).toHaveAttribute( + "aria-description", + "John (from Canvas; choose a member to link)", + ); +}); + +it("routes copied Buzz links and filters unsupported task destinations", async () => { + const f = fixture(); + const message = `buzz://channel/general/${"a".repeat(64)}`; + f.canvas.read.mockResolvedValue({ + ...head, + content: `## Todos\n\n- [ ] Review [channel](buzz://channel/general) and [message](${message}) and [website](https://example.com) and [relative](/settings) and [credentials](https://user:pass@example.com) and [invalid](buzz://bad/path)\n`, + }); + render(); + const user = userEvent.setup(); + await user.click(await screen.findByRole("link", { name: "channel" })); + expect(f.props.onOpenLink).toHaveBeenLastCalledWith("buzz://channel/general"); + await user.click(screen.getByRole("link", { name: "message" })); + expect(f.props.onOpenLink).toHaveBeenLastCalledWith(message); + expect(screen.getByRole("link", { name: "website" })).toHaveAttribute( + "href", + "https://example.com/", + ); + for (const name of ["relative", "credentials", "invalid"]) { + expect(screen.queryByRole("link", { name })).not.toBeInTheDocument(); + expect(screen.getByText(name)).toBeInTheDocument(); + } +}); +it("uses rendered Markdown and the displayed assignee in accessible names", async () => { + const f = fixture(); + f.canvas.read.mockResolvedValue({ + ...head, + content: + "## Todos\n\n- [ ] **Build** [release](https://example.com) · Assignee: John\n - [ ] Child\n", + }); + render(); + await screen.findByRole("checkbox", { name: "Build release" }); + expect( + screen.getByRole("button", { name: "Collapse Build release" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Status for Build release" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Assignee for Build release: John" }), + ).toHaveTextContent("John"); +}); +it("keeps a later subtree collapsed after adding and assigning an earlier task", async () => { + const f = fixture(), + user = userEvent.setup(); + const content = + "## Todos\n\n- [ ] Earlier · Assignee: John\n- [ ] Parent\n - [ ] Child\n"; + f.canvas.read.mockResolvedValue({ ...head, content }); + render(); + await user.click( + await screen.findByRole("button", { name: "Collapse Parent" }), + ); + await user.type( + screen.getByRole("textbox", { name: "New todo" }), + "Inserted{Enter}", + ); + await saved(); + expect( + screen.getByRole("button", { name: "Expand Parent" }), + ).toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: /^Assignee for Earlier/ }), + ); + await user.click( + await screen.findByRole("menuitemradio", { name: "Unassigned" }), + ); + await saved(); + expect( + screen.getByRole("button", { name: "Expand Parent" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("checkbox", { name: "Child" }), + ).not.toBeInTheDocument(); +}); + +it("keeps a later subtree collapsed when removing an earlier assignment", async () => { + const f = fixture(), + user = userEvent.setup(); + f.canvas.read.mockResolvedValue({ + ...head, + content: + "## Todos\n\n- [ ] Earlier · Assignee: John\n- [ ] Parent\n - [ ] Child\n", + }); + render(); + await user.click( + await screen.findByRole("button", { name: "Collapse Parent" }), + ); + await user.click( + screen.getByRole("button", { name: /^Assignee for Earlier/ }), + ); + await user.click( + await screen.findByRole("menuitemradio", { name: "Unassigned" }), + ); + await saved(); + expect( + screen.getByRole("button", { name: "Expand Parent" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("checkbox", { name: "Child" }), + ).not.toBeInTheDocument(); +}); +it("clears collapsed positions when refreshing a changed Canvas", async () => { + const f = fixture(), + user = userEvent.setup(); + f.canvas.read.mockResolvedValue({ + ...head, + content: "## Todos\n\n- [ ] Parent\n - [ ] Child\n", + }); + render(); + await user.click( + await screen.findByRole("button", { name: "Collapse Parent" }), + ); + f.canvas.read.mockResolvedValue({ + ...head, + content: "## Todos\n\n- [ ] Other!\n - [ ] New child\n", + }); + await user.click(screen.getByRole("button", { name: "Refresh" })); + expect( + await screen.findByRole("checkbox", { name: "New child" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Collapse Other!" }), + ).toBeInTheDocument(); +}); diff --git a/src/bundled/todos/TodosPanel.tsx b/src/bundled/todos/TodosPanel.tsx index 7c7eb19dd2..76363e118f 100644 --- a/src/bundled/todos/TodosPanel.tsx +++ b/src/bundled/todos/TodosPanel.tsx @@ -1,5 +1,12 @@ +import Markdown from "react-markdown"; +import { parseBuzzLink } from "../../features/navigation/buzz-links"; +import { profileKey } from "../../features/profiles/target"; +import { safeLinkUrl } from "../../features/relay/message-content"; +import remarkGfm from "remark-gfm"; +import { MessageLink } from "../../features/conversation/MessageLink"; import { CanvasConflictError } from "../../features/channel-templates/canvas-conflict"; import { + type ReactNode, useCallback, useEffect, useId, @@ -17,6 +24,8 @@ import type { ChannelCanvas } from "../../features/channel-templates/capability" import type { ChannelPanelContext } from "../../features/panels/service"; import { XIcon, + CaretDownIcon, + CaretRightIcon, InfoIcon, ArrowsClockwiseIcon, ListChecksIcon, @@ -46,6 +55,7 @@ import { import { publicKeyLabels } from "../../shared/identity/public-key"; import { readView, writeView } from "../../shared/view-state"; import { + todoLabelText, addTodo, assignTodo, readTodos, @@ -113,12 +123,14 @@ export function TodosPanel({ context, close, active, + onOpenLink = () => false, }: { canvas: ChannelCanvas; people: TodoPeople; context: ChannelPanelContext; close(): void; active(): boolean; + onOpenLink?(url: string): boolean; }) { const list = useSyncExternalStore( people.channels.subscribeList, @@ -139,6 +151,7 @@ export function TodosPanel({ const savedDirty = !!saved && saved.content !== saved.original; const [draft, setDraft] = useState(saved ?? empty); const latestDraft = useRef(draft); + const [collapsed, setCollapsed] = useState>(() => new Set()); const [loaded, setLoaded] = useState(false); const [busy, setBusy] = useState<"load" | "save">(); const [error, setError] = useState(""); @@ -162,6 +175,38 @@ export function TodosPanel({ const dirty = draft.content !== draft.original; const update = useCallback( (next: Draft) => { + const before = latestDraft.current.content; + if (before !== next.content) { + let start = 0, + end = before.length, + nextEnd = next.content.length; + while ( + start < end && + start < nextEnd && + before[start] === next.content[start] + ) + start++; + while ( + end > start && + nextEnd > start && + before[end - 1] === next.content[nextEnd - 1] + ) { + end--; + nextEnd--; + } + setCollapsed( + (current) => + new Set( + [...current].flatMap((offset) => + offset <= start + ? [offset] + : offset >= end + ? [offset + nextEnd - end] + : [], + ), + ), + ); + } latestDraft.current = next; setDraft(next); writeView( @@ -196,6 +241,7 @@ export function TodosPanel({ ); else if (!replace && savedDirty && !alreadySaved) setError("Recovered unsaved changes. Retry to save them to Canvas."); + if (replace) setCollapsed(new Set()); if (replace || !savedDirty || alreadySaved) update({ ...empty, @@ -367,6 +413,268 @@ export function TodosPanel({ : loaded ? "Saved in Canvas" : "Channel Canvas"; + // Source order and ancestor depth keep descendants beside their root across statuses. + const childrenByOffset = new Map(); + const ancestors: typeof items = []; + for (const item of items) { + ancestors.length = item.parents.length; + const parent = ancestors.at(-1)?.offset; + const children = childrenByOffset.get(parent) ?? []; + children.push(item); + childrenByOffset.set(parent, children); + ancestors.push(item); + } + const renderItems = (group: typeof items): ReactNode => + group.map((item) => { + const children = childrenByOffset.get(item.offset) ?? []; + const taskLabel = todoLabelText(item.label); + const assigneeName = item.assignee + ? resolveName(item.assignee.pubkey, item.assignee.name) || + keyLabels.get(item.assignee.pubkey) + : item.namedAssignee?.name; + + return ( +
  • +
    +
    + {children.length > 0 && ( + + ) : ( + + ) + } + onClick={() => + setCollapsed((current) => { + const next = new Set(current); + if (next.has(item.offset)) next.delete(item.offset); + else next.add(item.offset); + return next; + }) + } + /> + )} +
    + { + focusAfterToggle.current = `${prefix}-${item.offset}`; + change(() => + setStatus( + draft.content, + item.offset, + value ? "done" : "todo", + ), + ); + }} + aria-label={taskLabel} + label="" + /> +
    + + parseBuzzLink(value) || profileKey(value) + ? value + : safeLinkUrl(value) + } + allowedElements={[ + "p", + "a", + "strong", + "em", + "del", + "code", + "br", + "img", + ]} + unwrapDisallowed + components={{ + p: ({ children }) => {children}, + a: ({ href, children }) => + href ? ( + active() && onOpenLink(url)} + > + {children} + + ) : ( + {children} + ), + img: ({ alt }) => {alt}, + }} + > + {item.label} + +
    +
    +
    + } + value={item.status} + disabled={disabled} + options={statuses.map(([value, label]) => ({ + value, + label, + icon: , + }))} + onValueChange={(value) => { + const status = statuses.find( + ([status]) => status === value, + )?.[0]; + if (!status || status === item.status) return; + focusAfterToggle.current = `${prefix}-${item.offset}-status`; + change(() => setStatus(draft.content, item.offset, status)); + }} + /> +
    +
    + + {item.assignee + ? resolveName( + item.assignee.pubkey, + item.assignee.name, + ) || keyLabels.get(item.assignee.pubkey) + : item.namedAssignee?.name} + + ) + } + title={ + item.assignee + ? userLabel(item.assignee.pubkey, item.assignee.name) + : item.namedAssignee + ? `${item.namedAssignee.name} (from Canvas; choose a member to link)` + : "Assign task" + } + icon={ + item.assignee ? ( + personIcon(item.assignee.pubkey, item.assignee.name) + ) : ( +
    +
    +
    + {children.length > 0 && ( + + )} +
  • + ); + }); return (
    )} {statuses.map(([status, title]) => { - const group = items.filter((item) => item.status === status); + const group = (childrenByOffset.get(undefined) ?? []).filter( + (item) => item.status === status, + ); return ( group.length > 0 && (
    -
      - {group.map((item) => ( -
    • - { - focusAfterToggle.current = `${prefix}-${item.offset}`; - change(() => - setStatus( - draft.content, - item.offset, - value ? "done" : "todo", - ), - ); - }} - label={ - - {item.label} - - } - /> -
      -
      - } - value={item.status} - disabled={disabled} - options={statuses.map(([value, label]) => ({ - value, - label, - icon: , - }))} - onValueChange={(value) => { - const status = statuses.find( - ([status]) => status === value, - )?.[0]; - if (!status || status === item.status) - return; - focusAfterToggle.current = `${prefix}-${item.offset}-status`; - change(() => - setStatus( - draft.content, - item.offset, - status, - ), - ); - }} - /> -
      -
      -
      -
      -
    • - ))} -
    +
      {renderItems(group)}
    ) ); diff --git a/src/bundled/todos/index.tsx b/src/bundled/todos/index.tsx index 83c07922be..e244259a62 100644 --- a/src/bundled/todos/index.tsx +++ b/src/bundled/todos/index.tsx @@ -36,7 +36,7 @@ export const apply: PluginModule["apply"] = (ctx) => { /> ); } - function Panel({ channelContext, close }: PanelProps) { + function Panel({ channelContext, close, context: host }: PanelProps) { const state = useSyncExternalStore(ctx.relay.subscribe, ctx.relay.snapshot); const isActive = useCallback( () => active && ctx.relay.snapshot() === state, @@ -56,6 +56,7 @@ export const apply: PluginModule["apply"] = (ctx) => { context={channelContext} close={close} active={isActive} + onOpenLink={(url) => host?.open(url) ?? false} /> ); } diff --git a/src/bundled/todos/model.test.ts b/src/bundled/todos/model.test.ts index 06aaeb38b1..e4e593798b 100644 --- a/src/bundled/todos/model.test.ts +++ b/src/bundled/todos/model.test.ts @@ -37,14 +37,41 @@ describe("Canvas todo source edits", () => { ); expect(readTodos("## Todos\n- [-] Other\n- [//] Mark\n").items).toEqual([]); }); - it("recognizes actual Markdown structure, not examples or nested task lists", () => { + it("recognizes nested tasks but excludes examples, quotes and continuation prose", () => { const content = "```md\n## Todos\n- [ ] Fenced\n```\n\n## Todos\n\n- [ ] Real\n - [ ] Nested\n\n [ ] Continuation\n\n> - [ ] Quoted\n\n```\n- [ ] Code\n```\n\n### Details\n\n+ [x] Also real\n\n# Next\n\n- [ ] Other\n"; expect(readTodos(content).items.map((i) => i.label)).toEqual([ "Real", + "Nested", "Also real", ]); }); + it("reads nested task paths and edits only the selected source bytes", () => { + const content = + "## Todos\r\n\r\n- [ ] Ship\r\n - [/] Build\r\n - [X] Test\r\n - [ ] Ship\r\n- Group\r\n - [ ] Other\r\n\r\n## Keep\r\n- [ ] Outside\r\n"; + const { items } = readTodos(content); + expect(items.map(({ label, parents }) => [label, parents])).toEqual([ + ["Ship", []], + ["Build", ["Ship"]], + ["Test", ["Ship", "Build"]], + ["Ship", ["Ship"]], + ["Other", []], + ]); + const nested = items[2]; + if (!nested) throw new Error("Expected grandchild todo"); + expect(setStatus(content, nested.offset, "todo")).toBe( + content.replace("[X]", "[ ]"), + ); + const assigned = assignTodo(content, nested.offset, alex); + expect(assigned).toBe( + content.replace( + "Test\r\n", + `Test · Assignee: [Alex](${profileTarget(alex.pubkey)})\r\n`, + ), + ); + expect(assignTodo(assigned, nested.offset)).toBe(content); + expect(readTodos(addTodo(content, "New")).items).toHaveLength(6); + }); it("preserves prose, sections, markers and trailing whitespace when adding", () => { const before = "Preamble \r\n\r\n## Todos ###\r\n\r\nNotes \r\n* [x] Old\r\n\r\n## Other\r\nKeep exactly. "; @@ -165,3 +192,27 @@ it("recognizes first-line assignment when continuation prose has another link", "## Todos\n- [ ] Task\n See [docs](https://example.com).\n", ); }); + +it("recognizes a plain Canvas assignment and replaces or clears it without duplicating the suffix", () => { + const source = "## Todos\n\n- [ ] Ship · Assignee: John\n - [ ] Child\n"; + const item = first(source); + expect(item.label).toBe("Ship"); + expect(item.namedAssignee?.name).toBe("John"); + expect(assignTodo(source, item.offset)).toBe( + source.replace(" · Assignee: John", ""), + ); + const assigned = assignTodo(source, item.offset, { + pubkey: "a".repeat(64), + name: "Alice", + }); + expect(readTodos(assigned).items[0]?.assignee?.name).toBe("Alice"); + expect(assigned).not.toContain("Assignee: John"); +}); + +it("does not clear a named assignment lookalike inside multiline code", () => { + const before = "## Todos\n- [ ] Task ` · Assignee: John\n continued`\n"; + const item = first(before); + expect(item.namedAssignee).toBeUndefined(); + expect(assignTodo(before, item.offset)).toBe(before); + expect(() => assignTodo(before, item.offset, sam)).toThrow(/safely/); +}); diff --git a/src/bundled/todos/model.ts b/src/bundled/todos/model.ts index f37f040514..201f7130ea 100644 --- a/src/bundled/todos/model.ts +++ b/src/bundled/todos/model.ts @@ -1,4 +1,7 @@ +import type { Root, RootContent, ListItem } from "mdast"; import { fromMarkdown } from "mdast-util-from-markdown"; +import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough"; +import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough"; import { profileKey, profileTarget } from "../../features/profiles/target"; export type Assignee = { pubkey: string; name: string }; @@ -8,8 +11,10 @@ const marks = { todo: " ", doing: "/", done: "x" } as const; export type Todo = { offset: number; label: string; + parents: string[]; status: Status; end: number; + namedAssignee?: { name: string; start: number }; assignee?: (Assignee & { start: number }) | undefined; }; const assignment = " · Assignee: "; @@ -36,60 +41,88 @@ export function readTodos(content: string) { ); const section = boundary < 0 ? following : following.slice(0, boundary); const items: Todo[] = []; - for (const node of section) { - if (node.type !== "list" || node.ordered) continue; - for (const item of node.children) { + function visit(children: ListItem[], parents: string[]) { + for (const item of children) { + const before = items.length; const offset = item.position?.start.offset; if (offset === undefined) continue; const match = /^([-+*][ \t]+\[)([ xX/])\][ \t]+([^\r\n]+)/.exec( content.slice(offset), ); - if (!match?.[1] || !match[3]) continue; - const end = offset + match[0].trimEnd().length; - const paragraph = item.children[0]; - const link = - paragraph?.type === "paragraph" - ? paragraph.children.find( - (child) => - child.type === "link" && child.position?.end.offset === end, + if (match?.[1] && match[3]) { + const end = offset + match[0].trimEnd().length; + const paragraph = item.children[0]; + const link = + paragraph?.type === "paragraph" + ? paragraph.children.find( + (child) => + child.type === "link" && child.position?.end.offset === end, + ) + : undefined; + const linkStart = link?.position?.start.offset; + const linkEnd = link?.position?.end.offset; + const pubkey = link?.type === "link" ? profileKey(link.url) : undefined; + const name = + link?.type === "link" && + link.children.length === 1 && + link.children[0]?.type === "text" + ? link.children[0].value + : undefined; + const assignee = + pubkey && + name && + linkStart !== undefined && + linkEnd === end && + link?.type === "link" && + !link.title && + content + .slice(linkStart, linkEnd) + .endsWith(`](${profileTarget(pubkey)})`) && + content.slice(linkStart - assignment.length, linkStart) === assignment + ? { pubkey, name, start: linkStart - assignment.length } + : undefined; + const named = assignee + ? null + : / · Assignee: ([\p{L}\p{N}][\p{L}\p{N} .’'_-]*)$/u.exec( + content.slice(offset, end), + ); + const namedText = + paragraph?.type === "paragraph" && + paragraph.children.some( + (child) => + child.type === "text" && + (child.position?.start.offset ?? Infinity) <= + offset + (named?.index ?? 0) && + (child.position?.end.offset ?? -1) >= end, + ); + const namedAssignee = + named?.[1] && namedText + ? { name: named[1].trim(), start: offset + named.index } + : undefined; + items.push({ + offset: offset + match[1].length, + label: content + .slice( + offset + match[0].length - match[3].length, + assignee?.start ?? namedAssignee?.start ?? end, ) - : undefined; - const linkStart = link?.position?.start.offset; - const linkEnd = link?.position?.end.offset; - const pubkey = link?.type === "link" ? profileKey(link.url) : undefined; - const name = - link?.type === "link" && - link.children.length === 1 && - link.children[0]?.type === "text" - ? link.children[0].value - : undefined; - const assignee = - pubkey && - name && - linkStart !== undefined && - linkEnd === end && - link?.type === "link" && - !link.title && - content - .slice(linkStart, linkEnd) - .endsWith(`](${profileTarget(pubkey)})`) && - content.slice(linkStart - assignment.length, linkStart) === assignment - ? { pubkey, name, start: linkStart - assignment.length } - : undefined; - items.push({ - offset: offset + match[1].length, - label: content - .slice( - offset + match[0].length - match[3].length, - assignee?.start ?? end, - ) - .trim(), - status: match[2] === " " ? "todo" : match[2] === "/" ? "doing" : "done", - end, - assignee, - }); + .trim(), + status: + match[2] === " " ? "todo" : match[2] === "/" ? "doing" : "done", + end, + assignee, + ...(namedAssignee ? { namedAssignee } : {}), + parents, + }); + } + const todo = items.length > before ? items[items.length - 1] : undefined; + for (const child of item.children) + if (child.type === "list" && !child.ordered) + visit(child.children, todo ? [...parents, todo.label] : parents); } } + for (const node of section) + if (node.type === "list" && !node.ordered) visit(node.children, []); return { items, start }; } @@ -133,7 +166,7 @@ export function assignTodo( assignee?.name.replace(/\s+/g, " ").trim() || assignee?.pubkey || ""; const escaped = name.replace(/[\\`*_{}[\]()#+\-.!<>|&]/g, "\\$&"); const suffix = assignee ? `${assignment}[${escaped}](${target})` : ""; - const start = item.assignee?.start ?? item.end; + const start = item.assignee?.start ?? item.namedAssignee?.start ?? item.end; const next = content.slice(0, start) + suffix + content.slice(item.end); const parsed = readTodos(next); const edited = parsed.items.find((value) => value.offset === offset); @@ -147,3 +180,23 @@ export function assignTodo( ); return next; } + +/** Match the inline title renderer without exposing Markdown syntax in control names. */ +export function todoLabelText(label: string): string { + const text = (node: Root | RootContent): string => + "children" in node + ? node.children.map(text).join("") + : node.type === "text" || node.type === "inlineCode" + ? node.value + : node.type === "image" + ? (node.alt ?? "") + : node.type === "break" + ? " " + : ""; + return text( + fromMarkdown(label, { + extensions: [gfmStrikethrough()], + mdastExtensions: [gfmStrikethroughFromMarkdown()], + }), + ); +} diff --git a/tests/browser/github-body.spec.mjs b/tests/browser/github-body.spec.mjs index f109af56bc..7c629e3d17 100644 --- a/tests/browser/github-body.spec.mjs +++ b/tests/browser/github-body.spec.mjs @@ -17,7 +17,7 @@ const picture = const redirected = "https://objects.githubusercontent.com/github-production-user-asset-6210df/659873452-after.mp4"; const videoPath = fileURLToPath( - new URL("../fixtures/message-gallery/assets/sample.mp4", import.meta.url), + new URL("../fixtures/github-body.mp4", import.meta.url), ); const imagePath = fileURLToPath( new URL("../fixtures/attachment-media/still.png", import.meta.url), @@ -126,9 +126,9 @@ test("PR media plays under the packaged media policy in a narrow GitHub panel", ).toHaveCSS("list-style-type", "disc"); for (let index = 0; index < 2; index++) { const video = videos.nth(index); - await panel + const player = video.locator(".."); + await player .getByRole("button", { name: "Play video", exact: true }) - .nth(index) .click(); await expect .poll(() => video.evaluate((element) => element.currentTime)) @@ -136,9 +136,12 @@ test("PR media plays under the packaged media policy in a narrow GitHub panel", await expect .poll(() => video.evaluate((element) => element.videoWidth)) .toBeGreaterThan(0); - await panel + await player .getByRole("button", { name: "Pause video", exact: true }) .click(); + await expect + .poll(() => video.evaluate((element) => element.paused)) + .toBe(true); } for (const source of [before, after]) await expect(panel.locator(`a[href="${source}"]`)).toHaveCount(0); diff --git a/tests/browser/todos.spec.mjs b/tests/browser/todos.spec.mjs index a302fe274d..43e459bb70 100644 --- a/tests/browser/todos.spec.mjs +++ b/tests/browser/todos.spec.mjs @@ -33,7 +33,7 @@ test("opt-in Todos saves ordinary Canvas and disabling leaves it editable", asyn app, }) => { const original = - "# Notes\nKeep this prose.\n\n## Todos\n\n- [ ] First\n\n## Decisions\nKeep these too.\n"; + "# Notes\nKeep this prose.\n\n## Todos\n\n- [ ] First\n - [ ] Build\n - [/] Review\n - [ ] **Read** [guide](https://example.com) [Beta](buzz://channel/beta)\n- [ ] Legacy · Assignee: John\n\n## Decisions\nKeep these too.\n"; let head = app.sign({ kind: 40100, content: original, @@ -94,6 +94,41 @@ test("opt-in Todos saves ordinary Canvas and disabling leaves it editable", asyn exact: true, }); const input = drawer.getByRole("textbox", { name: "New todo" }); + const nested = drawer.getByRole("checkbox", { name: "Review", exact: true }); + await expect(nested).toBeVisible(); + const first = drawer.getByRole("checkbox", { name: "First", exact: true }); + const build = drawer.getByRole("checkbox", { name: "Build", exact: true }); + const positions = await Promise.all([ + first.boundingBox(), + build.boundingBox(), + nested.boundingBox(), + ]); + expect(positions[1].x).toBeGreaterThan(positions[0].x); + expect(positions[2].x).toBeGreaterThan(positions[1].x); + await expect(drawer.getByText("First / Build", { exact: true })).toHaveCount( + 0, + ); + await drawer + .getByRole("button", { name: "Collapse First", exact: true }) + .click(); + await expect(nested).toBeHidden(); + await drawer + .getByRole("button", { name: "Expand First", exact: true }) + .click(); + await expect(nested).toBeVisible(); + await expect( + drawer.getByRole("link", { name: "guide", exact: true }), + ).toHaveAttribute("href", "https://example.com/"); + await expect(drawer.locator("strong", { hasText: "Read" })).toBeVisible(); + await expect( + drawer.getByRole("button", { + name: "Assignee for Legacy: John", + exact: true, + }), + ).toHaveAttribute( + "aria-description", + "John (from Canvas; choose a member to link)", + ); await input.fill("Ship it"); await input.press("Enter"); await expect(drawer.getByRole("status")).toHaveText("Saved in Canvas"); @@ -119,6 +154,14 @@ test("opt-in Todos saves ordinary Canvas and disabling leaves it editable", asyn `## Todos\n\n- [x] Ship it · Assignee: [Fixture Reader](nostr:${npubEncode(app.viewer)})\n`, ), ); + const beforeChildEdit = head.content; + const child = drawer.getByRole("checkbox", { name: "Build", exact: true }); + await child.focus(); + await child.press("Space"); + await expect(child).toBeChecked(); + await expect(drawer.getByRole("status")).toHaveText("Saved in Canvas"); + expect(writes).toHaveLength(4); + expect(head.content).toBe(beforeChildEdit.replace("[ ] Build", "[x] Build")); const conversation = await page .getByRole("article", { name: "Conversation", exact: true }) .boundingBox(); @@ -133,6 +176,7 @@ test("opt-in Todos saves ordinary Canvas and disabling leaves it editable", asyn // clicking the launcher, which moves when the conversation expands. await expect(dock).toHaveCount(0); await launcher.click(); + await expect(child).toBeChecked(); await expect(assignee).toHaveAttribute("aria-description", /Fixture Reader/); await assignee.click(); await page @@ -202,7 +246,7 @@ test("opt-in Todos saves ordinary Canvas and disabling leaves it editable", asyn ); expect(fits).toBe(true); await expect(assignee).toHaveAttribute("data-size", "sm"); - await expect(assignee).toHaveAttribute("data-icon-variant", "avatar"); + await expect(assignee).toContainText("Fixture Reader"); }; // Channel-specific drawers stay off other pages, but returning to the same // channel can reopen its Canvas-backed content with a live launcher. @@ -267,6 +311,18 @@ test("opt-in Todos saves ordinary Canvas and disabling leaves it editable", asyn await expect( drawer.getByRole("checkbox", { name: "Ship it", exact: true }), ).toBeChecked(); + await drawer.getByRole("link", { name: "Beta", exact: true }).click(); + await expect( + page + .getByRole("article", { name: "Conversation" }) + .getByRole("tab", { name: "Beta", exact: true }), + ).toBeVisible(); + await sidebar.getByRole("button", { name: "Alpha", exact: true }).click(); + await expect( + page + .getByRole("article", { name: "Conversation" }) + .getByRole("tab", { name: "Alpha", exact: true }), + ).toBeVisible(); await plugins(); await enabled.click(); await expect(enabled).toHaveAccessibleName(/^Enable /); diff --git a/tests/fixtures/github-body.md b/tests/fixtures/github-body.md new file mode 100644 index 0000000000..7d51460489 --- /dev/null +++ b/tests/fixtures/github-body.md @@ -0,0 +1,9 @@ +# GitHub body playback fixture + +`github-body.mp4` repeats the message-gallery sample at its normal playback rate for 30 seconds. The longer clip keeps Play/Pause checks independent of browser actionability delays, without changing playback speed or looping in WebKit. Each player is exercised through its own controls. + +Regenerate from the repository root with FFmpeg: + +```sh +ffmpeg -stream_loop -1 -i tests/fixtures/message-gallery/assets/sample.mp4 -t 30 -vf 'fps=10,scale=320:180' -an -c:v libx264 -pix_fmt yuv420p -crf 30 -movflags +faststart tests/fixtures/github-body.mp4 +``` diff --git a/tests/fixtures/github-body.mp4 b/tests/fixtures/github-body.mp4 new file mode 100644 index 0000000000..e55a2a414e Binary files /dev/null and b/tests/fixtures/github-body.mp4 differ