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';