feat: let plugins see and act on text selected in a message, with a right-click menu and the side panel's selection

This commit is contained in:
Linus Rath
2026-10-20 17:21:12 +02:00
parent 00142540f6
commit 8deb887e95
7 changed files with 439 additions and 5 deletions
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { act, createEvent, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { Email, Mailbox, ThreadGroup } from '@/lib/jmap/types';
@@ -136,6 +136,66 @@ describe('email-detail-sidebar', () => {
});
});
describe('reader-selection-menu and the side panel\'s selection', () => {
const SURFACE = 'reader-selection-menu';
const plainBody = () => document.querySelector<HTMLElement>('[data-plugin-source="email-body"]')!;
const selectBody = () => {
const range = document.createRange();
range.selectNodeContents(plainBody());
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
};
const rightClick = () => {
const event = createEvent.contextMenu(plainBody(), { clientX: 30, clientY: 40 });
fireEvent(plainBody(), event);
return event;
};
afterEach(() => {
vi.useRealTimers();
window.getSelection()?.removeAllRanges();
});
it('leaves the browser menu alone without plugin entries, and without a selection', () => {
viewer(textMail('Hi there'));
selectBody();
expect(rightClick().defaultPrevented).toBe(false);
expect(screen.queryByRole('menu')).toBeNull();
act(() => { setPluginActions('p1', 'P1', SURFACE, [{ id: 'explain', label: 'Explain' }]); });
window.getSelection()!.removeAllRanges();
expect(rightClick().defaultPrevented).toBe(false);
expect(screen.queryByRole('menu')).toBeNull();
});
it('offers Copy and the plugin entries on selected text, and hands the click the message and the text', async () => {
act(() => { setPluginActions('p1', 'P1', SURFACE, [{ id: 'explain', label: 'Explain', icon: 'sparkles' }]); });
viewer(textMail('Hi there'));
selectBody();
expect(rightClick().defaultPrevented).toBe(true);
expect(screen.getByRole('menu')).toHaveTextContent('copy');
fireEvent.click(screen.getByRole('menuitem', { name: 'Explain' }));
await act(async () => {});
expect(dispatch).toHaveBeenCalledWith('p1', SURFACE, 'explain', { emailId: 'e1', selection: { text: 'Hi there' } });
expect(screen.queryByRole('menu')).toBeNull();
});
it('sends the side panel the selected text a moment after it changes', () => {
offerSlots('p1', ['email-detail-sidebar']);
viewer(textMail('Hi there'));
expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).selection).toEqual({ text: '' });
vi.useFakeTimers();
selectBody();
act(() => { document.dispatchEvent(new Event('selectionchange')); });
expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).selection).toEqual({ text: '' });
act(() => { vi.advanceTimersByTime(200); });
expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).selection).toEqual({ text: 'Hi there' });
});
});
describe('reader-header and reader-below-subject', () => {
it('put the frames above the subject and under the sender, with the message', () => {
offerSlots('p1', ['reader-header', 'reader-below-subject']);
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { uiHooks } from '@/lib/plugin-hooks';
import { bodyMenuPoint, selectedTextIn, watchBodySelection, watchElementSelection } from '../reader-selection-menu';
// Selected text inside a message body, for plugins. An HTML body is a
// sandboxed iframe: its selection never reaches the page's selectionchange,
// so the body document itself is watched.
function bodyFrame(html: string) {
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const doc = iframe.contentDocument!;
doc.body.innerHTML = html;
return { iframe, doc };
}
function selectAll(doc: Document, el: Element) {
const range = doc.createRange();
range.selectNodeContents(el);
const sel = doc.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
}
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});
describe('watchBodySelection', () => {
it('reports the selected text a moment after it changes, once per change, and tells the plugins', async () => {
const { iframe, doc } = bodyFrame('<p>Quoted words</p>');
const onSelection = vi.fn();
const heard = vi.fn();
const handler = uiHooks.onTextSelectionChange.register('p1', heard);
try {
watchBodySelection(doc, iframe, { emailId: 'e1', onSelection });
selectAll(doc, doc.querySelector('p')!);
doc.dispatchEvent(new Event('selectionchange'));
doc.dispatchEvent(new Event('selectionchange'));
expect(onSelection).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(onSelection).toHaveBeenCalledTimes(1);
expect(onSelection).toHaveBeenCalledWith('Quoted words');
await Promise.resolve();
expect(heard).toHaveBeenCalledWith({ text: 'Quoted words', source: 'email-body', emailId: 'e1' });
// The same selection again is not reported again.
doc.dispatchEvent(new Event('selectionchange'));
vi.advanceTimersByTime(200);
expect(onSelection).toHaveBeenCalledTimes(1);
// Gone: reported as empty, but plugins are not told about nothing.
doc.getSelection()!.removeAllRanges();
doc.dispatchEvent(new Event('selectionchange'));
vi.advanceTimersByTime(200);
expect(onSelection).toHaveBeenLastCalledWith('');
await Promise.resolve();
expect(heard).toHaveBeenCalledTimes(1);
} finally {
handler.dispose();
}
});
it('opens the menu on a right-click on selected text, at the frame offset, and keeps the browser menu otherwise', () => {
const { iframe, doc } = bodyFrame('<p>Quoted words</p>');
iframe.getBoundingClientRect = () => ({ left: 100, top: 50, width: 600, height: 400, right: 700, bottom: 450, x: 100, y: 50, toJSON: () => ({}) });
Object.defineProperty(doc.documentElement, 'clientWidth', { value: 600, configurable: true });
const onContextMenu = vi.fn().mockReturnValue(true);
watchBodySelection(doc, iframe, { emailId: 'e1', onContextMenu });
// No selection: nothing happens.
const idle = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, cancelable: true, bubbles: true });
doc.dispatchEvent(idle);
expect(onContextMenu).not.toHaveBeenCalled();
expect(idle.defaultPrevented).toBe(false);
selectAll(doc, doc.querySelector('p')!);
const click = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, cancelable: true, bubbles: true });
doc.dispatchEvent(click);
expect(onContextMenu).toHaveBeenCalledWith({ x: 110, y: 70 }, 'Quoted words');
expect(click.defaultPrevented).toBe(true);
// The menu declined (no plugin entries): the browser's own menu stays.
onContextMenu.mockReturnValue(false);
const declined = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, cancelable: true, bubbles: true });
doc.dispatchEvent(declined);
expect(declined.defaultPrevented).toBe(false);
});
it('scales the point when the body was shrunk to fit a narrow screen', () => {
const { iframe, doc } = bodyFrame('<p>x</p>');
iframe.getBoundingClientRect = () => ({ left: 0, top: 100, width: 300, height: 400, right: 300, bottom: 500, x: 0, y: 100, toJSON: () => ({}) });
Object.defineProperty(doc.documentElement, 'clientWidth', { value: 600, configurable: true });
expect(bodyMenuPoint({ clientX: 200, clientY: 40 }, doc, iframe)).toEqual({ x: 100, y: 120 });
});
it('stops when told to', () => {
const { iframe, doc } = bodyFrame('<p>Quoted words</p>');
const onSelection = vi.fn();
const stop = watchBodySelection(doc, iframe, { emailId: 'e1', onSelection });
stop();
selectAll(doc, doc.querySelector('p')!);
doc.dispatchEvent(new Event('selectionchange'));
vi.advanceTimersByTime(200);
expect(onSelection).not.toHaveBeenCalled();
});
});
describe('a plain-text body in the page', () => {
it('counts only a selection inside the body', () => {
document.body.innerHTML = '<div id="other">elsewhere</div><div id="body"><p>Plain words</p></div>';
const body = document.getElementById('body')!;
selectAll(document, document.getElementById('other')!);
expect(selectedTextIn(body)).toBe('');
selectAll(document, body.querySelector('p')!);
expect(selectedTextIn(body)).toBe('Plain words');
});
it('reports changes of the page selection inside the body', () => {
document.body.innerHTML = '<div id="body"><p>Plain words</p></div>';
const body = document.getElementById('body')!;
const onSelection = vi.fn();
watchElementSelection(body, { emailId: 'e1', onSelection });
selectAll(document, body.querySelector('p')!);
document.dispatchEvent(new Event('selectionchange'));
vi.advanceTimersByTime(200);
expect(onSelection).toHaveBeenCalledWith('Plain words');
});
});
+32 -2
View File
@@ -18,6 +18,7 @@ import { useCopyLink } from "@/hooks/use-copy-link";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { SubMenuPanel } from "@/components/ui/context-menu";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu";
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide } from "@/lib/toolbar-overflow";
@@ -994,6 +995,7 @@ function DetailsCategorySlot({ email, category }: { email: EmailReadView | null;
/** Narrowest the message may get next to a plugin panel before the panel collapses. */
const MIN_READER_WIDTH = 480;
const NO_BODY_SELECTION = { text: '' };
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
return (
@@ -1510,6 +1512,18 @@ export function EmailViewer({
[email, wantsPluginEmailView],
);
const pluginEmailProps = useMemo(() => ({ email: pluginEmailView }), [pluginEmailView]);
// The text selected in the body, for the side panel (a plugin working on
// what the user highlights) and the right-click menu on it. Reset per
// message; the body documents report changes a moment after they happen.
const [bodySelection, setBodySelection] = useState<{ text: string }>(NO_BODY_SELECTION);
useEffect(() => { setBodySelection(NO_BODY_SELECTION); }, [email?.id]);
const onBodySelection = useCallback((text: string) => {
setBodySelection((prev) => (prev.text === text ? prev : (text ? { text } : NO_BODY_SELECTION)));
}, []);
const pluginDetailProps = useMemo(() => ({ email: pluginEmailView, selection: bodySelection }), [pluginEmailView, bodySelection]);
const selectionMenu = useReaderSelectionMenu();
const emailIdRef = useRef(email?.id);
emailIdRef.current = email?.id;
const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]);
const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]);
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState<boolean>(() => {
@@ -2947,6 +2961,11 @@ export function EmailViewer({
const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc;
// The plain-text body, where reader-above-quote mounts between text and quote.
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
// A plain-text body sits in the page: watch the page's selection for it.
useEffect(() => {
if (!plainBodyEl || !hasDetailSidebar) return;
return watchElementSelection(plainBodyEl, { emailId: email?.id, onSelection: onBodySelection });
}, [plainBodyEl, hasDetailSidebar, email?.id, onBodySelection]);
// One object per body: React writes innerHTML again whenever this object
// changes, which would drop what was mounted in the body and fold the quote
// back up on every render.
@@ -3194,12 +3213,19 @@ export function EmailViewer({
// A press in the body closes a mention's card. The page never sees
// it: the focus is already inside the iframe, so there is no blur.
doc.addEventListener('mousedown', () => setBodyMention(null), true);
// Selected text, for the side panel, the onTextSelectionChange hook
// and the right-click menu plugins may add entries to.
watchBodySelection(doc, iframe, {
emailId: email?.id,
onSelection: (text) => { if (iframe.contentDocument === doc) onBodySelection(text); },
onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: emailIdRef.current, text }),
});
}
} catch {
// Cross-origin restrictions - iframe will still display content
}
}, [isDark, effectiveEmailContent.externalBlocked, email?.id, t, openCidPartLink]);
}, [isDark, effectiveEmailContent.externalBlocked, email?.id, t, openCidPartLink, onBodySelection, selectionMenu.openRef]);
// Wire up the iframe as soon as its sandboxed document has parsed, rather than
// waiting for the iframe 'load' event. 'load' also waits on every subresource,
@@ -6074,6 +6100,9 @@ export function EmailViewer({
ref={setPlainBodyEl}
className="email-content-text text-foreground"
dir="auto"
data-plugin-source="email-body"
data-email-id={email?.id}
onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email?.id)}
dangerouslySetInnerHTML={plainBodyHtml}
style={{
...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }),
@@ -6349,6 +6378,7 @@ export function EmailViewer({
</nav>
)}
<ReaderSelectionMenu menu={selectionMenu} />
{/* Plugin Detail Sidebar - resizable, collapsible */}
{hasDetailSidebar && !isMobile && (
<>
@@ -6407,7 +6437,7 @@ export function EmailViewer({
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<PluginSlot name="email-detail-sidebar" extraProps={pluginEmailProps} />
<PluginSlot name="email-detail-sidebar" extraProps={pluginDetailProps} />
</div>
</div>
</>
+198
View File
@@ -0,0 +1,198 @@
"use client";
// Selected text in a message body, for plugins: the right-click menu on it
// (the reader-selection-menu surface, api.ui.setActions), the selection the
// reader's side panel gets as a prop, and the onTextSelectionChange hook.
// An HTML body lives in a sandboxed iframe whose selection the page never
// sees, so the body's own document is watched; a plain-text body sits in the
// page and is watched through the document's selection.
//
// The menu only replaces the browser's own when a plugin put entries in it.
// Copy is kept, since the menu replaces the one that had it.
import { useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
import { useContextMenu } from "@/hooks/use-context-menu";
import { Copy } from "@/components/icons";
import { uiHooks } from "@/lib/plugin-hooks";
export const READER_SELECTION_SURFACE = "reader-selection-menu";
/** How long after the last change the selection goes to plugins. */
export const BODY_SELECTION_DELAY_MS = 150;
export interface ReaderSelectionTarget {
emailId: string | undefined;
text: string;
}
export interface Point { x: number; y: number }
/**
* Where a contextmenu event inside a body iframe is on the page: the frame's
* offset plus the event's position, scaled when the body was shrunk to fit a
* narrow screen. The keyboard (Shift+F10, the Menu key) fires the event at
* 0,0: then the point is under the selection.
*/
export function bodyMenuPoint(
e: { clientX: number; clientY: number },
doc: Document,
frame: HTMLIFrameElement | null,
): Point {
let x = e.clientX;
let y = e.clientY;
if (!x && !y) {
const sel = doc.getSelection();
const rect = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).getBoundingClientRect() : null;
if (rect && (rect.width || rect.height)) {
x = rect.left;
y = rect.bottom;
}
}
if (!frame) return { x, y };
const frameRect = frame.getBoundingClientRect();
const laidOut = doc.documentElement.clientWidth;
const scale = laidOut > 0 && frameRect.width > 0 ? frameRect.width / laidOut : 1;
return { x: frameRect.left + x * scale, y: frameRect.top + y * scale };
}
export interface BodySelectionWatch {
emailId: string | undefined;
/** The selected text as it changes; '' when the selection is gone. */
onSelection?: (text: string) => void;
/** A right-click on selected text. Return true when a menu was opened (the browser's is then suppressed). */
onContextMenu?: (point: Point, text: string) => boolean;
delayMs?: number;
}
function announce(text: string, emailId: string | undefined): void {
if (!text || uiHooks.onTextSelectionChange.size === 0) return;
void uiHooks.onTextSelectionChange.emit({ text, source: "email-body", emailId });
}
/**
* Watch the document of a body iframe. Returns a function that stops
* watching; the listeners also die with the document.
*/
export function watchBodySelection(doc: Document, frame: HTMLIFrameElement | null, opts: BodySelectionWatch): () => void {
const delay = opts.delayMs ?? BODY_SELECTION_DELAY_MS;
let timer: ReturnType<typeof setTimeout> | null = null;
let last = "";
const read = () => doc.getSelection()?.toString() ?? "";
const publish = () => {
timer = null;
const text = read();
if (text === last) return;
last = text;
opts.onSelection?.(text);
announce(text, opts.emailId);
};
const onSelectionChange = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(publish, delay);
};
const onContextMenu = (e: MouseEvent) => {
const text = read();
if (!text.trim() || !opts.onContextMenu) return;
if (opts.onContextMenu(bodyMenuPoint(e, doc, frame), text)) e.preventDefault();
};
doc.addEventListener("selectionchange", onSelectionChange);
doc.addEventListener("contextmenu", onContextMenu);
return () => {
if (timer) clearTimeout(timer);
doc.removeEventListener("selectionchange", onSelectionChange);
doc.removeEventListener("contextmenu", onContextMenu);
};
}
/** The text selected inside `el`, from the page's selection; '' when the selection is elsewhere. */
export function selectedTextIn(el: Element): string {
const sel = el.ownerDocument.getSelection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return "";
const anchor = sel.anchorNode;
const focus = sel.focusNode;
if (!anchor || !focus || !el.contains(anchor) || !el.contains(focus)) return "";
return sel.toString();
}
/** Watch a plain-text body that sits in the page itself (no iframe). */
export function watchElementSelection(el: HTMLElement, opts: Pick<BodySelectionWatch, "emailId" | "onSelection" | "delayMs">): () => void {
const delay = opts.delayMs ?? BODY_SELECTION_DELAY_MS;
const doc = el.ownerDocument;
let timer: ReturnType<typeof setTimeout> | null = null;
let last = "";
const publish = () => {
timer = null;
const text = selectedTextIn(el);
if (text === last) return;
last = text;
opts.onSelection?.(text);
// The page-wide listener (PluginAppEvents) tells plugins about this one.
};
const onSelectionChange = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(publish, delay);
};
doc.addEventListener("selectionchange", onSelectionChange);
return () => {
if (timer) clearTimeout(timer);
doc.removeEventListener("selectionchange", onSelectionChange);
};
}
/** The menu's state, and `open` for a right-click on selected text. */
export function useReaderSelectionMenu() {
const actions = usePluginActions(READER_SELECTION_SURFACE);
const menu = useContextMenu<ReaderSelectionTarget>();
const { openContextMenu } = menu;
const enabled = actions.length > 0;
// Body documents are wired once, when they load; they read the latest
// state through this ref.
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const open = useCallback((point: Point, target: ReaderSelectionTarget, e?: React.MouseEvent): boolean => {
if (!enabledRef.current || !target.text.trim()) return false;
openContextMenu({
preventDefault: () => e?.preventDefault(),
stopPropagation: () => e?.stopPropagation(),
clientX: point.x,
clientY: point.y,
} as React.MouseEvent, target);
return true;
}, [openContextMenu]);
const openRef = useRef(open);
openRef.current = open;
/** For a plain-text body in the page: the React contextmenu handler. */
const onElementContextMenu = useCallback((e: React.MouseEvent, emailId: string | undefined) => {
const el = e.currentTarget as HTMLElement;
const text = selectedTextIn(el);
if (!text.trim()) return;
const point = e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : bodyMenuPoint(e, el.ownerDocument, null);
openRef.current(point, { emailId, text }, e);
}, []);
return { ...menu, open, openRef, onElementContextMenu, enabled };
}
export function ReaderSelectionMenu({ menu }: { menu: ReturnType<typeof useReaderSelectionMenu> }) {
const t = useTranslations("email_viewer");
const { contextMenu, closeContextMenu, menuRef } = menu;
if (!contextMenu.isOpen) return null;
const target = contextMenu.data;
const copy = () => {
closeContextMenu();
void navigator.clipboard?.writeText(target?.text ?? "").catch(() => {});
};
return (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<ContextMenuItem icon={Copy} label={t("copy")} testId="reader-selection-copy" onClick={copy} />
<ContextMenuSeparator />
<PluginActions
surface="reader-selection-menu"
variant="menu"
context={{ emailId: target?.emailId, selection: { text: target?.text ?? "" } }}
onRan={closeContextMenu}
/>
</ContextMenu>
);
}
+13 -1
View File
@@ -46,6 +46,7 @@ import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection } from "./reader-selection-menu";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
@@ -548,6 +549,7 @@ export function ConversationEmailBody({
// reader-above-quote: the body the quote was collapsed in, once it is on screen.
const [htmlBodyEl, setHtmlBodyEl] = useState<HTMLElement | null>(null);
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
const selectionMenu = useReaderSelectionMenu();
const wantsQuoteSlot = usePluginSlotOffers('reader-above-quote').length > 0;
const quoteSlotProps = useMemo(
() => ({ email: wantsQuoteSlot ? emailToReadView(email) : null }),
@@ -796,11 +798,17 @@ export function ConversationEmailBody({
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
// Selected text: the onTextSelectionChange hook and the right-click
// menu plugins may add entries to.
watchBodySelection(doc, iframe, {
emailId: email.id,
onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: email.id, text }),
});
setHtmlBodyEl(doc.body);
} catch {
// contentDocument may be inaccessible under stricter sandboxes; ignore.
}
}, [t, isDark, allowExternal]);
}, [t, isDark, allowExternal, email.id, selectionMenu.openRef]);
return (
<div className={className}>
@@ -868,9 +876,13 @@ export function ConversationEmailBody({
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: '13px' }}
data-plugin-source="email-body"
data-email-id={email.id}
onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email.id)}
dangerouslySetInnerHTML={plainBodyHtml}
/>
)}
<ReaderSelectionMenu menu={selectionMenu} />
<ReaderAboveQuoteSlot
root={emailContent.isHtml ? htmlBodyEl : plainBodyEl}
frame={emailContent.isHtml ? iframeRef.current : null}
+2 -1
View File
@@ -70,9 +70,10 @@ export const SLOT_CATALOG = {
'email-footer': S('reader', 'frame', ['ui:email-footer'], { emailId: 'email:read' }, 'below the message body'),
'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read' }, 'above the messages of an open conversation (the phone conversation view and Mountain View; the default desktop layout opens one message at a time and has no conversation view)'),
'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read' }, 'buttons in the open message\'s toolbar', true),
'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read' }, 'a side panel next to the open message'),
'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', selection: 'email:read' }, 'a side panel next to the open message; `selection` is the text selected in the message as the user changes it ({ text })'),
'email-details-section': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', category: null }, 'a section in the message details'),
'attachment-actions': S('reader', 'frame', ['email:read'], { attachment: null }, 'buttons on each attachment', true),
'reader-selection-menu': S('reader', 'action', ['ui:context-menu', 'ui:toolbar'], { emailId: 'email:read', selection: 'email:read' }, 'entries in the right-click menu on selected text in a message (without plugin entries the browser\'s own menu stays); the click\'s selection is { text }'),
'attachment-menu': S('reader', 'action', ['email:read'], { emailId: 'email:read', blobId: 'email:read', name: 'email:read', type: 'email:read', size: 'email:read' }, 'entries in each attachment\'s menu (for example "Save to Nextcloud")'),
// ─── Composer ──────────────────────────────────────────────
+1
View File
@@ -639,6 +639,7 @@
"copy_failed": "Could not copy the code"
},
"mv_position": "{index} of {total}",
"copy": "Copy",
"plugin_panel_show": "Show plugin panel",
"plugin_panel_hide": "Hide plugin panel"
},