quoted-text.ts5.9 KBView on GitHub /**
* Client-side mirror of the server quoted-text collapse in
* apps/server/src/services/mail/lib/email-processor.ts.
*
* Optimistically-rendered sent replies are shown before the server has processed
* and synced them back. Without this, the raw `gmail_quote` block renders fully
* expanded (and unstyled) during the optimistic window, then snaps to the
* collapsed "•••" toggle once sync lands. Running the optimistic body through the
* same collapse here makes the optimistic render identical to the final one.
*
* Keep the summary markup and CSS in sync with email-processor.ts.
*/
const QUOTED_TOGGLE_SUMMARY =
'<summary class="quoted-toggle-summary" aria-label="Show quoted text">' +
'<span class="quoted-toggle-dots" aria-hidden="true"><span></span><span></span><span></span></span>' +
'<span class="quoted-toggle-srtext">Show quoted text</span>' +
'</summary>';
const QUOTED_TOGGLE_STYLE = `<style type="text/css">
details.quoted-toggle { margin-top: 0.75rem; }
.quoted-toggle-content { margin-top: 6px; padding-left: 10px; border-left: 2px solid rgba(128, 128, 128, 0.4); }
details.quoted-toggle summary {
display: inline-flex; align-items: center; width: fit-content;
padding: 4px; border-radius: 8px; cursor: pointer;
list-style: none; user-select: none;
}
details.quoted-toggle summary::-webkit-details-marker { display: none; }
details.quoted-toggle summary::marker { content: ''; }
.quoted-toggle-dots { display: inline-flex; align-items: center; gap: 3px; line-height: 0; }
.quoted-toggle-dots > span { width: 5px; height: 5px; border-radius: 50%; display: inline-block; }
:host(:not([data-theme="dark"])) details.quoted-toggle summary { background: #e8eaed; }
:host([data-theme="dark"]) details.quoted-toggle summary { background: #3c4043; }
details.quoted-toggle summary:hover { filter: brightness(0.95); }
:host(:not([data-theme="dark"])) .quoted-toggle-dots > span { background: #5f6368; }
:host([data-theme="dark"]) .quoted-toggle-dots > span { background: #bdc1c6; }
.quoted-toggle-srtext {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
</style>`;
/**
* Wrap `blockquote` / `.gmail_quote` blocks in a collapsed `<details class="quoted-toggle">`
* (skipping already-wrapped nested quotes) and prepend the matching styles, mirroring
* the server. Returns the input unchanged when there is no quote to collapse or when
* the DOM is unavailable (non-browser context).
*/
export function wrapQuotedTextForDisplay(html: string): string {
if (!html || typeof window === 'undefined' || typeof DOMParser === 'undefined') return html;
const doc = new DOMParser().parseFromString(html, 'text/html');
let wrapped = false;
const collapse = (selector: string) => {
doc.querySelectorAll(selector).forEach((el) => {
if (el.closest('details.quoted-toggle')) return;
const details = doc.createElement('details');
details.className = 'quoted-toggle';
details.setAttribute('style', 'margin-top:1em;');
details.innerHTML = `${QUOTED_TOGGLE_SUMMARY}<div class="quoted-toggle-content">${el.innerHTML}</div>`;
el.replaceWith(details);
wrapped = true;
});
};
// Collapse the outer `.gmail_quote` container first so its blockquote is skipped
// (closest check) — one toggle per quote level, not a redundant nested toggle.
collapse('.gmail_quote');
collapse('blockquote');
if (!wrapped) return html;
return `${QUOTED_TOGGLE_STYLE}${doc.body.innerHTML}`;
}
/**
* Remove quoted history from a draft body that is about to become **editor
* content**.
*
* Agent drafts are persisted with a `gmail_quote` block already appended
* server-side (`appendGmailQuote` in services/drafting/gmail-format.ts), which is
* what makes a send from the user's external client match a native Gmail reply.
* Feeding that body straight to TipTap put the entire prior message — including
* the mail renderer's `<style>` blob — into the editor, so the user was editing
* the quoted history. Gmail keeps the quote out of the editable flow; so do we.
*
* This is a strip, not a collapse: `wrapQuotedTextForDisplay`'s `<details>` toggle
* is display markup that TipTap has no node for. The quote is re-appended on send
* by `constructReplyBody`, which is idempotent, so a body that somehow kept its
* quote is still only quoted once.
*
* Returns the input unchanged when there is nothing to strip or the DOM is
* unavailable (non-browser context) — the editor is only ever built client-side.
*/
export function stripQuotedForEditing(html: string): string {
if (!html || typeof window === 'undefined' || typeof DOMParser === 'undefined') return html;
const doc = new DOMParser().parseFromString(html, 'text/html');
const quotes = doc.querySelectorAll(QUOTED_HISTORY_SELECTOR);
if (!quotes.length) return html;
// Removing a container detaches its nested quotes too; remove() on an
// already-detached node is a no-op, so the static NodeList is safe to walk.
quotes.forEach((el) => el.remove());
return doc.body.innerHTML;
}
/**
* What counts as QUOTED HISTORY for the purposes of deleting it.
*
* Narrower than the display collapse above, which also folds a bare `blockquote` — and it
* has to be, because the two do different things to what they match. Collapsing a bare
* blockquote hides content behind a toggle and is recoverable; DELETING one throws away a
* pull-quote the user or the agent wrote, from a draft they are about to send, with nothing
* to undo it. So this matches only the markers that mean "a client attached the prior
* message here": Gmail's own classes, and the `type="cite"` other clients emit. A bare
* `<blockquote>` is treated as the user's own words, which is what it usually is.
*/
const QUOTED_HISTORY_SELECTOR =
'.gmail_quote, .gmail_quote_container, blockquote[type="cite"], details.quoted-toggle';