mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
Merge branch 'feat/do-ux-mail-toolbar' into feat/do-ux-mail-fixes
This commit is contained in:
@@ -566,6 +566,11 @@ optgroup {
|
||||
animation: toast-exit 0.28s cubic-bezier(0.06, 0.71, 0.55, 1) forwards;
|
||||
}
|
||||
|
||||
/* The reader toolbar with no room for its labels: icons only, the names stay in the tooltips. */
|
||||
[data-toolbar-compact] [data-toolbar-label] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Mobile Responsive Utilities */
|
||||
|
||||
/*
|
||||
|
||||
@@ -4,7 +4,8 @@ import React from 'react';
|
||||
import { EmailIdentityBadge } from '../email-identity-badge';
|
||||
import type { Email, Identity } from '@/lib/jmap/types';
|
||||
|
||||
// A draft or sent mail from "User A" showed "User A via User A".
|
||||
// A draft or sent mail from "User A" showed "User A via User A", and any
|
||||
// other own message "via <identity>".
|
||||
|
||||
const identity = { id: 'id-1', name: 'User A', email: 'usera@example.org' } as Identity;
|
||||
const fromUser = (name: string) => ({ from: [{ name, email: 'usera@example.org' }], to: [] }) as unknown as Email;
|
||||
@@ -15,9 +16,16 @@ describe('EmailIdentityBadge', () => {
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('names the identity when the sender name differs', () => {
|
||||
it('names no identity on your own message, whatever the sender name', () => {
|
||||
// "via <identity>" read like a delegation warning; the address under the
|
||||
// name already says which of your addresses sent it.
|
||||
const { container } = render(<EmailIdentityBadge email={fromUser('Support Desk')} identities={[identity]} />);
|
||||
// The translation mock returns the key of the "via {name}" text.
|
||||
expect(container.textContent).toContain('identity_short');
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('still shows the sub-address tag', () => {
|
||||
const email = { from: [{ name: 'Support Desk', email: 'usera+news@example.org' }], to: [] } as unknown as Email;
|
||||
const { container } = render(<EmailIdentityBadge email={email} identities={[identity]} />);
|
||||
expect(container.textContent).toContain('news');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { render, act, fireEvent, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
// Spam and Trash showed the Inbox's toolbar. Spam now has "Not spam" in
|
||||
// Archive's place and no quick reply; Trash has "Restore" there and its
|
||||
// Delete says "Delete forever".
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
{ id: 'junk', name: 'Junk', role: 'junk', parentId: null, myRights: { mayAddItems: true } },
|
||||
{ id: 'trash', name: 'Trash', role: 'trash', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const message = (folder: string) => ({
|
||||
id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { [folder]: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Dave', email: 'dave@example.net' }], to: [{ name: 'Alice', email: 'alice@example.org' }],
|
||||
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
|
||||
bodyValues: { '1': { value: 'Plain body' } },
|
||||
}) as unknown as Email;
|
||||
|
||||
const handlers = () => ({
|
||||
onArchive: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onMarkAsSpam: vi.fn(),
|
||||
onUndoSpam: vi.fn(),
|
||||
onMoveToMailbox: vi.fn(),
|
||||
onQuickReply: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
async function viewer(folder: string, h = handlers()) {
|
||||
render(<EmailViewer email={message(folder)} isLoading={false} mailboxes={mailboxes} selectedMailbox={folder} currentMailboxRole={folder} {...h} />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
return h;
|
||||
}
|
||||
|
||||
const archiveButton = () => document.querySelector('button[title="tooltips.archive"]');
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ deleteAction: 'trash', permanentlyDeleteJunk: false, quickReplyEnabled: true });
|
||||
});
|
||||
|
||||
describe('reader toolbar in Spam', () => {
|
||||
it('offers Not spam in place of Archive', async () => {
|
||||
const h = await viewer('junk');
|
||||
expect(archiveButton()).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('reader-not-spam'));
|
||||
expect(h.onUndoSpam).toHaveBeenCalled();
|
||||
// Only once: the old spam button no longer says it as well.
|
||||
expect(document.querySelectorAll('button[title="spam.not_spam_title"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('has no quick reply', async () => {
|
||||
await viewer('junk');
|
||||
expect(screen.queryByPlaceholderText('quick_reply_placeholder')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reader toolbar in Trash', () => {
|
||||
it('offers Restore in place of Archive, back to the Inbox', async () => {
|
||||
const h = await viewer('trash');
|
||||
expect(archiveButton()).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('reader-restore'));
|
||||
expect(h.onMoveToMailbox).toHaveBeenCalledWith('inbox');
|
||||
});
|
||||
|
||||
it('says Delete forever', async () => {
|
||||
await viewer('trash');
|
||||
expect(screen.getByTestId('reader-delete').textContent).toContain('delete_forever');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reader toolbar in the Inbox', () => {
|
||||
it('keeps Archive, Delete and quick reply', async () => {
|
||||
await viewer('inbox');
|
||||
expect(archiveButton()).not.toBeNull();
|
||||
expect(screen.queryByTestId('reader-restore')).toBeNull();
|
||||
expect(screen.queryByTestId('reader-not-spam')).toBeNull();
|
||||
expect(screen.getByTestId('reader-delete').textContent).not.toContain('delete_forever');
|
||||
expect(screen.getByPlaceholderText('quick_reply_placeholder')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reader header', () => {
|
||||
it('shows no message size (the details have it)', async () => {
|
||||
render(<EmailViewer email={{ ...message('inbox'), size: 2150 } as Email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
expect(document.body.textContent).not.toContain('2.1 KB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { render, act, fireEvent, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// The reader toolbar hid whole buttons (Forward, Archive, Move, at 1024 px
|
||||
// even Reply) while their labels stayed. Now the labels go first, and Reply,
|
||||
// Reply all, Forward, Archive and Move stay while anything else can give way.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
{ id: 'clients', name: 'Clients', role: null, parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
const email = {
|
||||
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [], subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z',
|
||||
preview: '', hasAttachment: false, size: 1, textBody: [], htmlBody: [], attachments: [], bodyValues: {},
|
||||
} as unknown as Email;
|
||||
|
||||
const REQUIRED = ['1', '2', '3', '4', '5'];
|
||||
|
||||
/**
|
||||
* A toolbar 600 px wide whose two groups need `labelled` px each with labels
|
||||
* and `compact` px each without; every item is 40 px.
|
||||
*/
|
||||
function layout(labelled: number, compact: number) {
|
||||
const spies = [
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.closest('[data-toolbar-compact]') ? compact : labelled;
|
||||
}),
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(600),
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(40),
|
||||
];
|
||||
return () => spies.forEach((spy) => spy.mockRestore());
|
||||
}
|
||||
|
||||
let restore: (() => void) | null = null;
|
||||
afterEach(() => { restore?.(); restore = null; });
|
||||
|
||||
const viewer = (props: Partial<React.ComponentProps<typeof EmailViewer>> = {}) => render(
|
||||
<EmailViewer
|
||||
email={email}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox="inbox"
|
||||
currentMailboxRole="inbox"
|
||||
onReply={vi.fn()}
|
||||
onReplyAll={vi.fn()}
|
||||
onForward={vi.fn()}
|
||||
onArchive={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onMoveToMailbox={vi.fn()}
|
||||
onMarkAsSpam={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
const toolbar = (container: HTMLElement) => container.querySelector<HTMLElement>('[class*="@container/reader-toolbar"]')!;
|
||||
const hiddenPriorities = (container: HTMLElement) =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>('[data-overflow-item]'))
|
||||
.filter((item) => item.style.display === 'none')
|
||||
.map((item) => item.dataset.overflowPriority!);
|
||||
|
||||
describe('reader toolbar overflow', () => {
|
||||
it('keeps its labels when everything fits', () => {
|
||||
restore = layout(250, 150);
|
||||
const { container } = viewer();
|
||||
expect(toolbar(container).hasAttribute('data-toolbar-compact')).toBe(false);
|
||||
expect(hiddenPriorities(container)).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops the labels before any button', () => {
|
||||
restore = layout(400, 250);
|
||||
const { container } = viewer();
|
||||
expect(toolbar(container).hasAttribute('data-toolbar-compact')).toBe(true);
|
||||
expect(hiddenPriorities(container)).toEqual([]);
|
||||
// Every label is marked, so the compact toolbar shows icons only.
|
||||
const labels = Array.from(container.querySelectorAll('[data-toolbar-label]')).map((label) => label.textContent);
|
||||
expect(labels).toEqual(expect.arrayContaining(['reply', 'reply_all', 'forward', 'archive', 'delete', 'move']));
|
||||
});
|
||||
|
||||
it('then hides the optional buttons, never Reply, Reply all, Forward, Archive or Move', () => {
|
||||
restore = layout(500, 400);
|
||||
const { container } = viewer();
|
||||
const hidden = hiddenPriorities(container);
|
||||
expect(hidden.length).toBeGreaterThan(0);
|
||||
expect(hidden.filter((p) => REQUIRED.includes(p))).toEqual([]);
|
||||
// The least important first: the dark-mode toggle before the read state.
|
||||
expect(hidden).toContain('11');
|
||||
});
|
||||
|
||||
it('hides the required ones only in a reader too narrow for their icons, Reply last', () => {
|
||||
restore = layout(2000, 1000);
|
||||
const { container } = viewer();
|
||||
const hidden = hiddenPriorities(container);
|
||||
expect(hidden).toEqual(expect.arrayContaining(REQUIRED));
|
||||
restore();
|
||||
// A little more room keeps Reply.
|
||||
restore = layout(2000, 470);
|
||||
const { container: roomier } = viewer();
|
||||
const kept = hiddenPriorities(roomier);
|
||||
expect(kept).not.toContain('1');
|
||||
});
|
||||
|
||||
it('offers Open in new window and Fullscreen in the more menu when they have no room', async () => {
|
||||
restore = layout(500, 400);
|
||||
viewer({ onOpenInWindow: vi.fn(), onToggleFullscreen: vi.fn() });
|
||||
expect(screen.getByTestId('viewer-open-in-window').hasAttribute('data-overflow-item')).toBe(true);
|
||||
// jsdom lays nothing out, so every toolbar button reads as hidden here.
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); });
|
||||
expect(await screen.findByRole('menuitem', { name: 'open_in_window' })).toBeTruthy();
|
||||
expect(screen.getByRole('menuitem', { name: 'fullscreen' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,11 @@ let widthReads = 0;
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
widthReads = 0;
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => { widthReads++; return 40; });
|
||||
// A width read in the toolbar: its groups' (every measuring pass) and its
|
||||
// items' (only a pass that has to hide some).
|
||||
const inToolbar = (el: HTMLElement) => !!el.closest('[class*="@container/reader-toolbar"]');
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { if (inToolbar(this)) widthReads++; return 40; });
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { if (inToolbar(this)) widthReads++; return 0; });
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
|
||||
@@ -111,7 +111,11 @@ export function EmailIdentityBadge({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Full view for email viewer - now shows compact badges only
|
||||
// Full view for email viewer - the sub-address tag only. A message of your
|
||||
// own named its identity ("via <identity>"), which read like a delegation
|
||||
// warning; the sender's address, shown right under the name, already says
|
||||
// which of your addresses sent it.
|
||||
if (!displayTag) return null;
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-2', className)}>
|
||||
{/* Sub-address tag badge */}
|
||||
@@ -130,21 +134,6 @@ export function EmailIdentityBadge({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Identity badge (only if identity has a name and no sub-address tag) */}
|
||||
{!displayTag && matchingIdentity && showIdentityName && (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md',
|
||||
'bg-secondary text-muted-foreground border border-border',
|
||||
'text-xs font-medium'
|
||||
)}
|
||||
title={t('identity_name', { name: matchingIdentity.name })}
|
||||
aria-label={t('identity_name', { name: matchingIdentity.name })}
|
||||
>
|
||||
<Mail className="w-3 h-3" />
|
||||
<span>{t('identity_short', { name: matchingIdentity.name })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ import { QuickReplyPluginActions } from "./quick-reply-plugin-actions";
|
||||
import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope";
|
||||
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils";
|
||||
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
|
||||
import { itemsToHide } from "@/lib/toolbar-overflow";
|
||||
import { itemsToHide, toolbarHideOrder } from "@/lib/toolbar-overflow";
|
||||
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
|
||||
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { emailDisplayDate } from "@/lib/email-date";
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
Minus,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
RotateCcw,
|
||||
Copy,
|
||||
Brain,
|
||||
Keyboard,
|
||||
@@ -140,7 +141,9 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useIsPaneScoped } from "@/hooks/use-pane-context";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore, resolveEmailBlobContext, emailWriteSource, type EmailBlobContext } from "@/stores/email-store";
|
||||
import { useEmailStore, resolveEmailBlobContext, emailWriteSource, bulkDeleteIsPermanent, type EmailBlobContext } from "@/stores/email-store";
|
||||
import { trashRestoreTarget } from "@/lib/trash-restore-target";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { useListSelection } from "@/lib/plugin-sandbox/assistant/b.list-selection";
|
||||
import { readability } from "@/lib/plugin-sandbox/assistant/b.projection";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
@@ -210,8 +213,19 @@ const TOOLBAR_PRIORITY_ACTIONS: Record<string, MessageActionKey> = {
|
||||
'7': 'spam',
|
||||
'8': 'read',
|
||||
'9': 'print',
|
||||
'10': 'open_window',
|
||||
'11': 'extra:view_mode',
|
||||
'12': 'extra:fullscreen',
|
||||
};
|
||||
/**
|
||||
* The reader toolbar's buttons that stay: Reply (1), Reply all (2), Forward
|
||||
* (3), Archive (4, or what stands in its place in Spam and Trash) and Move
|
||||
* (5), with Delete, which never gives way. Only a reader too narrow for their
|
||||
* icons hides them, in this order.
|
||||
*/
|
||||
const REQUIRED_TOOLBAR_PRIORITIES = [5, 3, 2, 4, 1] as const;
|
||||
/** Set on the toolbar while it shows icons without their labels. */
|
||||
const TOOLBAR_COMPACT_ATTRIBUTE = 'data-toolbar-compact';
|
||||
const NO_ACTION_KEYS: ReadonlySet<MessageActionKey> = new Set();
|
||||
const NO_PLUGIN_KEYS: ReadonlySet<string> = new Set();
|
||||
|
||||
@@ -1160,6 +1174,7 @@ function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMo
|
||||
onExport={options.onExport}
|
||||
onPrint={options.onPrint}
|
||||
onViewSource={options.onViewSource}
|
||||
onOpenInWindow={options.onOpenInWindow}
|
||||
omit={options.omit}
|
||||
extras={options.extras}
|
||||
rowPlugins={false}
|
||||
@@ -1457,6 +1472,15 @@ export function EmailViewer({
|
||||
|
||||
// Detect if current mailbox is Junk folder
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
// Trash offers Restore in Archive's place; there and wherever a delete
|
||||
// destroys the mail, Delete says "Delete forever".
|
||||
const isInTrashFolder = currentMailboxRole === 'trash';
|
||||
const tActions = useTranslations('reader_actions');
|
||||
const tSidebarNames = useTranslations('sidebar');
|
||||
// Read for the re-render: bulkDeleteIsPermanent reads them from the store.
|
||||
useSettingsStore((state) => state.deleteAction);
|
||||
useSettingsStore((state) => state.permanentlyDeleteJunk);
|
||||
const deleteIsPermanent = bulkDeleteIsPermanent({ role: currentMailboxRole });
|
||||
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||
// in Sent, Drafts and Scheduled.
|
||||
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
|
||||
@@ -1808,7 +1832,7 @@ export function EmailViewer({
|
||||
// looked like (see layoutKey), and the width it last had: an open that
|
||||
// brings back a toolbar already measured at this width needs no measuring.
|
||||
// A loading message and a loaded one can differ, so a few are kept.
|
||||
const measuredToolbarRef = useRef(new Map<string, Set<number>>());
|
||||
const measuredToolbarRef = useRef(new Map<string, { hidden: Set<number>; compact: boolean }>());
|
||||
const toolbarWidthRef = useRef<number | undefined>(undefined);
|
||||
const currentTagIds = getEmailTagIds(email?.keywords);
|
||||
const sortedTagIds = sortTagIds(currentTagIds);
|
||||
@@ -1993,6 +2017,8 @@ export function EmailViewer({
|
||||
[mailboxes, selectedMailbox, moveOwnerAccountId, email],
|
||||
);
|
||||
|
||||
const restoreTarget = isInTrashFolder && email ? trashRestoreTarget(email, mailboxes, identities, moveOwnerAccountId) : undefined;
|
||||
|
||||
// Close dropdown menus on click outside
|
||||
useEffect(() => {
|
||||
if (!moreMenuOpen) return;
|
||||
@@ -2066,17 +2092,14 @@ export function EmailViewer({
|
||||
const key = layoutKey(items);
|
||||
const known = toolbarWidthRef.current !== undefined ? measuredToolbarRef.current.get(key) : undefined;
|
||||
if (known) {
|
||||
for (const item of items) item.style.display = known.has(Number(item.dataset.overflowPriority)) ? 'none' : '';
|
||||
applyHidden(known);
|
||||
el.toggleAttribute(TOOLBAR_COMPACT_ATTRIBUTE, known.compact);
|
||||
for (const item of items) item.style.display = known.hidden.has(Number(item.dataset.overflowPriority)) ? 'none' : '';
|
||||
applyHidden(known.hidden);
|
||||
return;
|
||||
}
|
||||
// Sort descending by priority so highest number (least important) is hidden first
|
||||
items.sort((a, b) =>
|
||||
Number(b.dataset.overflowPriority || 0) - Number(a.dataset.overflowPriority || 0)
|
||||
);
|
||||
// Show all items to measure their natural widths
|
||||
// Show all items, with their labels, to measure their natural widths
|
||||
items.forEach(item => { item.style.display = ''; });
|
||||
const containerWidth = el.clientWidth;
|
||||
el.removeAttribute(TOOLBAR_COMPACT_ATTRIBUTE);
|
||||
const leftGroup = el.firstElementChild as HTMLElement;
|
||||
const rightGroup = el.lastElementChild as HTMLElement;
|
||||
const mainGap = parseFloat(getComputedStyle(el).gap) || 0;
|
||||
@@ -2084,25 +2107,37 @@ export function EmailViewer({
|
||||
leftGroup.style.flexShrink = '0';
|
||||
rightGroup.style.flexShrink = '0';
|
||||
el.style.overflow = 'hidden';
|
||||
// Measure every width in one pass and decide from the numbers (see
|
||||
// itemsToHide) rather than hiding items one by one and re-reading.
|
||||
const groupIds = new Map<Element, string>([[leftGroup, 'left'], [rightGroup, 'right']]);
|
||||
const groupOf = (item: HTMLElement) => {
|
||||
const parent = item.parentElement;
|
||||
if (!parent) return '';
|
||||
if (!groupIds.has(parent)) groupIds.set(parent, String(groupIds.size));
|
||||
return groupIds.get(parent)!;
|
||||
};
|
||||
const measured = items.map((item) => ({ width: item.offsetWidth, group: groupOf(item) }));
|
||||
const overflow = leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap - (containerWidth + 1);
|
||||
const gapOf = {
|
||||
left: parseFloat(getComputedStyle(leftGroup).gap) || 0,
|
||||
right: parseFloat(getComputedStyle(rightGroup).gap) || 0,
|
||||
};
|
||||
const containerWidth = el.clientWidth;
|
||||
const overflowNow = () => leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap - (containerWidth + 1);
|
||||
let overflow = overflowNow();
|
||||
// The labels go before any button does (icons keep their tooltips).
|
||||
const compact = overflow > 0;
|
||||
if (compact) {
|
||||
el.setAttribute(TOOLBAR_COMPACT_ATTRIBUTE, '');
|
||||
overflow = overflowNow();
|
||||
}
|
||||
const hidden = new Set<number>();
|
||||
for (const i of itemsToHide(measured, overflow, gapOf)) {
|
||||
items[i].style.display = 'none';
|
||||
hidden.add(Number(items[i].dataset.overflowPriority));
|
||||
if (overflow > 0) {
|
||||
// Measure every width in one pass and decide from the numbers (see
|
||||
// itemsToHide) rather than hiding items one by one and re-reading.
|
||||
const order = toolbarHideOrder(items.map((item) => Number(item.dataset.overflowPriority || 0)), REQUIRED_TOOLBAR_PRIORITIES);
|
||||
const ordered = order.map((i) => items[i]);
|
||||
const groupIds = new Map<Element, string>([[leftGroup, 'left'], [rightGroup, 'right']]);
|
||||
const groupOf = (item: HTMLElement) => {
|
||||
const parent = item.parentElement;
|
||||
if (!parent) return '';
|
||||
if (!groupIds.has(parent)) groupIds.set(parent, String(groupIds.size));
|
||||
return groupIds.get(parent)!;
|
||||
};
|
||||
const measured = ordered.map((item) => ({ width: item.offsetWidth, group: groupOf(item) }));
|
||||
const gapOf = {
|
||||
left: parseFloat(getComputedStyle(leftGroup).gap) || 0,
|
||||
right: parseFloat(getComputedStyle(rightGroup).gap) || 0,
|
||||
};
|
||||
for (const i of itemsToHide(measured, overflow, gapOf)) {
|
||||
ordered[i].style.display = 'none';
|
||||
hidden.add(Number(ordered[i].dataset.overflowPriority));
|
||||
}
|
||||
}
|
||||
// Restore layout
|
||||
leftGroup.style.flexShrink = '';
|
||||
@@ -2110,7 +2145,7 @@ export function EmailViewer({
|
||||
el.style.overflow = '';
|
||||
const answers = measuredToolbarRef.current;
|
||||
if (answers.size >= 8) answers.delete(answers.keys().next().value!);
|
||||
answers.set(key, hidden);
|
||||
answers.set(key, { hidden, compact });
|
||||
applyHidden(hidden);
|
||||
};
|
||||
|
||||
@@ -3454,7 +3489,8 @@ export function EmailViewer({
|
||||
const [iframeReadyFor, setIframeReadyFor] = useState<string | null>(null);
|
||||
const latestSrcDocRef = useRef<string | null>(null);
|
||||
const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc;
|
||||
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !isDraft && !isScheduled && !isBodyLoading
|
||||
// Not on spam: a reply confirms the address to the sender.
|
||||
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
|
||||
&& (!effectiveEmailContent.isHtml || iframeReady);
|
||||
// The plain-text body, where reader-above-quote mounts between text and quote.
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
@@ -4238,7 +4274,10 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
const toolbarShownActions = (): ReadonlySet<MessageActionKey> => {
|
||||
const shown = new Set<MessageActionKey>(['delete', 'edit_draft']);
|
||||
toolbarRef.current?.querySelectorAll<HTMLElement>('[data-overflow-item]').forEach((item) => {
|
||||
const key = TOOLBAR_PRIORITY_ACTIONS[item.dataset.overflowPriority ?? ''];
|
||||
// A button standing in for another (Not spam in Archive's place) says which entry it is.
|
||||
const key = item.dataset.overflowAction !== undefined
|
||||
? item.dataset.overflowAction as MessageActionKey
|
||||
: TOOLBAR_PRIORITY_ACTIONS[item.dataset.overflowPriority ?? ''];
|
||||
if (key && item.getClientRects().length > 0) shown.add(key);
|
||||
});
|
||||
if (isMobile && !mvMobileViewer) {
|
||||
@@ -4276,6 +4315,10 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
...(onShowShortcuts && !isMobile
|
||||
? [{ kind: 'item' as const, key: 'extra:shortcuts' as const, icon: Keyboard, label: t('keyboard_shortcuts'), run: onShowShortcuts }]
|
||||
: []),
|
||||
// Only while the toolbar has no room for its button (see toolbarShownActions).
|
||||
...(onToggleFullscreen && !isFullscreen && !mvViewer && !isMobile
|
||||
? [{ kind: 'item' as const, key: 'extra:fullscreen' as const, icon: Maximize2, label: t('fullscreen'), run: onToggleFullscreen }]
|
||||
: []),
|
||||
];
|
||||
const readerActionOptions: MessageActionOptions = {
|
||||
email,
|
||||
@@ -4299,6 +4342,8 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
onExport: devices.noExport ? undefined : () => void handleExportEmail(),
|
||||
onPrint: devices.noPrint ? undefined : () => handlePrint(),
|
||||
onViewSource: devices.noExport ? undefined : () => setShowSourceModal(true),
|
||||
// Only while the toolbar has no room for its button (see toolbarShownActions).
|
||||
onOpenInWindow: onOpenInWindow && !mvViewer && !isMobile ? onOpenInWindow : undefined,
|
||||
omit: readerMenuOmit,
|
||||
extras: readerExtras.filter((entry) => entry.kind !== 'item' || !readerMenuOmit.has(entry.key)),
|
||||
};
|
||||
@@ -4367,16 +4412,16 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={t('reschedule_send')}
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden @[52rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('reschedule_send')}</span>}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="hidden @[52rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('reschedule_send')}</span>}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8 sm:gap-1.5 sm:py-0" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden @[52rem]/reader-toolbar:inline text-sm whitespace-nowrap">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="hidden @[52rem]/reader-toolbar:inline text-sm whitespace-nowrap">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
|
||||
</Button>
|
||||
{onCancelScheduledSend && !email.isSmimeScheduled && (
|
||||
<Button variant="ghost" size="sm" onClick={onCancelScheduledSend} className="hidden sm:flex sm:h-8 sm:gap-1.5 sm:py-0" title={t('cancel_scheduled_send')} data-testid="cancel-scheduled-send">
|
||||
<CalendarX className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden @[52rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('cancel_scheduled_send')}</span>}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="hidden @[52rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('cancel_scheduled_send')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -4405,7 +4450,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={t('tooltips.reply')}
|
||||
>
|
||||
<Reply className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('reply')}</span>}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('reply')}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -4417,7 +4462,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={t('tooltips.reply_all')}
|
||||
>
|
||||
<ReplyAll className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('reply_all')}</span>}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('reply_all')}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -4429,7 +4474,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={t('tooltips.forward')}
|
||||
>
|
||||
<Forward className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('forward')}</span>}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('forward')}</span>}
|
||||
</Button>
|
||||
</>)}
|
||||
<PluginSlot name="toolbar-actions" />
|
||||
@@ -4449,8 +4494,40 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
|
||||
{!isScheduled && (
|
||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||
{/* Archive */}
|
||||
{archiveActionEnabled && (
|
||||
{/* Archive. In Spam "Not spam" takes its place, in Trash "Restore". */}
|
||||
{isInJunkFolder && onUndoSpam && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onUndoSpam}
|
||||
data-overflow-item
|
||||
data-overflow-priority="4"
|
||||
data-overflow-action="spam"
|
||||
data-testid="reader-not-spam"
|
||||
className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer ? "order-1" : "hover:bg-emerald-50 dark:hover:bg-emerald-950/30")}
|
||||
title={t('spam.not_spam_title')}
|
||||
>
|
||||
<ShieldCheck className={cn("h-4 w-4", !mvViewer && "text-emerald-600 dark:text-emerald-400")} />
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('spam.not_spam_title')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
{isInTrashFolder && restoreTarget && onMoveToMailbox && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onMoveToMailbox(restoreTarget.id)}
|
||||
data-overflow-item
|
||||
data-overflow-priority="4"
|
||||
data-overflow-action=""
|
||||
data-testid="reader-restore"
|
||||
className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-1")}
|
||||
title={tActions('restore_to', { folder: localizeMailboxName(restoreTarget.role, restoreTarget.name, (key) => tSidebarNames(`mailboxes.${key}`)) })}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{tActions('restore')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
{archiveActionEnabled && !isInJunkFolder && !isInTrashFolder && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -4461,7 +4538,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={t('tooltips.archive')}
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('archive')}</span>}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('archive')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
{/* Delete */}
|
||||
@@ -4470,10 +4547,11 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className={cn("flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0", mvViewer && "order-3")}
|
||||
title={t('tooltips.delete')}
|
||||
title={deleteIsPermanent ? tActions('delete_forever') : t('tooltips.delete')}
|
||||
data-testid="reader-delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('delete')}</span>}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{deleteIsPermanent ? tActions('delete_forever') : t('delete')}</span>}
|
||||
</Button>
|
||||
{/* Move to folder */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
@@ -4493,7 +4571,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
aria-expanded={moveMenuOpen}
|
||||
>
|
||||
<FolderInput className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('move')}</span>}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('move')}</span>}
|
||||
</Button>
|
||||
<ContextMenu ref={moveMenu.menuRef} isOpen={moveMenuOpen} position={moveMenu.contextMenu.position} onClose={closeMoveMenu} label={t('move_to')}>
|
||||
<div className="w-56 max-h-72 overflow-y-auto">
|
||||
@@ -4525,7 +4603,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
aria-expanded={tagMenuOpen}
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('tag')}</span>}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('tag')}</span>}
|
||||
</Button>
|
||||
<ContextMenu ref={tagMenu.menuRef} isOpen={tagMenuOpen} position={tagMenu.contextMenu.position} onClose={closeTagMenu} label={t('tag')}>
|
||||
<div className="w-56">
|
||||
@@ -4540,7 +4618,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
</div>
|
||||
|
||||
{/* Spam */}
|
||||
{spamApplicable && (onMarkAsSpam || onUndoSpam) && (
|
||||
{spamApplicable && !isInJunkFolder && onMarkAsSpam && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -4560,7 +4638,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
) : (
|
||||
<ShieldAlert className={cn("h-4 w-4", !mvViewer && "text-red-600 dark:text-red-400")} />
|
||||
)}
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}</span>}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -4582,7 +4660,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
>
|
||||
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
||||
{showToolbarLabels && (
|
||||
<span className="grid text-center text-[0.625rem] leading-tight sm:text-sm sm:whitespace-nowrap">
|
||||
<span data-toolbar-label="" className="grid text-center text-[0.625rem] leading-tight sm:text-sm sm:whitespace-nowrap">
|
||||
<span className={cn("col-start-1 row-start-1", !isUnread && "invisible")} aria-hidden={!isUnread}>{t('read')}</span>
|
||||
<span className={cn("col-start-1 row-start-1", isUnread && "invisible")} aria-hidden={isUnread}>{t('unread')}</span>
|
||||
</span>
|
||||
@@ -4602,7 +4680,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={t('print')}
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm whitespace-nowrap">{t('print')}</span>}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="hidden sm:inline text-sm whitespace-nowrap">{t('print')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -4629,6 +4707,8 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenInWindow}
|
||||
data-overflow-item
|
||||
data-overflow-priority="10"
|
||||
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||
title={tWindows('open_in_window')}
|
||||
aria-label={tWindows('open_in_window')}
|
||||
@@ -4639,13 +4719,14 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
)}
|
||||
|
||||
{/* Fullscreen toggle - hidden on mobile (already fullscreen there).
|
||||
Never overflows into the More menu: in fullscreen this button is
|
||||
the way back out, so it must stay visible. */}
|
||||
The first to give way to the More menu, but never in fullscreen:
|
||||
there this button is the way back out, so it must stay visible. */}
|
||||
{onToggleFullscreen && !mvViewer && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleFullscreen}
|
||||
{...(isFullscreen ? {} : { 'data-overflow-item': '', 'data-overflow-priority': '12' })}
|
||||
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||
title={isFullscreen ? t('exit_fullscreen') : t('fullscreen')}
|
||||
aria-label={isFullscreen ? t('exit_fullscreen') : t('fullscreen')}
|
||||
@@ -4983,16 +5064,12 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
</div>
|
||||
)}
|
||||
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender.
|
||||
Mountain View puts it on the sender's row instead, and shows no size. */}
|
||||
Mountain View puts it on the sender's row instead. The size is
|
||||
in the details. */}
|
||||
<div className={cn("hidden flex-shrink-0 text-end", !mvViewer && "sm:block")}>
|
||||
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
|
||||
{formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
{email.size > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<bdi>{formatFileSize(email.size)}</bdi>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5343,18 +5420,13 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Date/time + size on the right (mobile) */}
|
||||
{/* Date/time on the right (mobile); the size is in the details */}
|
||||
<div className="sm:hidden flex-shrink-0 text-end ms-2">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{mvMobileViewer
|
||||
? formatDate(emailDisplayDate(email))
|
||||
: formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
{email.size > 0 && !mvMobileViewer && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<bdi>{formatFileSize(email.size)}</bdi>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mountain View phone: reply from the top of the message, not
|
||||
only from the row under a long body (#1475). */}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
const saveBlobAs = vi.fn(() => true);
|
||||
vi.mock('@/lib/download-guard', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/download-guard')>()),
|
||||
saveBlobAs: (...args: unknown[]) => saveBlobAs(...(args as [])),
|
||||
}));
|
||||
|
||||
import { FilePreviewModal } from '../file-preview-modal';
|
||||
import { EmlPreview } from '../eml-preview';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
// An attached message without a file name opened as "attachment", saved as
|
||||
// "attachment", in a monospace font with a 12-hour date and seconds.
|
||||
|
||||
const EML = [
|
||||
'From: Tobias Brenner <tobias.brenner@example.net>',
|
||||
'To: Mara Lindqvist <mara.lindqvist@example.net>',
|
||||
'Subject: Original: room booking',
|
||||
'Date: Thu, 15 Oct 2026 05:40:37 +0200',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'Room 2.14 is booked.',
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
const preview = (name: string, onDownload = vi.fn()) => {
|
||||
render(
|
||||
<FilePreviewModal
|
||||
name={name}
|
||||
onClose={() => {}}
|
||||
onDownload={onDownload}
|
||||
getFileContent={async () => ({ blob: new Blob([EML], { type: 'message/rfc822' }), contentType: 'message/rfc822' })}
|
||||
/>,
|
||||
);
|
||||
return onDownload;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
saveBlobAs.mockClear();
|
||||
useSettingsStore.setState({ timeFormat: '24h' });
|
||||
});
|
||||
|
||||
describe('attached message preview', () => {
|
||||
it('is titled and saved by its subject when the part has no name', async () => {
|
||||
const onDownload = preview('attachment');
|
||||
await waitFor(() => expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Original: room booking'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'download' }));
|
||||
expect(onDownload).not.toHaveBeenCalled();
|
||||
expect(saveBlobAs).toHaveBeenCalledTimes(1);
|
||||
expect((saveBlobAs.mock.calls[0] as unknown[])[1]).toMatch(/^Original.*room booking\.eml$/);
|
||||
});
|
||||
|
||||
it("keeps the sender's own file name", async () => {
|
||||
const onDownload = preview('booking-thread.eml');
|
||||
await waitFor(() => expect(screen.getByRole('heading', { level: 2 }).textContent).toBe('Original: room booking'));
|
||||
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('booking-thread.eml');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'download' }));
|
||||
expect(onDownload).toHaveBeenCalled();
|
||||
expect(saveBlobAs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the body in the reader's font and the date as the reader does", () => {
|
||||
useSettingsStore.setState({ timeFormat: '24h' });
|
||||
const { container } = render(
|
||||
<EmlPreview message={{ subject: 's', text: 'Room 2.14 is booked.', date: '2026-10-15T03:40:37Z', attachments: [] }} />,
|
||||
);
|
||||
const doc = container.querySelector('iframe')?.getAttribute('srcdoc') ?? '';
|
||||
expect(doc).not.toContain('monospace');
|
||||
expect(doc).toContain('-apple-system');
|
||||
expect(container.textContent).not.toContain(':37');
|
||||
expect(container.textContent).not.toMatch(/AM|PM/);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { Paperclip, Download } from "@/components/icons";
|
||||
import { saveBlobAs } from "@/lib/download-guard";
|
||||
import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization";
|
||||
import { turnOffLinks } from "@/lib/phishing-links";
|
||||
import { getEffectiveTimeZone } from "@/lib/timezone";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { stripBidiControls } from "@/lib/bidi";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -66,6 +66,7 @@ export function EmlPreview({ message, onOpenAttachment, linksOff, downloadsOff =
|
||||
const remoteBlockedByOrg = useRemoteContentHeldBackAnywhere();
|
||||
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
|
||||
const tFiles = useTranslations("files");
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
|
||||
// dir="auto" on a wrapper we control lets the first strong character pick
|
||||
// the direction, matching the main viewer.
|
||||
@@ -74,11 +75,14 @@ export function EmlPreview({ message, onOpenAttachment, linksOff, downloadsOff =
|
||||
? turnOffLinks(sanitizeEmailBodyForIframe(message.html, blockRemote).html, { title: tPhishing("link_off"), note: tPhishing("link_off_note") })
|
||||
: sanitizeEmailBodyForIframe(message.html, blockRemote).html}</div>`
|
||||
: message.text
|
||||
? `<pre dir="auto" style="white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,monospace;margin:0;padding:8px">${escapeHtml(message.text)}</pre>`
|
||||
? `<div dir="auto" style="white-space:pre-wrap;word-break:break-word;margin:0;padding:8px">${escapeHtml(message.text)}</div>`
|
||||
: "";
|
||||
// The reader's font (.email-content), for a plain-text body and as the
|
||||
// default under an HTML one.
|
||||
const bodyStyle = `<style>body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:0.9375rem;line-height:1.6;color:#1a1a1a}</style>`;
|
||||
// The strict CSP is the network backstop for whatever the DOM walk misses.
|
||||
const bodyDoc = body
|
||||
? `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${emailIframeCsp(blockRemote)}"><meta name="referrer" content="no-referrer"></head><body>${body}</body></html>`
|
||||
? `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${emailIframeCsp(blockRemote)}"><meta name="referrer" content="no-referrer">${bodyStyle}</head><body>${body}</body></html>`
|
||||
: "";
|
||||
|
||||
const downloadAttachment = (att: EmlAttachment) => {
|
||||
@@ -102,7 +106,7 @@ export function EmlPreview({ message, onOpenAttachment, linksOff, downloadsOff =
|
||||
<div><span className="font-medium text-foreground">{t("to")}: </span><bdi>{message.to.map(formatAddress).join(", ")}</bdi></div>
|
||||
)}
|
||||
{message.date && (
|
||||
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString(undefined, { timeZone: getEffectiveTimeZone() })}</div>
|
||||
<div><span className="font-medium text-foreground">{t("date")}: </span>{formatDateTime(message.date, timeFormat, { weekday: "short", year: "numeric", month: "short", day: "numeric" })}</div>
|
||||
)}
|
||||
</div>
|
||||
{bodyDoc && (
|
||||
|
||||
@@ -13,6 +13,8 @@ import { EmlPreview, type EmlAttachment, type ParsedEml } from "@/components/fil
|
||||
import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
|
||||
import { canOpenUrlInNewWindow } from "@/lib/standalone-display";
|
||||
import { saveBlobAs } from "@/lib/download-guard";
|
||||
import { emailExportFilename } from "@/lib/download-filename";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { noMediaSavingProps } from "@/lib/device-frame";
|
||||
import { useDevicePolicy, useStrictestDevices } from "@/hooks/use-device-policy";
|
||||
import { Watermark, useWatermarkText } from "@/components/device/watermark";
|
||||
@@ -49,6 +51,14 @@ const PdfMobileViewer = dynamic(
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* A message attached without a file name gets one made up from its type or
|
||||
* the download fallback: "Email.eml", "attachment".
|
||||
*/
|
||||
function isUnnamedMessagePart(name: string): boolean {
|
||||
return /^(attachment|email|download)(\.eml)?$/i.test(name.trim());
|
||||
}
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
@@ -185,6 +195,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
// (text/html, image/svg+xml, ...) in a new tab would execute it in-origin.
|
||||
const [canOpenInNewTab, setCanOpenInNewTab] = useState(false);
|
||||
const [emlContent, setEmlContent] = useState<ParsedEml | null>(null);
|
||||
const [emlBlob, setEmlBlob] = useState<Blob | null>(null);
|
||||
// The PDF blob itself, kept alongside its object URL: the pdf.js mobile
|
||||
// viewer needs the bytes, because fetching the blob: URL is blocked by CSP
|
||||
// connect-src (#871). The URL is still what the <iframe> and the
|
||||
@@ -235,6 +246,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
setObjectUrl(null);
|
||||
setCanOpenInNewTab(false);
|
||||
setEmlContent(null);
|
||||
setEmlBlob(null);
|
||||
setPdfBlob(null);
|
||||
setHtmlDoc(null);
|
||||
setLoading(true);
|
||||
@@ -263,7 +275,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
// stays off the bundle) and render it like an email via EmlPreview.
|
||||
const { default: PostalMime } = await import("postal-mime");
|
||||
const parsed = await new PostalMime().parse(await blob.arrayBuffer());
|
||||
if (!cancelled) setEmlContent(parsed as ParsedEml);
|
||||
if (!cancelled) {
|
||||
setEmlContent(parsed as ParsedEml);
|
||||
setEmlBlob(blob);
|
||||
}
|
||||
} else {
|
||||
// Re-type the blob to one its viewer can show; a blob: URL with a
|
||||
// type the browser cannot display is silently downloaded instead.
|
||||
@@ -339,11 +354,23 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
);
|
||||
}
|
||||
|
||||
// An attached message that came without a file name of its own is called
|
||||
// by its subject, not "attachment", and saved under it.
|
||||
const emlSubject = fileType === "eml" && isUnnamedMessagePart(name) ? emlContent?.subject?.trim() || null : null;
|
||||
const download = () => {
|
||||
if (emlSubject && emlBlob) {
|
||||
const filename = emailExportFilename({ subject: emlSubject } as Email, { template: "{subject}" });
|
||||
saveBlobAs(emlBlob, filename, { kind: "attachment", subject: policyAccount });
|
||||
return;
|
||||
}
|
||||
void onDownload();
|
||||
};
|
||||
|
||||
return (
|
||||
<PreviewShell
|
||||
name={name}
|
||||
name={emlSubject ?? name}
|
||||
onClose={onClose}
|
||||
onDownload={downloadsOff ? undefined : () => void onDownload()}
|
||||
onDownload={downloadsOff ? undefined : download}
|
||||
neighbours={neighbours}
|
||||
// An installed app hands the new window to the system browser, which
|
||||
// cannot load our blob: URL (#865): no dead button there.
|
||||
|
||||
@@ -195,7 +195,7 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{!compact && <span className="whitespace-nowrap">{action.label}</span>}
|
||||
{!compact && <span data-toolbar-label="" className="whitespace-nowrap">{action.label}</span>}
|
||||
{/* The count as the search's filter button draws its own. */}
|
||||
{action.badge ? (
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { trashRestoreTarget } from '../trash-restore-target';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox' },
|
||||
{ id: 'sent', name: 'Sent', role: 'sent' },
|
||||
{ id: 'drafts', name: 'Drafts', role: 'drafts' },
|
||||
{ id: 'trash', name: 'Trash', role: 'trash' },
|
||||
{ id: 'b:inbox', name: 'Inbox', role: 'inbox', accountId: 'b', isShared: true },
|
||||
] as unknown as Mailbox[];
|
||||
const identities = [{ email: 'Robin@example.org' }];
|
||||
const mail = (from: string, keywords: Record<string, boolean> = {}) =>
|
||||
({ from: [{ email: from }], keywords }) as unknown as Email;
|
||||
|
||||
describe('trashRestoreTarget', () => {
|
||||
it('puts received mail back in the Inbox', () => {
|
||||
expect(trashRestoreTarget(mail('dave@example.net'), mailboxes, identities)?.id).toBe('inbox');
|
||||
});
|
||||
|
||||
it('puts your own mail back in Sent and drafts back in Drafts', () => {
|
||||
expect(trashRestoreTarget(mail('robin@example.org'), mailboxes, identities)?.id).toBe('sent');
|
||||
expect(trashRestoreTarget(mail('robin@example.org', { $draft: true }), mailboxes, identities)?.id).toBe('drafts');
|
||||
});
|
||||
|
||||
it("uses the message's own account", () => {
|
||||
expect(trashRestoreTarget(mail('dave@example.net'), mailboxes, identities, 'b')?.id).toBe('b:inbox');
|
||||
});
|
||||
|
||||
it('falls back to the Inbox when the folder is missing', () => {
|
||||
const noSent = mailboxes.filter((m) => m.role !== 'sent');
|
||||
expect(trashRestoreTarget(mail('robin@example.org'), noSent, identities)?.id).toBe('inbox');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,26 @@ export interface MeasuredToolbarItem {
|
||||
group: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The order the reader's toolbar gives way in, as indexes into `priorities`.
|
||||
* Labels go before any button (the caller measures without them first). Then
|
||||
* the optional buttons, least important (highest number) first. The ones in
|
||||
* `required` (Reply, Reply all, Forward, Archive, Move; Delete never takes
|
||||
* part) go only when the toolbar still doesn't fit without all the optional
|
||||
* ones, in the order `required` lists them: a reader too narrow for six
|
||||
* icons.
|
||||
*/
|
||||
export function toolbarHideOrder(priorities: number[], required: readonly number[]): number[] {
|
||||
const indexes = priorities.map((_, i) => i);
|
||||
const optional = indexes
|
||||
.filter((i) => !required.includes(priorities[i]))
|
||||
.sort((a, b) => priorities[b] - priorities[a]);
|
||||
const last = indexes
|
||||
.filter((i) => required.includes(priorities[i]))
|
||||
.sort((a, b) => required.indexOf(priorities[a]) - required.indexOf(priorities[b]));
|
||||
return [...optional, ...last];
|
||||
}
|
||||
|
||||
/**
|
||||
* Which items to hide so the toolbar fits, decided from one measuring pass.
|
||||
* `items` come least important first; they are hidden in that order until
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Email, Identity, Mailbox } from "@/lib/jmap/types";
|
||||
import { isAuthenticationSpoofed } from "@/lib/email-headers";
|
||||
|
||||
/**
|
||||
* The folder Restore puts a message in Trash back into. The server keeps no
|
||||
* record of the folder a message was deleted from, so this goes by what the
|
||||
* message is: a draft back to Drafts, mail you sent back to Sent, anything
|
||||
* else to the Inbox. In a list that mixes accounts, the message's own
|
||||
* account's folder. Undefined when there's no such folder.
|
||||
*/
|
||||
export function trashRestoreTarget(
|
||||
email: Pick<Email, "keywords" | "from" | "authenticationResults">,
|
||||
mailboxes: Mailbox[],
|
||||
identities: Pick<Identity, "email">[],
|
||||
ownerAccountId?: string,
|
||||
): Mailbox | undefined {
|
||||
const from = email.from?.[0]?.email?.toLowerCase();
|
||||
const own = !!from
|
||||
&& !isAuthenticationSpoofed(email.authenticationResults)
|
||||
&& identities.some((identity) => identity.email?.toLowerCase() === from);
|
||||
const role = email.keywords?.["$draft"] === true ? "drafts" : own ? "sent" : "inbox";
|
||||
const pick = (wanted: string) => {
|
||||
const candidates = mailboxes.filter((m) => m.role === wanted);
|
||||
return candidates.find((m) => !!ownerAccountId && m.accountId === ownerAccountId)
|
||||
?? candidates.find((m) => !m.isShared)
|
||||
?? candidates[0];
|
||||
};
|
||||
return pick(role) ?? (role === "inbox" ? undefined : pick("inbox"));
|
||||
}
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "إيقاف القراءة",
|
||||
"reader_view": "عرض القراءة"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "استعادة",
|
||||
"restore_to": "إعادة النقل إلى {folder}",
|
||||
"delete_forever": "حذف نهائي"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "خارجي",
|
||||
"sender_tooltip": "من {domain}، من خارج مؤسستك",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Atura la lectura",
|
||||
"reader_view": "Vista de lectura"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Restaura",
|
||||
"restore_to": "Torna a moure a {folder}",
|
||||
"delete_forever": "Suprimeix per sempre"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Extern",
|
||||
"sender_tooltip": "De {domain}, fora de la vostra organització",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Ukončit předčítání",
|
||||
"reader_view": "Zobrazení pro čtení"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Obnovit",
|
||||
"restore_to": "Přesunout zpět do složky {folder}",
|
||||
"delete_forever": "Smazat natrvalo"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Externí",
|
||||
"sender_tooltip": "Z {domain}, mimo vaši organizaci",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Stop oplæsning",
|
||||
"reader_view": "Læsevisning"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Gendan",
|
||||
"restore_to": "Flyt tilbage til {folder}",
|
||||
"delete_forever": "Slet permanent"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Ekstern",
|
||||
"sender_tooltip": "Fra {domain}, uden for din organisation",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Vorlesen beenden",
|
||||
"reader_view": "Leseansicht"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Wiederherstellen",
|
||||
"restore_to": "Zurück nach {folder} verschieben",
|
||||
"delete_forever": "Endgültig löschen"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Extern",
|
||||
"sender_tooltip": "Von {domain}, außerhalb Ihrer Organisation",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Stop reading",
|
||||
"reader_view": "Reader view"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Restore",
|
||||
"restore_to": "Move back to {folder}",
|
||||
"delete_forever": "Delete forever"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "External",
|
||||
"sender_tooltip": "From {domain}, outside your organisation",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Detener la lectura",
|
||||
"reader_view": "Vista de lectura"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Restaurar",
|
||||
"restore_to": "Volver a mover a {folder}",
|
||||
"delete_forever": "Eliminar para siempre"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Externo",
|
||||
"sender_tooltip": "De {domain}, fuera de tu organización",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Lõpeta ettelugemine",
|
||||
"reader_view": "Lugemisvaade"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Taasta",
|
||||
"restore_to": "Teisalda tagasi kausta {folder}",
|
||||
"delete_forever": "Kustuta jäädavalt"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Väline",
|
||||
"sender_tooltip": "Domeenilt {domain}, väljastpoolt sinu organisatsiooni",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "پایان خواندن",
|
||||
"reader_view": "نمای مطالعه"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "بازیابی",
|
||||
"restore_to": "انتقال دوباره به {folder}",
|
||||
"delete_forever": "حذف برای همیشه"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "خارجی",
|
||||
"sender_tooltip": "از {domain}، خارج از سازمان شما",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Arrêter la lecture",
|
||||
"reader_view": "Mode lecture"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Restaurer",
|
||||
"restore_to": "Remettre dans {folder}",
|
||||
"delete_forever": "Supprimer définitivement"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Externe",
|
||||
"sender_tooltip": "De {domain}, hors de votre organisation",
|
||||
|
||||
@@ -855,6 +855,11 @@
|
||||
"read_aloud_stop": "עצירת ההקראה",
|
||||
"reader_view": "תצוגת קריאה"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "שחזור",
|
||||
"restore_to": "החזרה אל {folder}",
|
||||
"delete_forever": "מחיקה לצמיתות"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "חיצוני",
|
||||
"sender_tooltip": "מ־{domain}, מחוץ לארגון שלך",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Felolvasás leállítása",
|
||||
"reader_view": "Olvasó nézet"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Visszaállítás",
|
||||
"restore_to": "Visszahelyezés ide: {folder}",
|
||||
"delete_forever": "Végleges törlés"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Külső",
|
||||
"sender_tooltip": "Innen: {domain}, a szervezeteden kívülről",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Interrompi la lettura",
|
||||
"reader_view": "Modalità lettura"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Ripristina",
|
||||
"restore_to": "Riporta in {folder}",
|
||||
"delete_forever": "Elimina definitivamente"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Esterno",
|
||||
"sender_tooltip": "Da {domain}, fuori dalla tua organizzazione",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "読み上げを停止",
|
||||
"reader_view": "リーダー表示"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "元に戻す",
|
||||
"restore_to": "{folder} に戻す",
|
||||
"delete_forever": "完全に削除"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "外部",
|
||||
"sender_tooltip": "{domain} から(組織外)",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "읽기 중지",
|
||||
"reader_view": "읽기 보기"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "복원",
|
||||
"restore_to": "{folder}(으)로 되돌리기",
|
||||
"delete_forever": "영구 삭제"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "외부",
|
||||
"sender_tooltip": "{domain}에서 보냄, 조직 외부",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Beigt nolasīšanu",
|
||||
"reader_view": "Lasīšanas skats"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Atjaunot",
|
||||
"restore_to": "Pārvietot atpakaļ uz {folder}",
|
||||
"delete_forever": "Dzēst neatgriezeniski"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Ārējs",
|
||||
"sender_tooltip": "No {domain}, ārpus jūsu organizācijas",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Уншихыг зогсоох",
|
||||
"reader_view": "Унших харагдац"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Сэргээх",
|
||||
"restore_to": "{folder} руу буцаах",
|
||||
"delete_forever": "Бүрмөсөн устгах"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Гадаад",
|
||||
"sender_tooltip": "{domain}-аас, таны байгууллагаас гадуур",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Stopp opplesning",
|
||||
"reader_view": "Lesevisning"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Gjenopprett",
|
||||
"restore_to": "Flytt tilbake til {folder}",
|
||||
"delete_forever": "Slett for godt"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Ekstern",
|
||||
"sender_tooltip": "Fra {domain}, utenfor organisasjonen din",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Voorlezen stoppen",
|
||||
"reader_view": "Leesweergave"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Terugzetten",
|
||||
"restore_to": "Terugzetten naar {folder}",
|
||||
"delete_forever": "Definitief verwijderen"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Extern",
|
||||
"sender_tooltip": "Van {domain}, buiten je organisatie",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Zakończ czytanie",
|
||||
"reader_view": "Widok do czytania"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Przywróć",
|
||||
"restore_to": "Przenieś z powrotem do folderu {folder}",
|
||||
"delete_forever": "Usuń na zawsze"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Zewnętrzny",
|
||||
"sender_tooltip": "Z domeny {domain}, spoza Twojej organizacji",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Parar a leitura",
|
||||
"reader_view": "Vista de leitura"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Restaurar",
|
||||
"restore_to": "Mover de volta para {folder}",
|
||||
"delete_forever": "Eliminar para sempre"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Externo",
|
||||
"sender_tooltip": "De {domain}, fora da sua organização",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Opriți citirea",
|
||||
"reader_view": "Vizualizare pentru citire"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Restaurează",
|
||||
"restore_to": "Mută înapoi în {folder}",
|
||||
"delete_forever": "Șterge definitiv"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Extern",
|
||||
"sender_tooltip": "De la {domain}, din afara organizației dvs.",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Остановить чтение",
|
||||
"reader_view": "Режим чтения"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Восстановить",
|
||||
"restore_to": "Вернуть в папку {folder}",
|
||||
"delete_forever": "Удалить навсегда"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Внешнее",
|
||||
"sender_tooltip": "С домена {domain}, вне вашей организации",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Zastaviť čítanie",
|
||||
"reader_view": "Zobrazenie na čítanie"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Obnoviť",
|
||||
"restore_to": "Presunúť späť do priečinka {folder}",
|
||||
"delete_forever": "Odstrániť natrvalo"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Externé",
|
||||
"sender_tooltip": "Z {domain}, mimo vašej organizácie",
|
||||
|
||||
@@ -2008,6 +2008,11 @@
|
||||
"read_aloud_stop": "หยุดอ่าน",
|
||||
"reader_view": "มุมมองการอ่าน"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "กู้คืน",
|
||||
"restore_to": "ย้ายกลับไปที่ {folder}",
|
||||
"delete_forever": "ลบถาวร"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "ภายนอก",
|
||||
"sender_tooltip": "จาก {domain} ซึ่งอยู่นอกองค์กรของคุณ",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Okumayı durdur",
|
||||
"reader_view": "Okuma görünümü"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Geri yükle",
|
||||
"restore_to": "{folder} klasörüne geri taşı",
|
||||
"delete_forever": "Kalıcı olarak sil"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Harici",
|
||||
"sender_tooltip": "{domain} adresinden, kuruluşunuzun dışından",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Зупинити читання",
|
||||
"reader_view": "Режим читання"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Відновити",
|
||||
"restore_to": "Повернути до теки {folder}",
|
||||
"delete_forever": "Видалити назавжди"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Зовнішнє",
|
||||
"sender_tooltip": "З домену {domain}, поза вашою організацією",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "Dừng đọc",
|
||||
"reader_view": "Chế độ đọc"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "Khôi phục",
|
||||
"restore_to": "Chuyển lại vào {folder}",
|
||||
"delete_forever": "Xóa vĩnh viễn"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Bên ngoài",
|
||||
"sender_tooltip": "Từ {domain}, bên ngoài tổ chức của bạn",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "停止朗讀",
|
||||
"reader_view": "閱讀檢視"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "復原",
|
||||
"restore_to": "移回 {folder}",
|
||||
"delete_forever": "永久刪除"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "外部",
|
||||
"sender_tooltip": "來自 {domain},位於您的組織之外",
|
||||
|
||||
@@ -912,6 +912,11 @@
|
||||
"read_aloud_stop": "停止朗读",
|
||||
"reader_view": "阅读视图"
|
||||
},
|
||||
"reader_actions": {
|
||||
"restore": "恢复",
|
||||
"restore_to": "移回 {folder}",
|
||||
"delete_forever": "永久删除"
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "外部",
|
||||
"sender_tooltip": "来自 {domain},位于您的组织之外",
|
||||
|
||||
Reference in New Issue
Block a user