mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
feat: add preview cards under the message as an attachment position
This commit is contained in:
@@ -94,6 +94,19 @@ describe('ReaderAttachments bar', () => {
|
||||
expect(screen.queryByRole('button', { name: /attachments_show_more/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows every file as a card, without collapsing, in cards layout', () => {
|
||||
const files = Array.from({ length: 8 }, (_, i) => file(String(i), `file-${i}.pdf`));
|
||||
const props = renderBar(files, { layout: 'cards' });
|
||||
expect(screen.getByTestId('reader-attachments').dataset.layout).toBe('cards');
|
||||
expect(screen.getAllByTestId('attachment')).toHaveLength(8);
|
||||
expect(screen.queryByRole('button', { name: /attachments_show_more/ })).toBeNull();
|
||||
const card = screen.getAllByTestId('attachment')[2];
|
||||
const buttons = within(card).getAllByRole('button');
|
||||
fireEvent.click(buttons[buttons.length - 1]);
|
||||
expect(props.onDownload).toHaveBeenCalledWith(files[2]);
|
||||
expect(props.onOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('draws the image itself when there is a thumbnail', () => {
|
||||
const { container } = render(
|
||||
<ReaderAttachments
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// The attachment position setting decides where the reader draws the
|
||||
// attachments: a bar of tiles above the body, or cards after it with a
|
||||
// paperclip in the header that jumps down to them.
|
||||
|
||||
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 { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
const email = {
|
||||
id: 'e1',
|
||||
blobId: 'b1',
|
||||
threadId: 't1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
from: [{ name: 'Sarah Kim', email: 'sarah@example.com' }],
|
||||
to: [{ email: 'me@example.com' }],
|
||||
subject: 'Invoice and prototype',
|
||||
receivedAt: '2026-01-01T00:00:00Z',
|
||||
preview: 'Here is the invoice',
|
||||
hasAttachment: true,
|
||||
size: 100,
|
||||
textBody: [{ partId: '1', type: 'text/plain' }],
|
||||
htmlBody: [{ partId: '1', type: 'text/plain' }],
|
||||
bodyValues: { '1': { value: 'Here is the invoice.' } },
|
||||
attachments: [
|
||||
{ partId: '2', blobId: 'blob-pdf', name: 'Invoice-2026-089.pdf', type: 'application/pdf', size: 51200, disposition: 'attachment' },
|
||||
{ partId: '3', blobId: 'blob-zip', name: 'icons.zip', type: 'application/zip', size: 2048, disposition: 'attachment' },
|
||||
],
|
||||
} as unknown as Email;
|
||||
|
||||
const client = new Proxy({ getAccountId: () => 'me' } as Record<string, unknown>, {
|
||||
get: (target, prop) => (prop in target ? target[prop as string] : prop === 'then' ? undefined : vi.fn().mockResolvedValue(null)),
|
||||
}) as unknown as IJMAPClient;
|
||||
|
||||
const follows = (a: Element, b: Element) => !!(b.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
|
||||
describe('attachment position in the reader', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ client } as never);
|
||||
useSettingsStore.setState({ markAsReadDelay: -1 });
|
||||
});
|
||||
|
||||
it('draws a bar above the body below the header', async () => {
|
||||
useSettingsStore.setState({ attachmentPosition: 'below-header' });
|
||||
const { container } = render(<EmailViewer email={email} mailboxes={[]} selectedMailbox="inbox" />);
|
||||
const body = await screen.findByText('Here is the invoice.');
|
||||
const sections = container.querySelectorAll('[data-testid="reader-attachments"]');
|
||||
expect(sections).toHaveLength(1);
|
||||
expect((sections[0] as HTMLElement).dataset.layout).toBe('bar');
|
||||
expect(follows(body, sections[0])).toBe(true);
|
||||
expect(screen.queryByTestId('attachments-jump')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws cards after the body, with a jump link in the header', async () => {
|
||||
useSettingsStore.setState({ attachmentPosition: 'below-message' });
|
||||
const { container } = render(<EmailViewer email={email} mailboxes={[]} selectedMailbox="inbox" />);
|
||||
const body = await screen.findByText('Here is the invoice.');
|
||||
const sections = container.querySelectorAll('[data-testid="reader-attachments"]');
|
||||
expect(sections).toHaveLength(1);
|
||||
expect((sections[0] as HTMLElement).dataset.layout).toBe('cards');
|
||||
expect(follows(sections[0], body)).toBe(true);
|
||||
expect(sections[0].querySelectorAll('[data-testid="attachment"]')).toHaveLength(2);
|
||||
|
||||
const jump = screen.getByTestId('attachments-jump');
|
||||
const scrollIntoView = vi.fn();
|
||||
sections[0].parentElement!.scrollIntoView = scrollIntoView;
|
||||
jump.click();
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import { useMailReading } from "@/hooks/use-mail-reading";
|
||||
import { useReadAloud } from "@/hooks/use-read-aloud";
|
||||
import { ReadAloudBar } from "./read-aloud-bar";
|
||||
import { attachmentIcon } from "./attachment-chips";
|
||||
import { ReaderAttachments } from "./reader-attachments";
|
||||
import { ReaderAttachments, type AttachmentDragProps } from "./reader-attachments";
|
||||
import { ReaderViewDialog } from "./reader-view-dialog";
|
||||
import { ReadingMask } from "./reading-mask";
|
||||
import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
|
||||
@@ -113,6 +113,7 @@ import {
|
||||
BookOpen,
|
||||
ArrowLeft,
|
||||
Pencil,
|
||||
Paperclip,
|
||||
} from "@/components/icons";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
@@ -3040,6 +3041,28 @@ export function EmailViewer({
|
||||
}
|
||||
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email, t, tFiles]);
|
||||
|
||||
// What the attachment bar and the attachment cards share.
|
||||
const attachmentCardsRef = useRef<HTMLDivElement>(null);
|
||||
const readerAttachmentProps = {
|
||||
attachments: effectiveAttachments,
|
||||
thumbUrls: imageThumbUrls,
|
||||
opensPreview: (attachment: EffectiveAttachment) => isAttachmentPreviewable(attachment) && mailAttachmentAction === 'preview',
|
||||
onOpen: handleEffectiveAttachmentOpen,
|
||||
onDownload: handleEffectiveAttachmentDownload,
|
||||
onDownloadAll: handleDownloadAllAttachments,
|
||||
downloadingAll: isDownloadingAll,
|
||||
onSaveToFiles: canSaveToFiles ? setSaveToFilesTarget : undefined,
|
||||
pluginMenu: (attachment: EffectiveAttachment) => <AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="p-2 rounded-md" />,
|
||||
renderItem: (attachment: EffectiveAttachment, tile: (drag: AttachmentDragProps) => React.ReactNode) => (
|
||||
<DraggableAttachmentChip attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (<>
|
||||
{tile(dragProps)}
|
||||
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
|
||||
</>)}
|
||||
</DraggableAttachmentChip>
|
||||
),
|
||||
};
|
||||
|
||||
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
|
||||
const downloadAllButton = effectiveAttachments.length > 1 ? (
|
||||
<button
|
||||
@@ -5031,6 +5054,20 @@ export function EmailViewer({
|
||||
|
||||
|
||||
</div>
|
||||
{/* Cards mode: a paperclip with the count jumps down to the cards. */}
|
||||
{attachmentPosition === 'below-message' && effectiveAttachments.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => attachmentCardsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
|
||||
title={t('attachments')}
|
||||
aria-label={t('attachments_count', { count: effectiveAttachments.length })}
|
||||
className="self-start flex-shrink-0 inline-flex items-center gap-1.5 h-8 px-2 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
data-testid="attachments-jump"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
<bdi>{effectiveAttachments.length}</bdi>
|
||||
</button>
|
||||
)}
|
||||
{/* Attachments on the right (beside-sender mode) */}
|
||||
{attachmentPosition === 'beside-sender' && effectiveAttachments.length > 0 && (
|
||||
<div className="relative flex flex-col items-end justify-start gap-1 flex-shrink-0 max-w-[50%]">
|
||||
@@ -5870,28 +5907,11 @@ export function EmailViewer({
|
||||
<PluginSlot name="email-banner" extraProps={pluginEmailProps} />
|
||||
|
||||
{/* === ATTACHMENTS: a bar of tiles under the header. Beside-sender
|
||||
mode keeps its own column on wide screens. === */}
|
||||
{effectiveAttachments.length > 0 && (
|
||||
mode keeps its own column on wide screens; cards mode draws them
|
||||
after the body instead. === */}
|
||||
{attachmentPosition !== 'below-message' && effectiveAttachments.length > 0 && (
|
||||
<div className={cn("bg-background border-b border-border px-4 lg:px-6 py-3", attachmentPosition === 'beside-sender' && "lg:hidden")}>
|
||||
<ReaderAttachments
|
||||
attachments={effectiveAttachments}
|
||||
thumbUrls={imageThumbUrls}
|
||||
opensPreview={(attachment) => isAttachmentPreviewable(attachment) && mailAttachmentAction === 'preview'}
|
||||
onOpen={handleEffectiveAttachmentOpen}
|
||||
onDownload={handleEffectiveAttachmentDownload}
|
||||
onDownloadAll={handleDownloadAllAttachments}
|
||||
downloadingAll={isDownloadingAll}
|
||||
onSaveToFiles={canSaveToFiles ? setSaveToFilesTarget : undefined}
|
||||
pluginMenu={(attachment) => <AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="p-2 rounded-md" />}
|
||||
renderItem={(attachment, tile) => (
|
||||
<DraggableAttachmentChip attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
|
||||
{(dragProps) => (<>
|
||||
{tile(dragProps)}
|
||||
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
|
||||
</>)}
|
||||
</DraggableAttachmentChip>
|
||||
)}
|
||||
/>
|
||||
<ReaderAttachments {...readerAttachmentProps} layout="bar" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5987,6 +6007,13 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* === ATTACHMENTS as cards after the body (below-message mode) === */}
|
||||
{attachmentPosition === 'below-message' && effectiveAttachments.length > 0 && !mvOpenFolded && !mvAwaitingConversation && (
|
||||
<div ref={attachmentCardsRef} className={cn("bg-background border-t border-border px-4 lg:px-6 py-4 scroll-mt-4", mvViewer && "lg:ps-[60px]")}>
|
||||
<ReaderAttachments {...readerAttachmentProps} layout="cards" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="email-footer" extraProps={pluginFooterProps} />
|
||||
|
||||
{/* Mountain View: the conversation's later messages, folded. */}
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface ReaderAttachmentItem {
|
||||
blobId?: string;
|
||||
}
|
||||
|
||||
/** "bar": tiles under the header. "cards": larger previews under the message. */
|
||||
export type ReaderAttachmentLayout = "bar" | "cards";
|
||||
|
||||
export interface AttachmentDragProps {
|
||||
draggable: boolean;
|
||||
onPointerEnter: () => void;
|
||||
@@ -40,6 +43,7 @@ export function splitFileName(name: string): [stem: string, ext: string] {
|
||||
|
||||
interface ReaderAttachmentsProps<T extends ReaderAttachmentItem> {
|
||||
attachments: T[];
|
||||
layout?: ReaderAttachmentLayout;
|
||||
/** Object URLs of image attachments, keyed by attachment id. */
|
||||
thumbUrls: Record<string, string>;
|
||||
/** Whether a click on the attachment previews it instead of downloading it. */
|
||||
@@ -68,12 +72,14 @@ const NO_DRAG: AttachmentDragProps = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The attachments of an open message as a bar of equal tiles: a summary line
|
||||
* with "Download all", then one tile per file with its preview, name, type,
|
||||
* size and a download button that is always there.
|
||||
* The attachments of an open message: a summary line with "Download all",
|
||||
* then either a bar of equal tiles (preview, name, type, size and a download
|
||||
* button that is always there) or larger preview cards whose actions show
|
||||
* on hover, focus and touch screens.
|
||||
*/
|
||||
export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
attachments,
|
||||
layout = "bar",
|
||||
thumbUrls,
|
||||
opensPreview,
|
||||
onOpen,
|
||||
@@ -92,7 +98,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
const totalSize = attachments.reduce((sum, a) => sum + (a.size || 0), 0);
|
||||
const collapses = attachments.length > BAR_COLLAPSE_AT;
|
||||
const collapses = layout === "bar" && attachments.length > BAR_COLLAPSE_AT;
|
||||
const shown = collapses && !expanded ? attachments.slice(0, BAR_COLLAPSE_AT - 1) : attachments;
|
||||
const hidden = collapses && !expanded ? attachments.slice(BAR_COLLAPSE_AT - 1) : [];
|
||||
|
||||
@@ -150,8 +156,71 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
);
|
||||
};
|
||||
|
||||
const card = (attachment: T, drag: AttachmentDragProps) => {
|
||||
const name = getAttachmentDisplayName(attachment.name, attachment.type);
|
||||
const [stem, ext] = splitFileName(name);
|
||||
const kind = ext ? ext.slice(1).toUpperCase() : "";
|
||||
const preview = opensPreview(attachment);
|
||||
const url = thumbUrls[attachment.id];
|
||||
const { icon: Icon, className: iconClass } = attachmentIcon(attachment.type, attachment.name);
|
||||
return (
|
||||
<div
|
||||
data-testid="attachment"
|
||||
data-attachment-name={attachment.name ?? undefined}
|
||||
title={`${preview ? tFiles("preview") : t("download")} ${name}`}
|
||||
draggable={drag.draggable}
|
||||
onPointerEnter={drag.onPointerEnter}
|
||||
onDragStart={drag.onDragStart}
|
||||
onDragEnd={drag.onDragEnd}
|
||||
className="group relative flex min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-card transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div aria-hidden="true" className="h-32 bg-muted/60">
|
||||
{url ? (
|
||||
<img src={url} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||
<Icon className={cn("h-10 w-10", iconClass)} />
|
||||
{kind && kind.length <= 5 && (
|
||||
<span className={cn("text-xs font-semibold leading-none", iconClass)}>{kind}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(attachment)}
|
||||
aria-label={preview ? t("preview_named", { name }) : t("download_named", { name })}
|
||||
className="flex min-w-0 flex-col gap-0.5 border-t border-border px-3 py-2.5 text-start outline-none after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-ring"
|
||||
>
|
||||
<span className="flex min-w-0 text-sm text-foreground" dir="auto">
|
||||
<span className="truncate">{stem}</span>
|
||||
{ext && <span className="shrink-0">{ext}</span>}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{kind && <>{kind} · </>}
|
||||
<bdi>{formatFileSize(attachment.size)}</bdi>
|
||||
</span>
|
||||
</button>
|
||||
{/* Over the preview, never over the name. Always shown where there is no hover. */}
|
||||
<div className="absolute end-2 top-2 z-10 flex items-center gap-0.5 rounded-lg border border-border bg-background/90 p-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100">
|
||||
{onSaveToFiles && attachment.blobId && (
|
||||
<IconButton label={t("save_to_files.title")} onClick={() => onSaveToFiles(attachment)}>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
{pluginMenu?.(attachment)}
|
||||
<IconButton label={t("download_named", { name })} title={t("download")} onClick={() => onDownload(attachment)}>
|
||||
<Download className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const draw = layout === "cards" ? card : tile;
|
||||
|
||||
return (
|
||||
<section aria-label={t("attachments")} className={cn("flex flex-col gap-3", className)} data-testid="reader-attachments">
|
||||
<section aria-label={t("attachments")} className={cn("flex flex-col gap-3", className)} data-testid="reader-attachments" data-layout={layout}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Paperclip className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
@@ -176,12 +245,15 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2">
|
||||
<div className={cn(
|
||||
"grid",
|
||||
layout === "cards" ? "grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-3" : "grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2",
|
||||
)}>
|
||||
{shown.map((attachment) => (
|
||||
<div key={attachment.id} className="flex min-w-0 flex-col gap-1">
|
||||
{renderItem
|
||||
? renderItem(attachment, (drag) => tile(attachment, drag))
|
||||
: tile(attachment, NO_DRAG)}
|
||||
? renderItem(attachment, (drag) => draw(attachment, drag))
|
||||
: draw(attachment, NO_DRAG)}
|
||||
</div>
|
||||
))}
|
||||
{hidden.length > 0 && (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useSettingsStore, type AttachmentPosition } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { DownloadsSettings } from './downloads-settings';
|
||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||
@@ -163,10 +163,11 @@ export function ReadingSettings() {
|
||||
<SettingItem label={t('attachment_position.label')} description={t('attachment_position.description')}>
|
||||
<Select
|
||||
value={attachmentPosition}
|
||||
onChange={(value) => updateSetting('attachmentPosition', value as 'beside-sender' | 'below-header')}
|
||||
onChange={(value) => updateSetting('attachmentPosition', value as AttachmentPosition)}
|
||||
options={[
|
||||
{ value: 'beside-sender', label: t('attachment_position.beside-sender') },
|
||||
{ value: 'below-header', label: t('attachment_position.below-header') },
|
||||
{ value: 'below-message', label: t('attachment_position.below-message') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
@@ -1902,9 +1902,10 @@
|
||||
},
|
||||
"attachment_position": {
|
||||
"label": "Anhangsposition",
|
||||
"description": "Wo Anhänge im E-Mail-Header angezeigt werden",
|
||||
"description": "Wo die Anhänge einer geöffneten E-Mail angezeigt werden",
|
||||
"beside-sender": "Neben dem Absender",
|
||||
"below-header": "Unter dem Header"
|
||||
"below-header": "Unter dem Header",
|
||||
"below-message": "Als Karten unter der Nachricht"
|
||||
},
|
||||
"emails_per_page": {
|
||||
"10": "10 E-Mails",
|
||||
|
||||
@@ -1905,9 +1905,10 @@
|
||||
},
|
||||
"attachment_position": {
|
||||
"label": "Attachment position",
|
||||
"description": "Where to display attachments in the email header",
|
||||
"description": "Where to display the attachments of an open email",
|
||||
"beside-sender": "Next to sender",
|
||||
"below-header": "Below header"
|
||||
"below-header": "Below header",
|
||||
"below-message": "Cards below the message"
|
||||
},
|
||||
"emails_per_page": {
|
||||
"10": "10 emails",
|
||||
|
||||
@@ -302,7 +302,8 @@ export type FirstDayOfWeek = 0 | 1 | 6; // 0 = Sunday, 1 = Monday, 6 = Saturday
|
||||
export type TimeZoneSetting = 'auto' | (string & {});
|
||||
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
|
||||
export type MailAttachmentAction = 'preview' | 'download';
|
||||
export type AttachmentPosition = 'beside-sender' | 'below-header';
|
||||
/** 'below-message' shows larger preview cards after the body. */
|
||||
export type AttachmentPosition = 'beside-sender' | 'below-header' | 'below-message';
|
||||
export type ToolbarPosition = 'top' | 'below-subject';
|
||||
export type ArchiveMode = 'single' | 'year' | 'month';
|
||||
export type MailLayout = 'split' | 'focus' | 'horizontal';
|
||||
|
||||
Reference in New Issue
Block a user