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
19 changes: 19 additions & 0 deletions docs/plans/2026-09-19-conversation-export-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,25 @@ rendered file match what the visitor saw.
comments entirely. The widget shows such text literally, so the escaped
form is also the more faithful one. Inline HTML later in a line is left
alone: it cannot extend past its own paragraph.

One exception: a line-leading autolink, `<https://example.com>` or
`<help@example.com>`, is left alone. It can never open an HTML block, and
escaping it shows the brackets and links the wrong destination.

Neither form can let a block through, for two different reasons. The
address form must start with a letter or digit, because `!` and `?` begin
the comment, processing instruction, declaration, and CDATA blocks:
`<!--a@b>` is an unterminated comment wearing an address, not an autolink.
That holds even where CommonMark would accept the address, as it does for
`<?q@example.com>`: hiding the transcript is the worse outcome. A
`<script`, `<pre`, `<style`, or `<textarea` block does start with a
letter, but its tag name must be followed by whitespace, `>`, or the end
of the line, and neither `:` nor `@` is any of those, so neither form can
match one. Loosening either form, by allowing whitespace in the address
or dropping the closing `>`, would break that. The first version of this
exception accepted any local part, shipped in 1.17.0, and let such a line
hide the rest of a rendered transcript.

4. **Line endings are normalized** to `\n`, and trailing blank lines dropped,
unless the text ends inside an open code block. There the trailing lines
are code, so they are left alone and only the block is closed.
Expand Down
27 changes: 27 additions & 0 deletions widget/src/utils/__tests__/exportConversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,33 @@ describe("protectMessageText", () => {
expect(protectMessageText("<help@example.com>")).toBe(
"<help@example.com>",
);
expect(protectMessageText("<a.b-c+d@example.co.uk>")).toBe(
"<a.b-c+d@example.co.uk>",
);
});

it("still escapes a block opener dressed up as an email autolink", () => {
// Each of these opens an HTML block that runs across blank lines until a
// terminator that never comes, so it would hide every later message.
expect(protectMessageText("<!--a@b>")).toBe("\\<!--a@b>");
expect(protectMessageText("<!--noreply@example.com>")).toBe(
"\\<!--noreply@example.com>",
);
expect(protectMessageText("<?a@b>")).toBe("\\<?a@b>");
expect(protectMessageText("<![CDATA[a@b>")).toBe("\\<![CDATA[a@b>");
expect(protectMessageText("<!--https://user@host/x>")).toBe(
"\\<!--https://user@host/x>",
);
});

it("escapes an address that is a valid autolink and a block opener at once", () => {
// CommonMark allows "?" and "!" in an address, so these really are
// autolinks. They also open a processing instruction and a declaration
// that never close, and hiding the transcript is the worse outcome.
expect(protectMessageText("<?q@example.com>")).toBe("\\<?q@example.com>");
expect(protectMessageText("<!weird@example.com>")).toBe(
"\\<!weird@example.com>",
);
});
});

Expand Down
52 changes: 52 additions & 0 deletions widget/src/utils/__tests__/saveText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,58 @@ describe("copyText", () => {
expect(document.activeElement).toBe(button);
});

it("still resolves when returning focus after the fallback throws", async () => {
const button = document.createElement("button");
document.body.appendChild(button);
button.focus();
vi.spyOn(button, "focus").mockImplementation(() => {
throw new Error("blocked");
});
(document as Doc).execCommand = vi.fn(() => true);

// The copy itself worked, so tidying up must not turn it into a failure.
await expect(copyText("hello")).resolves.toBe(true);
expect(document.querySelector("textarea")).toBeNull();
});

it("resolves false when even reading the focused element throws", async () => {
// Nothing in the fallback may reach the caller as a rejection, including
// the statements that run before its own try block.
vi.spyOn(document, "activeElement", "get").mockImplementation(() => {
throw new Error("blocked");
});
(document as Doc).execCommand = vi.fn(() => true);

await expect(copyText("hello")).resolves.toBe(false);
});

it("keeps the temporary textarea out of the tab order", async () => {
// If it can ever be left behind, it must not become a keyboard stop that
// holds the whole transcript while hidden from assistive technology.
let tabIndex: number | undefined;
(document as Doc).execCommand = vi.fn(() => {
tabIndex = document.querySelector("textarea")?.tabIndex;
return true;
});

await copyText("hello");

expect(tabIndex).toBe(-1);
});

it("returns focus even when removing the textarea throws", async () => {
const button = document.createElement("button");
document.body.appendChild(button);
button.focus();
vi.spyOn(HTMLTextAreaElement.prototype, "remove").mockImplementation(() => {
throw new Error("blocked");
});
(document as Doc).execCommand = vi.fn(() => true);

await expect(copyText("hello")).resolves.toBe(true);
expect(document.activeElement).toBe(button);
});

it("returns focus to where it was after the fallback", async () => {
const button = document.createElement("button");
document.body.appendChild(button);
Expand Down
11 changes: 10 additions & 1 deletion widget/src/utils/exportConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,17 @@ const FENCE_OPEN = /^([ \t]*)(`{3,}|~{3,})(.*)$/;
// A line-leading "<" that CommonMark could read as the start of an HTML block.
// An autolink (<scheme:host/path> or <user@host>) never opens one, and
// escaping it would show the brackets and link the wrong destination.
//
// Why neither exception can let a block through. The address must start with
// a letter or digit, because "!" and "?" begin the comment, processing
// instruction, declaration and CDATA blocks: `<!--a@b>` is an unterminated
// comment wearing an address, not an autolink. A `<script`, `<pre`, `<style`
// or `<textarea` block does start with a letter, but its tag name must be
// followed by whitespace, ">", or the end of the line, and neither ":" nor
// "@" is any of those, so neither alternative can match one. Loosening either
// alternative (whitespace in the address, no closing ">") would break that.
const HTML_BLOCK_START =
/^( {0,3})<(?![A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\s]*>|[^\s<>@]+@[^\s<>]+>)(?=[A-Za-z!?/])/;
/^( {0,3})<(?![A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\s]*>|[A-Za-z0-9][^\s<>@]*@[^\s<>]+>)(?=[A-Za-z!?/])/;
const INDENTED = /^(?: {4}|\t)/;

/** Leading whitespace as columns, with tabs at four-column stops. */
Expand Down
27 changes: 23 additions & 4 deletions widget/src/utils/saveText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ function legacyCopy(text: string): boolean {
area.value = text;
area.setAttribute("readonly", "");
area.setAttribute("aria-hidden", "true");
// Out of the tab order: if removal ever fails, it must not be left behind
// as a keyboard stop holding the transcript, hidden from assistive tech.
area.tabIndex = -1;
area.style.position = "fixed";
area.style.top = "0";
area.style.left = "0";
Expand All @@ -26,9 +29,19 @@ function legacyCopy(text: string): boolean {
return false;
} finally {
// In a finally, so a refusal cannot leave the textarea on the page or
// the visitor's focus inside it.
area?.remove();
previous?.focus();
// the visitor's focus inside it. Each step is guarded on its own: a throw
// here would replace the result above and reject the caller's promise,
// and a failed removal must not cost the visitor their focus as well.
try {
area?.remove();
} catch {
// Nothing more can be done about the textarea.
}
try {
previous?.focus();
} catch {
// Nor about focus.
}
}
}

Expand All @@ -51,7 +64,13 @@ export async function copyText(text: string): Promise<boolean> {
} catch {
// Refused. Try the legacy path.
}
return legacyCopy(text);
try {
return legacyCopy(text);
} catch {
// legacyCopy guards its own steps, but "never rejects" should not depend
// on auditing each statement in it, including the ones before its try.
return false;
}
}

// Revoking straight away cancels the download in some browsers. FileSaver.js
Expand Down
Loading