Merge remote-tracking branch 'origin/main' into pr/quote-header-i18n
This commit is contained in:
@@ -5,11 +5,12 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeSignatureHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -89,6 +90,7 @@ interface EmailComposerProps {
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
delayedUntil?: string;
|
||||
requestReadReceipt?: boolean;
|
||||
}) => void | Promise<void>;
|
||||
onScheduledSendCreated?: () => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
@@ -206,6 +208,7 @@ export function EmailComposer({
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||
const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault);
|
||||
const activeIdentities = useIdentityStore((s) => s.identities);
|
||||
// Pro shell: surface identities from every connected account, grouped
|
||||
// for the From dropdown's <optgroup>s. Outside Pro this collapses to
|
||||
@@ -266,11 +269,9 @@ export function EmailComposer({
|
||||
const getInitialSubject = () => {
|
||||
if (!replyTo?.subject) return "";
|
||||
if (mode === 'forward') {
|
||||
const fwdPrefix = t('prefix.forward');
|
||||
return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`;
|
||||
return buildForwardSubject(replyTo.subject, t('prefix.forward'));
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
const rePrefix = t('prefix.reply');
|
||||
return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
|
||||
return buildReplySubject(replyTo.subject, t('prefix.reply'));
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -398,6 +399,7 @@ export function EmailComposer({
|
||||
const [body, setBody] = useState(initialData?.body ?? getInitialBody());
|
||||
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
|
||||
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
||||
const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault);
|
||||
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
|
||||
// Mirror of draftId for synchronous reads inside chained saves; React's
|
||||
// setDraftId is async, so a queued saveDraft would otherwise see the old
|
||||
@@ -1670,6 +1672,7 @@ export function EmailComposer({
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||
inReplyTo: threadingHeaders?.inReplyTo,
|
||||
references: threadingHeaders?.references,
|
||||
requestReadReceipt,
|
||||
delayedUntil: effectiveDelayedUntil,
|
||||
});
|
||||
|
||||
@@ -2231,7 +2234,7 @@ export function EmailComposer({
|
||||
)}
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(env(safe-area-inset-bottom)/2)]">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0 pb-[calc(0.625rem+env(safe-area-inset-bottom)/2)]">
|
||||
{/* Left side actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
@@ -2294,6 +2297,21 @@ export function EmailComposer({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Read-receipt request toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setRequestReadReceipt(v => !v)}
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
requestReadReceipt && "bg-green-600 text-white hover:bg-green-600 hover:text-white dark:bg-green-600 dark:hover:bg-green-600"
|
||||
)}
|
||||
title={requestReadReceipt ? t('read_receipt_on') : t('read_receipt_off')}
|
||||
aria-pressed={requestReadReceipt}
|
||||
>
|
||||
<MailCheck className="w-4 h-4" />
|
||||
</Button>
|
||||
<PluginSlot name="composer-toolbar" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -83,6 +83,8 @@ import { useThemeStore } from "@/stores/theme-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
import { ReadReceiptBanner } from "./read-receipt-banner";
|
||||
import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||
@@ -911,6 +913,7 @@ export function EmailViewer({
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||
const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse);
|
||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
|
||||
const dragOutActive = useMemo(() => isDragOutSupported(), []);
|
||||
@@ -2193,6 +2196,9 @@ export function EmailViewer({
|
||||
// Hide inline cid-referenced images when the user has opted to keep them
|
||||
// out of the attachment list (default on): these are embedded in the body.
|
||||
.filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||
// Hide machine-readable report parts (MDN read-receipts, DSN bounce
|
||||
// reports). These are required MIME parts, not real user attachments.
|
||||
.filter(att => att.type !== 'message/disposition-notification' && att.type !== 'message/delivery-status')
|
||||
.map((attachment, index) => ({
|
||||
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
||||
name: attachment.name || null,
|
||||
@@ -3252,6 +3258,103 @@ export function EmailViewer({
|
||||
? calendarInvitationParsingEnabled && !!findCalendarAttachment(email)
|
||||
: false;
|
||||
|
||||
// ── Read receipt (MDN, RFC 8098) ──────────────────────────────
|
||||
// Detect a Disposition-Notification-To request on the open message. The
|
||||
// header is parsed into email.headers by the client; look it up
|
||||
// case-insensitively and extract the bare address.
|
||||
const readReceiptRequestedBy = useMemo(() => {
|
||||
const headers = email?.headers as Record<string, string | string[]> | undefined;
|
||||
if (!headers) return null;
|
||||
let raw: string | undefined;
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === 'disposition-notification-to') {
|
||||
const v = headers[key];
|
||||
raw = Array.isArray(v) ? v[0] : v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!raw) return null;
|
||||
const m = raw.match(/<([^>]+)>/);
|
||||
const addr = (m ? m[1] : raw).trim();
|
||||
return addr || null;
|
||||
}, [email?.headers]);
|
||||
|
||||
// The identity whose address received the original message — the MDN is sent
|
||||
// "from" that address. Falls back to the primary identity.
|
||||
const receiptIdentity = useMemo(() => {
|
||||
if (!identities?.length) return null;
|
||||
const recipients = [...(email?.to || []), ...(email?.cc || [])]
|
||||
.map(r => r.email?.toLowerCase())
|
||||
.filter(Boolean);
|
||||
return identities.find(i => recipients.includes(i.email?.toLowerCase())) || identities[0];
|
||||
}, [identities, email?.to, email?.cc]);
|
||||
|
||||
const mdnAlreadyHandled = email?.keywords?.['$mdnsent'] === true;
|
||||
const [mdnHandledLocally, setMdnHandledLocally] = useState(false);
|
||||
useEffect(() => { setMdnHandledLocally(false); }, [email?.id]);
|
||||
|
||||
// Only offer the receipt for mail you're actually reading in a "received"
|
||||
// location. Suppress your own copies (sent/drafts), discarded mail (trash),
|
||||
// and spam (junk) - never confirm your address to spammers. Inbox, Archive
|
||||
// and user folders all qualify.
|
||||
const inReceiptEligibleFolder = !['sent', 'drafts', 'trash', 'junk'].includes(currentMailboxRole || '');
|
||||
|
||||
const shouldOfferReadReceipt =
|
||||
!!readReceiptRequestedBy &&
|
||||
!mdnAlreadyHandled &&
|
||||
!mdnHandledLocally &&
|
||||
readReceiptResponse !== 'never' &&
|
||||
inReceiptEligibleFolder &&
|
||||
!isDraft &&
|
||||
!!receiptIdentity;
|
||||
|
||||
const sendReadReceiptNow = useCallback(async (automatic: boolean) => {
|
||||
if (!client || !email || !readReceiptRequestedBy || !receiptIdentity) return;
|
||||
const { rawId } = stripCrossAccountIdentityPrefix(receiptIdentity.id);
|
||||
try {
|
||||
await client.sendReadReceipt({
|
||||
to: readReceiptRequestedBy,
|
||||
fromEmail: receiptIdentity.email,
|
||||
fromName: receiptIdentity.name,
|
||||
identityId: rawId ?? receiptIdentity.id,
|
||||
originalMessageId: email.messageId,
|
||||
originalSubject: email.subject,
|
||||
originalRecipient: receiptIdentity.email,
|
||||
automatic,
|
||||
subject: t('read_receipt.mdn_subject', { subject: email.subject || '' }),
|
||||
humanText: t('read_receipt.mdn_body', { recipient: receiptIdentity.email }),
|
||||
});
|
||||
await client.setKeyword(email.id, '$mdnsent');
|
||||
} catch (err) {
|
||||
// Surface the failure instead of silently resetting the banner so we can
|
||||
// see which step (upload / import / submission) failed.
|
||||
console.error('Read-receipt (MDN) send failed:', err);
|
||||
toast.error(t('read_receipt.send_failed'), {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}, [client, email, readReceiptRequestedBy, receiptIdentity, t]);
|
||||
|
||||
const ignoreReadReceipt = useCallback(async () => {
|
||||
setMdnHandledLocally(true);
|
||||
if (client && email) {
|
||||
// $MDNSent is the RFC 3503 flag every IMAP/JMAP client honours, so the
|
||||
// request is suppressed everywhere - not just locally.
|
||||
try { await client.setKeyword(email.id, '$mdnsent'); } catch { /* best effort */ }
|
||||
}
|
||||
}, [client, email]);
|
||||
|
||||
// "always" mode: auto-send the MDN once when the message is opened.
|
||||
const autoMdnRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (readReceiptResponse !== 'always') return;
|
||||
if (!shouldOfferReadReceipt || !email) return;
|
||||
if (autoMdnRef.current === email.id) return;
|
||||
autoMdnRef.current = email.id;
|
||||
sendReadReceiptNow(true).catch(() => { autoMdnRef.current = null; });
|
||||
}, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]);
|
||||
|
||||
// Show loading skeleton while email is being fetched
|
||||
if (isLoading && !email) {
|
||||
return (
|
||||
@@ -4946,9 +5049,10 @@ export function EmailViewer({
|
||||
error={smimeUnlockError}
|
||||
/>
|
||||
|
||||
{/* Unified Notification Banner - External Content + Calendar Invitation */}
|
||||
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
|
||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
hasCalendarInvitation) && (
|
||||
hasCalendarInvitation ||
|
||||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
|
||||
<div className="border-b border-border bg-muted/30 isolate">
|
||||
<div className="px-6 py-1.5">
|
||||
<div className="flex flex-col gap-3 isolate">
|
||||
@@ -5003,6 +5107,17 @@ export function EmailViewer({
|
||||
|
||||
|
||||
|
||||
{/* Read-receipt (MDN) request banner — only in "ask" mode */}
|
||||
{readReceiptResponse === 'ask' && shouldOfferReadReceipt && readReceiptRequestedBy && (
|
||||
<div className="py-1">
|
||||
<ReadReceiptBanner
|
||||
requestedBy={readReceiptRequestedBy}
|
||||
onSend={() => sendReadReceiptNow(false)}
|
||||
onIgnore={ignoreReadReceipt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendar Invitation Banner */}
|
||||
{hasCalendarInvitation && (
|
||||
<div className="py-1">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { MailCheck, Loader2, CheckCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface ReadReceiptBannerProps {
|
||||
/** Address that requested the receipt (Disposition-Notification-To). */
|
||||
requestedBy: string;
|
||||
/** Sends the MDN. Should resolve when the receipt has been submitted. */
|
||||
onSend: () => Promise<void>;
|
||||
/** Suppresses the request without sending (sets $MDNSent server-side). */
|
||||
onIgnore: () => void;
|
||||
}
|
||||
|
||||
export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceiptBannerProps) {
|
||||
const t = useTranslations('email_viewer.read_receipt');
|
||||
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||
|
||||
if (state === 'sent') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
||||
<span>{t('sent')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm dark:border-amber-700/50 dark:bg-amber-950/30">
|
||||
<MailCheck className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-foreground">{t('prompt')}</span>
|
||||
<span className="break-all text-muted-foreground">{requestedBy}</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setState('sending');
|
||||
try {
|
||||
await onSend();
|
||||
setState('sent');
|
||||
} catch {
|
||||
setState('idle');
|
||||
}
|
||||
}}
|
||||
disabled={state === 'sending'}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{state === 'sending' && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{t('send')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onIgnore}
|
||||
className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700"
|
||||
>
|
||||
{t('ignore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ interface FilterRuleModalProps {
|
||||
}
|
||||
|
||||
const ALL_FIELDS: FilterConditionField[] = [
|
||||
"from", "to", "cc", "subject", "header", "size", "body",
|
||||
"from", "to", "cc", "subject", "header", "size", "body", "attachment",
|
||||
];
|
||||
|
||||
const TEXT_COMPARATORS: FilterComparator[] = [
|
||||
@@ -36,6 +36,14 @@ const TEXT_COMPARATORS: FilterComparator[] = [
|
||||
|
||||
const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"];
|
||||
|
||||
const ATTACHMENT_COMPARATORS: FilterComparator[] = ["has_any", "has_type"];
|
||||
|
||||
function comparatorsFor(field: FilterConditionField): FilterComparator[] {
|
||||
if (field === "size") return SIZE_COMPARATORS;
|
||||
if (field === "attachment") return ATTACHMENT_COMPARATORS;
|
||||
return TEXT_COMPARATORS;
|
||||
}
|
||||
|
||||
const ALL_ACTION_TYPES: FilterActionType[] = [
|
||||
"move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop",
|
||||
];
|
||||
@@ -47,6 +55,27 @@ function makeEmptyCondition(): FilterCondition {
|
||||
return { field: "from", comparator: "contains", value: "" };
|
||||
}
|
||||
|
||||
// Multi-value handling: conditions are stored as string | string[]. The UI
|
||||
// presents them as a single comma-separated text input — the user types
|
||||
// "a, b, c" and the saved value becomes ["a","b","c"]. Single entries stay
|
||||
// strings so existing single-value rules don't change shape.
|
||||
function valueToInputString(v: string | string[]): string {
|
||||
if (Array.isArray(v)) return v.join(", ");
|
||||
return v;
|
||||
}
|
||||
|
||||
function inputStringToValue(s: string): string | string[] {
|
||||
const parts = s.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
||||
if (parts.length === 0) return "";
|
||||
if (parts.length === 1) return parts[0];
|
||||
return parts;
|
||||
}
|
||||
|
||||
function isConditionValueEmpty(v: string | string[]): boolean {
|
||||
if (Array.isArray(v)) return v.length === 0 || v.every((x) => !x.trim());
|
||||
return !v.trim();
|
||||
}
|
||||
|
||||
function makeEmptyAction(): FilterAction {
|
||||
return { type: "move", value: "" };
|
||||
}
|
||||
@@ -97,9 +126,23 @@ export function FilterRuleModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const validConditions = conditions.filter(
|
||||
(c) => c.value.trim()
|
||||
);
|
||||
// While editing, condition.value is always the raw string typed into the
|
||||
// input (commas not yet split). Convert to array form here on save so a
|
||||
// user typing "a, b, c" actually persists as ["a","b","c"]. This is the
|
||||
// moment we know editing is finished - splitting earlier would eat any
|
||||
// comma the user just typed mid-edit.
|
||||
const validConditions = conditions
|
||||
.filter((c) => {
|
||||
if (c.field === "attachment" && c.comparator === "has_any") return true;
|
||||
return !isConditionValueEmpty(c.value);
|
||||
})
|
||||
.map((c) => {
|
||||
if (c.field === "attachment" && c.comparator === "has_any") return c;
|
||||
if (c.field === "size") return c; // numeric, single-value only
|
||||
if (typeof c.value !== "string") return c; // already structured
|
||||
const parsed = inputStringToValue(c.value);
|
||||
return { ...c, value: parsed };
|
||||
});
|
||||
if (validConditions.length === 0) {
|
||||
toast.error(t("validation_empty_conditions"));
|
||||
return;
|
||||
@@ -129,15 +172,27 @@ export function FilterRuleModal({
|
||||
prev.map((c, i) => {
|
||||
if (i !== index) return c;
|
||||
const updated = { ...c, ...updates };
|
||||
if (updates.field === "size" && !SIZE_COMPARATORS.includes(c.comparator)) {
|
||||
updated.comparator = "greater_than";
|
||||
}
|
||||
if (updates.field && updates.field !== "size" && SIZE_COMPARATORS.includes(c.comparator)) {
|
||||
updated.comparator = "contains";
|
||||
// Reconcile the comparator when the field changes so we never end up
|
||||
// with e.g. (field=attachment, comparator=contains) — invalid for the
|
||||
// Sieve generator. Each field has its own valid comparator set.
|
||||
if (updates.field && updates.field !== c.field) {
|
||||
const allowed = comparatorsFor(updates.field);
|
||||
if (!allowed.includes(c.comparator)) {
|
||||
updated.comparator = allowed[0];
|
||||
}
|
||||
}
|
||||
if (updates.field && updates.field !== "header") {
|
||||
delete updated.headerName;
|
||||
}
|
||||
// has_any takes no value; clear it so we don't leak old text into
|
||||
// the generated Sieve.
|
||||
if (updated.field === "attachment" && updated.comparator === "has_any") {
|
||||
updated.value = "";
|
||||
}
|
||||
// Size is numeric, single value only - collapse any list to scalar.
|
||||
if (updated.field === "size" && Array.isArray(updated.value)) {
|
||||
updated.value = updated.value[0] ?? "";
|
||||
}
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
@@ -281,24 +336,58 @@ export function FilterRuleModal({
|
||||
className={selectClass}
|
||||
aria-label={t("comparators.contains")}
|
||||
>
|
||||
{(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map(
|
||||
(c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`comparators.${c}`)}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
{comparatorsFor(condition.field).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`comparators.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Input
|
||||
value={condition.value}
|
||||
onChange={(e) => updateCondition(index, { value: e.target.value })}
|
||||
placeholder={
|
||||
condition.field === "size" ? t("size_placeholder") : t("header_placeholder")
|
||||
}
|
||||
className="flex-1 min-w-[120px]"
|
||||
type={condition.field === "size" ? "number" : "text"}
|
||||
/>
|
||||
{/* has_any takes no value; render a stub so the row layout
|
||||
stays consistent but no input is editable. */}
|
||||
{condition.field === "attachment" && condition.comparator === "has_any" ? (
|
||||
<div className="flex-1 min-w-[120px]" />
|
||||
) : (
|
||||
<Input
|
||||
value={valueToInputString(condition.value)}
|
||||
onChange={(e) =>
|
||||
// Store the raw input string while typing. Splitting
|
||||
// commas into an array on every keystroke would eat
|
||||
// the comma the moment it's typed.
|
||||
updateCondition(index, { value: e.target.value })
|
||||
}
|
||||
onBlur={(e) => {
|
||||
// On blur: normalise comma-separated input into an
|
||||
// array (or single string when only one item). Size
|
||||
// stays numeric/single-value; attachment-has_any has
|
||||
// no value at all.
|
||||
if (condition.field === "size") return;
|
||||
if (
|
||||
condition.field === "attachment" &&
|
||||
condition.comparator === "has_any"
|
||||
)
|
||||
return;
|
||||
const parsed = inputStringToValue(e.target.value);
|
||||
// Only update if the normalised shape actually
|
||||
// differs - avoids triggering a no-op re-render and
|
||||
// resetting the user's cursor on every blur.
|
||||
if (
|
||||
JSON.stringify(parsed) !== JSON.stringify(condition.value)
|
||||
) {
|
||||
updateCondition(index, { value: parsed });
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
condition.field === "size"
|
||||
? t("size_placeholder")
|
||||
: condition.field === "attachment"
|
||||
? t("attachment_type_placeholder")
|
||||
: t("value_placeholder_multi")
|
||||
}
|
||||
className="flex-1 min-w-[120px]"
|
||||
type={condition.field === "size" ? "number" : "text"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,6 +7,19 @@
|
||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
|
||||
|
||||
// Lightweight **bold** support in plugin dialog messages. Everything else is
|
||||
// rendered literally (newlines come from the parent's white-space: pre-wrap).
|
||||
// Splitting on the ** delimiter yields alternating plain/bold segments (odd
|
||||
// indices are bold). Plugins control these strings, so the delimiters balance.
|
||||
function renderMessage(message?: string): React.ReactNode {
|
||||
if (!message) return null;
|
||||
return message.split('**').map((seg, i) =>
|
||||
i % 2 === 1
|
||||
? <strong key={i}>{seg}</strong>
|
||||
: <React.Fragment key={i}>{seg}</React.Fragment>,
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginDialogHost(): React.JSX.Element | null {
|
||||
const current = useSyncExternalStore(subscribe, head, () => null);
|
||||
|
||||
@@ -50,9 +63,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--background, #fff)',
|
||||
color: 'var(--foreground, #0f172a)',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
background: 'var(--color-popover, #fff)',
|
||||
color: 'var(--color-popover-foreground, #0f172a)',
|
||||
border: '1px solid var(--color-border, #e2e8f0)',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
maxWidth: 480,
|
||||
@@ -63,8 +76,8 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
|
||||
{current.title}
|
||||
</h2>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--muted-foreground, #64748b)', whiteSpace: 'pre-wrap' }}>
|
||||
{current.message}
|
||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||
{renderMessage(current.message)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{current.kind === 'confirm' && (
|
||||
@@ -78,7 +91,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
border: '1px solid var(--color-border, #e2e8f0)',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
}}
|
||||
@@ -97,14 +110,14 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
background: current.danger ? '#dc2626' : '#3b82f6',
|
||||
color: '#fff',
|
||||
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
|
||||
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--color-muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
From plugin: {current.pluginId}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -104,7 +105,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
initialDraftText: draftText,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
@@ -116,7 +117,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
mode: 'replyAll',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
@@ -128,7 +129,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
mode: 'forward',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Fwd: ${email.subject || t('email_composer.new_message')}`,
|
||||
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Download } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
@@ -17,6 +18,7 @@ export function PWAInstallPrompt() {
|
||||
useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const t = useTranslations("pwa_install");
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(DISMISSED_KEY)) return;
|
||||
@@ -84,17 +86,17 @@ export function PWAInstallPrompt() {
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
|
||||
Install {appName}
|
||||
{t("title", { appName })}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
Install our app for quick access and offline support.
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
||||
aria-label="Dismiss install prompt"
|
||||
aria-label={t("dismiss_aria")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -105,20 +107,20 @@ export function PWAInstallPrompt() {
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
Not now
|
||||
{t("not_now")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Install
|
||||
{t("install")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismissForever}
|
||||
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
|
||||
>
|
||||
Don't remind me again
|
||||
{t("dont_remind")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,8 @@ export function ComposingSettings() {
|
||||
subAddressDelimiter,
|
||||
signaturePosition,
|
||||
signatureSeparatorEnabled,
|
||||
requestReadReceiptDefault,
|
||||
readReceiptResponse,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { client } = useAuthStore();
|
||||
@@ -78,6 +80,25 @@ export function ComposingSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('request_read_receipt.label')} description={t('request_read_receipt.description')}>
|
||||
<ToggleSwitch
|
||||
checked={requestReadReceiptDefault}
|
||||
onChange={(checked) => updateSetting('requestReadReceiptDefault', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('read_receipt_response.label')} description={t('read_receipt_response.description')}>
|
||||
<Select
|
||||
value={readReceiptResponse}
|
||||
onChange={(value) => updateSetting('readReceiptResponse', value as 'ask' | 'always' | 'never')}
|
||||
options={[
|
||||
{ value: 'ask', label: t('read_receipt_response.ask') },
|
||||
{ value: 'always', label: t('read_receipt_response.always') },
|
||||
{ value: 'never', label: t('read_receipt_response.never') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('sub_address_delimiter.label')}
|
||||
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
||||
|
||||
@@ -36,7 +36,17 @@ function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||
const conditions = rule.conditions.slice(0, 2).map((c) => {
|
||||
const field = t(`condition_fields.${c.field}`);
|
||||
const comparator = t(`comparators.${c.comparator}`);
|
||||
return `${field} ${comparator} "${c.value}"`;
|
||||
// has_any is a no-value test ("attachment is present"); appending
|
||||
// `""` would look broken in the summary line.
|
||||
if (c.field === "attachment" && c.comparator === "has_any") {
|
||||
return `${field} ${comparator}`;
|
||||
}
|
||||
// Multi-value conditions render as "a" / "b" / "c" with the locale's
|
||||
// OR-glue between items so the line still reads as natural language.
|
||||
const valueStr = Array.isArray(c.value)
|
||||
? c.value.map((v) => `"${v}"`).join(` ${t("or")} `)
|
||||
: `"${c.value}"`;
|
||||
return `${field} ${comparator} ${valueStr}`;
|
||||
});
|
||||
|
||||
const joiner = rule.matchType === "all" ? t("and") : t("or");
|
||||
@@ -97,7 +107,22 @@ function VisualRuleSummary({ rule }: { rule: FilterRule }) {
|
||||
<span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">{field}</span>
|
||||
<span className="text-muted-foreground">{comparator}</span>
|
||||
<span className="text-foreground">“{c.value}”</span>
|
||||
{!(c.field === "attachment" && c.comparator === "has_any") && (
|
||||
<span className="text-foreground">
|
||||
{Array.isArray(c.value)
|
||||
? c.value.map((v, k) => (
|
||||
<span key={k}>
|
||||
{k > 0 && (
|
||||
<span className="text-muted-foreground/70 italic mx-0.5">
|
||||
{t("or")}
|
||||
</span>
|
||||
)}
|
||||
“{v}”
|
||||
</span>
|
||||
))
|
||||
: <>“{c.value}”</>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user