Merge branch 'main' into feature/scheduled-send
This commit is contained in:
@@ -48,7 +48,12 @@ export function CalendarSidebarPanel({
|
||||
const tSub = useTranslations("calendar.subscription");
|
||||
const tMgmt = useTranslations("calendar.management");
|
||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
||||
const allSubs = useCalendarStore((s) => s.icalSubscriptions);
|
||||
const currentAccountId = client?.getAccountId();
|
||||
const icalSubscriptions = useMemo(
|
||||
() => allSubs.filter(s => !s.accountId || s.accountId === currentAccountId),
|
||||
[allSubs, currentAccountId],
|
||||
);
|
||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft } from "lucide-react";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||
@@ -76,7 +76,65 @@ function buildDuration(startDate: Date, endDate: Date): string {
|
||||
}
|
||||
|
||||
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
||||
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
|
||||
|
||||
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
|
||||
|
||||
interface AlertRow {
|
||||
id: string;
|
||||
value: number;
|
||||
unit: AlertUnit;
|
||||
}
|
||||
|
||||
let alertRowSeq = 0;
|
||||
function newAlertRow(value: number, unit: AlertUnit): AlertRow {
|
||||
alertRowSeq += 1;
|
||||
return { id: `r${alertRowSeq}`, value, unit };
|
||||
}
|
||||
|
||||
function alertRowToOffset(row: AlertRow): string | null {
|
||||
if (row.unit === "at_time") return "PT0S";
|
||||
const v = Math.max(0, Math.floor(row.value));
|
||||
if (!Number.isFinite(v) || v <= 0) return null;
|
||||
switch (row.unit) {
|
||||
case "minutes": return `-PT${v}M`;
|
||||
case "hours": return `-PT${v}H`;
|
||||
case "days": return `-P${v}D`;
|
||||
case "weeks": return `-P${v}W`;
|
||||
}
|
||||
}
|
||||
|
||||
function offsetToAlertRow(offset: string): AlertRow | null {
|
||||
if (offset === "PT0S" || offset === "P0D" || offset === "PT0M") {
|
||||
return newAlertRow(0, "at_time");
|
||||
}
|
||||
let m = offset.match(/^-?P(\d+)W$/);
|
||||
if (m) return newAlertRow(parseInt(m[1], 10), "weeks");
|
||||
m = offset.match(/^-?P(\d+)D$/);
|
||||
if (m) return newAlertRow(parseInt(m[1], 10), "days");
|
||||
m = offset.match(/^-?PT(\d+)H$/);
|
||||
if (m) return newAlertRow(parseInt(m[1], 10), "hours");
|
||||
m = offset.match(/^-?PT(\d+)M$/);
|
||||
if (m) {
|
||||
const mins = parseInt(m[1], 10);
|
||||
if (mins > 0 && mins % 1440 === 0) return newAlertRow(mins / 1440, "days");
|
||||
if (mins > 0 && mins % 60 === 0) return newAlertRow(mins / 60, "hours");
|
||||
return newAlertRow(mins, "minutes");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatAlertRowLabel(
|
||||
row: { value: number; unit: AlertUnit },
|
||||
t: ReturnType<typeof useTranslations>
|
||||
): string {
|
||||
if (row.unit === "at_time") return t("alerts.at_time");
|
||||
switch (row.unit) {
|
||||
case "minutes": return t("alerts.minutes_before", { count: row.value });
|
||||
case "hours": return t("alerts.hours_before", { count: row.value });
|
||||
case "days": return t("alerts.days_before", { count: row.value });
|
||||
case "weeks": return t("alerts.weeks_before", { count: row.value });
|
||||
}
|
||||
}
|
||||
|
||||
function formatDurationDisplay(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}min`;
|
||||
@@ -88,17 +146,15 @@ function formatDurationDisplay(minutes: number): string {
|
||||
|
||||
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||
if (!event.alerts) return null;
|
||||
const first = Object.values(event.alerts)[0];
|
||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||
const offset = first.trigger.offset;
|
||||
if (offset === "PT0S") return t("alerts.at_time");
|
||||
const minMatch = offset.match(/-?PT(\d+)M$/);
|
||||
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
|
||||
const hourMatch = offset.match(/-?PT(\d+)H$/);
|
||||
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
|
||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
|
||||
return null;
|
||||
const labels: string[] = [];
|
||||
for (const alert of Object.values(event.alerts)) {
|
||||
if (alert.trigger["@type"] !== "OffsetTrigger") continue;
|
||||
const row = offsetToAlertRow(alert.trigger.offset);
|
||||
if (!row) continue;
|
||||
labels.push(formatAlertRowLabel(row, t));
|
||||
}
|
||||
if (labels.length === 0) return null;
|
||||
return labels.join(", ");
|
||||
}
|
||||
|
||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||
@@ -216,22 +272,36 @@ export function EventModal({
|
||||
if (!event?.recurrenceRules?.length) return "none";
|
||||
return event.recurrenceRules[0].frequency as RecurrenceOption;
|
||||
});
|
||||
const [alert, setAlert] = useState<AlertOption>(() => {
|
||||
if (!event?.alerts) return "none";
|
||||
const first = Object.values(event.alerts)[0];
|
||||
if (!first) return "none";
|
||||
if (first.trigger["@type"] === "OffsetTrigger") {
|
||||
const offset = first.trigger.offset;
|
||||
if (offset === "PT0S") return "at_time";
|
||||
const minMatch = offset.match(/-?PT(\d+)M$/);
|
||||
if (minMatch) return minMatch[1] as AlertOption;
|
||||
const hourMatch = offset.match(/-?PT(\d+)H$/);
|
||||
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption;
|
||||
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption;
|
||||
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
|
||||
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
|
||||
if (!event?.alerts) return [];
|
||||
const rows: AlertRow[] = [];
|
||||
for (const [id, alert] of Object.entries(event.alerts)) {
|
||||
// Preserve alerts we can't represent in this UI (absolute triggers,
|
||||
// email actions, offsets with non-canonical shapes) so they survive a save.
|
||||
if (alert.trigger["@type"] !== "OffsetTrigger" || alert.action !== "display") {
|
||||
preservedAlertsRef.current[id] = alert;
|
||||
continue;
|
||||
}
|
||||
const row = offsetToAlertRow(alert.trigger.offset);
|
||||
if (!row) {
|
||||
preservedAlertsRef.current[id] = alert;
|
||||
continue;
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return "none";
|
||||
return rows;
|
||||
});
|
||||
|
||||
const addAlertRow = useCallback(() => {
|
||||
setAlertRows((prev) => [...prev, newAlertRow(10, "minutes")]);
|
||||
}, []);
|
||||
const updateAlertRow = useCallback((id: string, patch: Partial<Omit<AlertRow, "id">>) => {
|
||||
setAlertRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
}, []);
|
||||
const removeAlertRow = useCallback((id: string) => {
|
||||
setAlertRows((prev) => prev.filter((r) => r.id !== id));
|
||||
}, []);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
@@ -400,17 +470,23 @@ export function EventModal({
|
||||
if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null;
|
||||
}
|
||||
|
||||
if (alert !== "none") {
|
||||
const offset = alert === "at_time" ? "PT0S" : `-PT${alert}M`;
|
||||
data.alerts = {
|
||||
alert1: {
|
||||
"@type": "Alert",
|
||||
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
|
||||
action: "display",
|
||||
acknowledged: null,
|
||||
relatedTo: null,
|
||||
},
|
||||
const builtAlerts: Record<string, CalendarEventAlert> = { ...preservedAlertsRef.current };
|
||||
let alertIdx = 0;
|
||||
for (const row of alertRows) {
|
||||
const offset = alertRowToOffset(row);
|
||||
if (offset === null) continue;
|
||||
let key = `alert${++alertIdx}`;
|
||||
while (key in builtAlerts) key = `alert${++alertIdx}`;
|
||||
builtAlerts[key] = {
|
||||
"@type": "Alert",
|
||||
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
|
||||
action: "display",
|
||||
acknowledged: null,
|
||||
relatedTo: null,
|
||||
};
|
||||
}
|
||||
if (Object.keys(builtAlerts).length > 0) {
|
||||
data.alerts = builtAlerts;
|
||||
} else if (event && event.alerts && Object.keys(event.alerts).length > 0) {
|
||||
data.alerts = null;
|
||||
}
|
||||
@@ -435,7 +511,7 @@ export function EventModal({
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -987,37 +1063,81 @@ export function EventModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
||||
<select
|
||||
value={recurrence}
|
||||
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="none">{t("recurrence.none")}</option>
|
||||
<option value="daily">{t("recurrence.daily")}</option>
|
||||
<option value="weekly">{t("recurrence.weekly")}</option>
|
||||
<option value="monthly">{t("recurrence.monthly")}</option>
|
||||
<option value="yearly">{t("recurrence.yearly")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
|
||||
<select
|
||||
value={alert}
|
||||
onChange={(e) => setAlert(e.target.value as AlertOption)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="none">{t("alerts.none")}</option>
|
||||
<option value="at_time">{t("alerts.at_time")}</option>
|
||||
<option value="5">{t("alerts.minutes_before", { count: 5 })}</option>
|
||||
<option value="15">{t("alerts.minutes_before", { count: 15 })}</option>
|
||||
<option value="30">{t("alerts.minutes_before", { count: 30 })}</option>
|
||||
<option value="60">{t("alerts.hours_before", { count: 1 })}</option>
|
||||
<option value="1440">{t("alerts.days_before", { count: 1 })}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
|
||||
<select
|
||||
value={recurrence}
|
||||
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="none">{t("recurrence.none")}</option>
|
||||
<option value="daily">{t("recurrence.daily")}</option>
|
||||
<option value="weekly">{t("recurrence.weekly")}</option>
|
||||
<option value="monthly">{t("recurrence.monthly")}</option>
|
||||
<option value="yearly">{t("recurrence.yearly")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
|
||||
{alertRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("alerts.none")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{alertRows.map((row) => (
|
||||
<div key={row.id} className="flex items-center gap-2">
|
||||
{row.unit !== "at_time" && (
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={row.value}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
updateAlertRow(row.id, { value: Number.isFinite(n) ? Math.max(1, n) : 1 });
|
||||
}}
|
||||
className="w-20"
|
||||
aria-label={t("alerts.amount")}
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
value={row.unit}
|
||||
onChange={(e) => {
|
||||
const unit = e.target.value as AlertUnit;
|
||||
updateAlertRow(row.id, {
|
||||
unit,
|
||||
value: unit === "at_time" ? 0 : (row.value || 1),
|
||||
});
|
||||
}}
|
||||
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
aria-label={t("alerts.unit")}
|
||||
>
|
||||
<option value="at_time">{t("alerts.at_time")}</option>
|
||||
<option value="minutes">{t("alerts.unit_minutes_before")}</option>
|
||||
<option value="hours">{t("alerts.unit_hours_before")}</option>
|
||||
<option value="days">{t("alerts.unit_days_before")}</option>
|
||||
<option value="weeks">{t("alerts.unit_weeks_before")}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAlertRow(row.id)}
|
||||
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label={t("alerts.remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAlertRow}
|
||||
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("alerts.add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{attendees.length > 0 && (
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ICalImportModalProps {
|
||||
calendars: Calendar[];
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
@@ -25,7 +26,7 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||
type ImportStep = "select" | "preview" | "importing";
|
||||
type ImportMode = "file" | "url";
|
||||
|
||||
export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) {
|
||||
export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) {
|
||||
const t = useTranslations("calendar.import");
|
||||
const tCal = useTranslations("calendar");
|
||||
const tCommon = useTranslations("common");
|
||||
@@ -43,8 +44,8 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
const [isParsing, setIsParsing] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [importMode, setImportMode] = useState<ImportMode>("file");
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
const [importMode, setImportMode] = useState<ImportMode>(initialUrl ? "url" : "file");
|
||||
const [urlInput, setUrlInput] = useState(initialUrl || "");
|
||||
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -13,9 +13,11 @@ interface ICalSubscriptionModalProps {
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
editSubscription?: ICalSubscription;
|
||||
initialUrl?: string;
|
||||
initialName?: string;
|
||||
}
|
||||
|
||||
export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) {
|
||||
export function ICalSubscriptionModal({ client, onClose, editSubscription, initialUrl, initialName }: ICalSubscriptionModalProps) {
|
||||
const t = useTranslations("calendar.subscription");
|
||||
const tCommon = useTranslations("common");
|
||||
const addICalSubscription = useCalendarStore((s) => s.addICalSubscription);
|
||||
@@ -23,8 +25,8 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa
|
||||
|
||||
const isEdit = !!editSubscription;
|
||||
|
||||
const [url, setUrl] = useState(editSubscription?.url || "");
|
||||
const [name, setName] = useState(editSubscription?.name || "");
|
||||
const [url, setUrl] = useState(editSubscription?.url || initialUrl || "");
|
||||
const [name, setName] = useState(editSubscription?.name || initialName || "");
|
||||
const [color, setColor] = useState(editSubscription?.color || "#3b82f6");
|
||||
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -76,7 +78,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [url, name, color, refreshInterval, client, addICalSubscription, onClose, t]);
|
||||
}, [url, name, color, refreshInterval, client, isEdit, editSubscription, addICalSubscription, updateICalSubscription, onClose, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -388,41 +388,56 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
setActionNotice(null);
|
||||
setActionError(null);
|
||||
try {
|
||||
const events = await client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId);
|
||||
if (events.length > 0) {
|
||||
const parsed = events[0];
|
||||
setParsedEvent(parsed);
|
||||
|
||||
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
|
||||
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback.
|
||||
try {
|
||||
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
|
||||
const rawText = await blob.text();
|
||||
const icsMethod = extractMethodFromRawIcs(rawText);
|
||||
if (icsMethod !== 'unknown') {
|
||||
setRawIcsMethod(icsMethod);
|
||||
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
|
||||
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback — in
|
||||
// parallel with parsing to save a roundtrip.
|
||||
const [events, rawText] = await Promise.all([
|
||||
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
|
||||
(async () => {
|
||||
try {
|
||||
const blob = await client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar');
|
||||
return await blob.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch { /* ignore - fall back to heuristic detection */ }
|
||||
})(),
|
||||
]);
|
||||
|
||||
if (parsed.uid && supportsCalendar) {
|
||||
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
|
||||
if (!storeHasIt) {
|
||||
try {
|
||||
const serverEvents = await client.queryCalendarEvents({});
|
||||
const matching = serverEvents.filter((e) => e.uid === parsed.uid);
|
||||
if (matching.length > 0) {
|
||||
useCalendarStore.setState((s) => {
|
||||
const existingIds = new Set(s.events.map((e) => e.id));
|
||||
const newEvents = matching.filter((e) => !existingIds.has(e.id));
|
||||
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore lookup failure */ }
|
||||
}
|
||||
}
|
||||
setState('parsed');
|
||||
} else {
|
||||
if (events.length === 0) {
|
||||
setState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = events[0];
|
||||
setParsedEvent(parsed);
|
||||
|
||||
if (rawText) {
|
||||
const icsMethod = extractMethodFromRawIcs(rawText);
|
||||
if (icsMethod !== 'unknown') {
|
||||
setRawIcsMethod(icsMethod);
|
||||
}
|
||||
}
|
||||
|
||||
setState('parsed');
|
||||
|
||||
// Hydrate the calendar store with the matching event in the background —
|
||||
// only needed for the "already in calendar" pill, must not block the banner.
|
||||
// Filter by UID server-side; the previous unfiltered query fetched up to
|
||||
// 1000 events plus multiple /get batches just to find one match.
|
||||
if (parsed.uid && supportsCalendar) {
|
||||
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
|
||||
if (!storeHasIt) {
|
||||
client.queryCalendarEvents({ uid: parsed.uid })
|
||||
.then((matching) => {
|
||||
if (matching.length === 0) return;
|
||||
useCalendarStore.setState((s) => {
|
||||
const existingIds = new Set(s.events.map((e) => e.id));
|
||||
const newEvents = matching.filter((e) => !existingIds.has(e.id));
|
||||
return newEvents.length > 0 ? { events: [...s.events, ...newEvents] } : s;
|
||||
});
|
||||
})
|
||||
.catch(() => { /* ignore lookup failure */ });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setState('error');
|
||||
|
||||
@@ -9,7 +9,7 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { sanitizeSignatureHtml } from "@/lib/email-sanitization";
|
||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -32,9 +32,10 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { findReplyIdentityId } from "@/lib/reply-identity";
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||
function htmlToPlainText(html: string): string {
|
||||
@@ -55,6 +56,10 @@ export interface ComposerDraftData {
|
||||
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
replyTo?: EmailComposerProps['replyTo'];
|
||||
draftId: string | null;
|
||||
/** When set, overrides the header From: - sent through the selected identity's envelope. */
|
||||
fromOverrideEmail?: string;
|
||||
fromOverrideName?: string;
|
||||
fromOverrideEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface EmailComposerProps {
|
||||
@@ -69,6 +74,7 @@ interface EmailComposerProps {
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
identityId?: string;
|
||||
envelopeMailFrom?: string;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
@@ -99,6 +105,14 @@ interface EmailComposerProps {
|
||||
messageId?: string;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
// Pre-built quote header block. Supplied by the composer opener after it
|
||||
// runs emailHooks.onBuildQuoteHeader through plugin transforms. When set,
|
||||
// the composer uses these verbatim instead of building its own default
|
||||
// "On X, Y wrote:" / "---------- Forwarded message ----------" block.
|
||||
quoteHeaderHtml?: string;
|
||||
quoteHeaderText?: string;
|
||||
/** Mirror of QuoteHeader.wrapInBlockquote. Defaults to true. */
|
||||
quoteWrapInBlockquote?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +127,39 @@ type ComposerAttachment = {
|
||||
abortController?: AbortController;
|
||||
};
|
||||
|
||||
type SignatureIdentityLike = {
|
||||
htmlSignature?: string;
|
||||
textSignature?: string;
|
||||
} | null | undefined;
|
||||
|
||||
// Render the embedded signature for "above quote" mode. Bracketed with
|
||||
// `data-signature-block` marker paragraphs so we can swap the inner content
|
||||
// when the user switches identity without losing the surrounding draft or
|
||||
// quoted message. The markers are preserved through TipTap by the
|
||||
// StyledParagraph extension.
|
||||
function buildEmbeddedSignatureHtml(
|
||||
identity: SignatureIdentityLike,
|
||||
options: { embed: boolean; separator: boolean }
|
||||
): string {
|
||||
if (!options.embed) return '';
|
||||
const startMarker = options.separator
|
||||
? `<p data-signature-block="separator">-- </p>`
|
||||
: `<p data-signature-block="start"></p>`;
|
||||
const endMarker = `<p data-signature-block="end"></p>`;
|
||||
if (identity?.htmlSignature) {
|
||||
return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`;
|
||||
}
|
||||
if (identity?.textSignature) {
|
||||
const escaped = identity.textSignature
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `${startMarker}<p>${escaped}</p>${endMarker}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onScheduledSendCreated,
|
||||
@@ -134,6 +181,25 @@ export function EmailComposer({
|
||||
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
|
||||
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const primaryIdentity = identities[0] ?? null;
|
||||
|
||||
// The signature identity used when embedding the signature into the initial
|
||||
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
||||
// below, but uses initialData (or primary) since selectedIdentityId state
|
||||
// does not exist yet at this point.
|
||||
const initialCurrentIdentityForSig = initialData?.selectedIdentityId
|
||||
? identities.find((i) => i.id === initialData.selectedIdentityId) || primaryIdentity
|
||||
: primaryIdentity;
|
||||
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
|
||||
? initialCurrentIdentityForSig
|
||||
: primaryIdentity;
|
||||
const shouldEmbedSignatureAboveQuote =
|
||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote' &&
|
||||
!!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
|
||||
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = () => {
|
||||
@@ -183,10 +249,25 @@ export function EmailComposer({
|
||||
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
|
||||
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
|
||||
|
||||
// When "above quote" is configured, splice signature between the user's
|
||||
// drafting area and the quoted content so it reads naturally as a
|
||||
// closing for the reply body. Send-time append is skipped - see
|
||||
// shouldEmbedSignatureAboveQuote.
|
||||
const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
|
||||
const signatureBlock = shouldEmbedSignatureAboveQuote
|
||||
? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}`
|
||||
: '';
|
||||
|
||||
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
||||
if (replyTo.quoteHeaderText !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const body = mode === 'forward' ? originalText : quotedText;
|
||||
return `${prefix}${signatureBlock}\n\n${replyTo.quoteHeaderText}\n${body}`;
|
||||
}
|
||||
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
||||
return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
@@ -198,20 +279,38 @@ export function EmailComposer({
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
|
||||
const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
|
||||
embed: shouldEmbedSignatureAboveQuote,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
|
||||
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
||||
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const wrap = replyTo.quoteWrapInBlockquote !== false;
|
||||
const originalHtml = replyTo.htmlBody
|
||||
?? (replyTo.body
|
||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||
: '');
|
||||
const bodyHtml = wrap
|
||||
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${originalHtml}</blockquote>`
|
||||
: originalHtml;
|
||||
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
|
||||
}
|
||||
|
||||
// Build quoted content as HTML
|
||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
||||
return `${prefix}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
|
||||
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
|
||||
}
|
||||
|
||||
if (replyTo.body) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
return `${prefix}${signatureBlock}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
return `${prefix}${signatureBlock}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
@@ -225,9 +324,17 @@ export function EmailComposer({
|
||||
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
|
||||
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
|
||||
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
|
||||
// value and try to destroy a draft that was just replaced.
|
||||
const draftIdRef = useRef<string | null>(initialData?.draftId ?? null);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSavedDataRef = useRef<string>("");
|
||||
// Tracks the currently-running saveDraft so concurrent callers (autosave
|
||||
// timer + send button) serialize instead of issuing parallel destroy/create
|
||||
// requests with the same draftId. See bug #303.
|
||||
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
|
||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||
return replyTo.attachments
|
||||
@@ -249,6 +356,9 @@ export function EmailComposer({
|
||||
const [shakeField, setShakeField] = useState<string | null>(null);
|
||||
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
|
||||
const [subAddressTag, setSubAddressTag] = useState<string>(initialData?.subAddressTag ?? '');
|
||||
const [fromOverrideEnabled, setFromOverrideEnabled] = useState<boolean>(initialData?.fromOverrideEnabled ?? false);
|
||||
const [fromOverrideEmail, setFromOverrideEmail] = useState<string>(initialData?.fromOverrideEmail ?? '');
|
||||
const [fromOverrideName, setFromOverrideName] = useState<string>(initialData?.fromOverrideName ?? '');
|
||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
@@ -283,24 +393,96 @@ export function EmailComposer({
|
||||
});
|
||||
|
||||
const { client } = useAuthStore();
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const primaryIdentity = identities[0] ?? null;
|
||||
const currentIdentity = selectedIdentityId
|
||||
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
||||
: primaryIdentity;
|
||||
// Alias identities often lack a configured signature - fall back to the primary
|
||||
// identity's signature so replies (which auto-select a matching alias) still
|
||||
// populate the user's signature.
|
||||
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
|
||||
? currentIdentity
|
||||
: primaryIdentity;
|
||||
|
||||
// Hold the TipTap editor instance so we can swap the embedded signature
|
||||
// when the user switches identity in "above quote" mode without rebuilding
|
||||
// the whole body (which would lose user edits to the surrounding draft).
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
|
||||
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id;
|
||||
const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled;
|
||||
prevSignatureIdentityIdRef.current = signatureIdentity?.id;
|
||||
prevSignatureSeparatorRef.current = signatureSeparatorEnabled;
|
||||
if (!editor) return;
|
||||
if (!identityChanged && !separatorChanged) return;
|
||||
if (plainTextMode) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
|
||||
if (signaturePosition !== 'above_quote') return;
|
||||
|
||||
const currentHtml = editor.getHTML();
|
||||
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
|
||||
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
|
||||
if (!startEl) return;
|
||||
const endEl = doc.querySelector('[data-signature-block="end"]');
|
||||
|
||||
const newSignature = buildEmbeddedSignatureHtml(signatureIdentity, {
|
||||
embed: true,
|
||||
separator: signatureSeparatorEnabled,
|
||||
});
|
||||
if (!newSignature) return;
|
||||
|
||||
// Build a temporary container holding the replacement nodes so we can
|
||||
// splice them in without re-serializing/parsing twice.
|
||||
const replacementHost = doc.createElement('div');
|
||||
replacementHost.innerHTML = newSignature;
|
||||
const replacementNodes = Array.from(replacementHost.childNodes);
|
||||
|
||||
const parent = startEl.parentNode;
|
||||
if (!parent) return;
|
||||
|
||||
// Remove the existing signature range [startEl … endEl] inclusive, or
|
||||
// from startEl to the next blockquote if no end marker is present.
|
||||
const removeUntil = endEl && endEl.parentNode === parent ? endEl : null;
|
||||
const toRemove: Node[] = [];
|
||||
let cursor: Node | null = startEl;
|
||||
while (cursor) {
|
||||
toRemove.push(cursor);
|
||||
if (cursor === removeUntil) break;
|
||||
const next: Node | null = cursor.nextSibling;
|
||||
if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break;
|
||||
cursor = next;
|
||||
}
|
||||
const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null;
|
||||
toRemove.forEach((node) => parent.removeChild(node));
|
||||
replacementNodes.forEach((node) => parent.insertBefore(node, insertBefore));
|
||||
|
||||
const nextHtml = doc.body.innerHTML;
|
||||
if (nextHtml !== currentHtml) {
|
||||
editor.commands.setContent(nextHtml, { emitUpdate: true });
|
||||
}
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSelectReplyIdentity) return;
|
||||
if (selectedIdentityId || initialData?.selectedIdentityId) return;
|
||||
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||
|
||||
const matchedIdentityId = findReplyIdentityId(identities, {
|
||||
const resolved = resolveReplyFrom(identities, {
|
||||
to: replyTo?.to,
|
||||
cc: replyTo?.cc,
|
||||
bcc: replyTo?.bcc,
|
||||
});
|
||||
|
||||
if (matchedIdentityId) {
|
||||
setSelectedIdentityId(matchedIdentityId);
|
||||
if (resolved) {
|
||||
setSelectedIdentityId(resolved.identityId);
|
||||
if (resolved.overrideEmail && !fromOverrideEnabled) {
|
||||
setFromOverrideEnabled(true);
|
||||
setFromOverrideEmail(resolved.overrideEmail);
|
||||
if (resolved.overrideName) setFromOverrideName(resolved.overrideName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -319,6 +501,7 @@ export function EmailComposer({
|
||||
}
|
||||
}, [
|
||||
autoSelectReplyIdentity,
|
||||
fromOverrideEnabled,
|
||||
identities,
|
||||
initialData?.selectedIdentityId,
|
||||
mode,
|
||||
@@ -329,10 +512,10 @@ export function EmailComposer({
|
||||
selectedIdentityId,
|
||||
]);
|
||||
|
||||
const composerSignatureHtml = currentIdentity?.htmlSignature
|
||||
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
|
||||
: currentIdentity?.textSignature
|
||||
? `<div>${getPlainTextSignature(currentIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
||||
: signatureIdentity?.textSignature
|
||||
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
: '';
|
||||
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
|
||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||
@@ -368,8 +551,8 @@ export function EmailComposer({
|
||||
}, [currentSmimeIdentityId]);
|
||||
|
||||
// Keep a ref to current state for the unmount save
|
||||
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId });
|
||||
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
|
||||
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName });
|
||||
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName };
|
||||
|
||||
// Track initial values for dirty detection (captured once on first render)
|
||||
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
|
||||
@@ -729,7 +912,7 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
// Auto-save draft functionality
|
||||
const saveDraft = async (): Promise<string | null> => {
|
||||
const saveDraftOnce = async (): Promise<string | null> => {
|
||||
if (!client) return null;
|
||||
|
||||
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
||||
@@ -755,20 +938,27 @@ export function EmailComposer({
|
||||
|
||||
// Only save if data has changed
|
||||
if (currentData === lastSavedDataRef.current) {
|
||||
return draftId;
|
||||
return draftIdRef.current;
|
||||
}
|
||||
|
||||
setSaveStatus('saving');
|
||||
|
||||
// Get the selected identity or primary identity
|
||||
// Generate sub-addressed email if tag is set
|
||||
const fromEmail = currentIdentity?.email
|
||||
const identityFromEmail = currentIdentity?.email
|
||||
? subAddressTag
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||
: currentIdentity.email
|
||||
: undefined;
|
||||
const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim())
|
||||
? fromOverrideEmail.trim()
|
||||
: identityFromEmail;
|
||||
const fromName = (fromOverrideEnabled && fromOverrideEmail.trim())
|
||||
? (fromOverrideName.trim() || undefined)
|
||||
: (currentIdentity?.name || undefined);
|
||||
|
||||
try {
|
||||
const previousDraftId = draftIdRef.current;
|
||||
const savedDraftId = await client.createDraft(
|
||||
toAddresses,
|
||||
subject || t('no_subject'),
|
||||
@@ -777,12 +967,15 @@ export function EmailComposer({
|
||||
bccAddresses,
|
||||
currentIdentity?.id,
|
||||
fromEmail,
|
||||
draftId || undefined,
|
||||
previousDraftId || undefined,
|
||||
uploadedAttachments,
|
||||
currentIdentity?.name || undefined,
|
||||
fromName,
|
||||
plainTextMode ? undefined : body
|
||||
);
|
||||
|
||||
// Update the ref synchronously so a queued save sees the new id and
|
||||
// doesn't try to destroy the just-replaced draft.
|
||||
draftIdRef.current = savedDraftId;
|
||||
setDraftId(savedDraftId);
|
||||
lastSavedDataRef.current = currentData;
|
||||
setSaveStatus('saved');
|
||||
@@ -799,6 +992,28 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
|
||||
// Serialize saves: each call waits for the previous in-flight save before
|
||||
// running. This prevents the autosave timer and the send button from
|
||||
// racing two `Email/set { destroy, create }` requests against the same
|
||||
// draftId, which left orphan drafts and (when EmailSubmission failed)
|
||||
// looked like "send didn't happen" (#303).
|
||||
const saveDraft = (): Promise<string | null> => {
|
||||
const previous = inflightSaveRef.current;
|
||||
const promise = (async (): Promise<string | null> => {
|
||||
if (previous) {
|
||||
try { await previous; } catch { /* prior failure already reported */ }
|
||||
}
|
||||
return saveDraftOnce();
|
||||
})();
|
||||
inflightSaveRef.current = promise;
|
||||
promise.finally(() => {
|
||||
if (inflightSaveRef.current === promise) {
|
||||
inflightSaveRef.current = null;
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
// Keep saveDraftRef pointing to latest saveDraft
|
||||
saveDraftRef.current = saveDraft;
|
||||
|
||||
@@ -816,6 +1031,10 @@ export function EmailComposer({
|
||||
|
||||
// Set new timeout for auto-save (2 seconds after last change)
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
// Clear the ref so handleSend can distinguish "save scheduled" from
|
||||
// "save in flight" - the former still needs flushing, the latter is
|
||||
// tracked via inflightSaveRef.
|
||||
saveTimeoutRef.current = null;
|
||||
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
|
||||
emailHooks.onDraftChange.emit({
|
||||
to: to.split(',').map(s => s.trim()).filter(Boolean),
|
||||
@@ -966,33 +1185,66 @@ export function EmailComposer({
|
||||
}
|
||||
}
|
||||
|
||||
let finalDraftId = draftId;
|
||||
// Resolve the freshest draftId we can. Two cases:
|
||||
// 1. An autosave is currently in flight - wait for it; don't issue a
|
||||
// parallel destroy/create that would race with it on the same id.
|
||||
// 2. A debounced save is scheduled (timer set) - cancel it and flush
|
||||
// now so the latest body content lands on the server.
|
||||
// Use draftIdRef (not the React state) because state updates from
|
||||
// the in-flight save may not have rendered yet when we read here.
|
||||
let finalDraftId = draftIdRef.current;
|
||||
if (inflightSaveRef.current) {
|
||||
try {
|
||||
const savedId = await inflightSaveRef.current;
|
||||
if (savedId) finalDraftId = savedId;
|
||||
} catch (err) {
|
||||
debug.error('In-flight draft save failed before send:', err);
|
||||
}
|
||||
}
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
try {
|
||||
const savedId = await saveDraft();
|
||||
if (savedId) {
|
||||
finalDraftId = savedId;
|
||||
}
|
||||
if (savedId) finalDraftId = savedId;
|
||||
} catch (err) {
|
||||
debug.error('Failed to save draft before send:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const fromEmail = currentIdentity?.email
|
||||
const identityFromEmail = currentIdentity?.email
|
||||
? subAddressTag
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||
: currentIdentity.email
|
||||
: undefined;
|
||||
// When the user has typed a From override, that becomes the header From
|
||||
// (and MIME-builder From in the S/MIME path). The identity still drives
|
||||
// the SMTP envelope MAIL FROM - set explicitly so it doesn't mistakenly
|
||||
// default to the override address.
|
||||
const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0;
|
||||
const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail;
|
||||
const fromName = overrideActive
|
||||
? (fromOverrideName.trim() || undefined)
|
||||
: (currentIdentity?.name || undefined);
|
||||
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
|
||||
|
||||
// Body is already HTML from the rich text editor (or plain text in plain text mode).
|
||||
// When "above quote" mode is configured for replies/forwards, the signature
|
||||
// was embedded into the body during init (see getInitialBody) so the
|
||||
// trailing append must be skipped to avoid duplicating it.
|
||||
const signatureAlreadyInBody =
|
||||
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
|
||||
signaturePosition === 'above_quote';
|
||||
|
||||
// Build HTML signature block (used only in rich text mode)
|
||||
const buildSignatureHtml = (): string => {
|
||||
if (currentIdentity?.htmlSignature) {
|
||||
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
|
||||
if (signatureAlreadyInBody) return '';
|
||||
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
|
||||
if (signatureIdentity?.htmlSignature) {
|
||||
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
|
||||
}
|
||||
if (currentIdentity?.textSignature) {
|
||||
return `<br><br>-- <br>${currentIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
if (signatureIdentity?.textSignature) {
|
||||
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -1003,9 +1255,10 @@ export function EmailComposer({
|
||||
: null;
|
||||
|
||||
// In plain text mode, send text/plain only (no HTML body)
|
||||
const signatureOpts = { separator: signatureSeparatorEnabled };
|
||||
const finalBody = plainTextMode
|
||||
? appendPlainTextSignature(body, currentIdentity)
|
||||
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
|
||||
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts));
|
||||
|
||||
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||
const finalHtmlBody = plainTextMode
|
||||
@@ -1041,6 +1294,12 @@ export function EmailComposer({
|
||||
if (smimeSign_ && !smimeKeyRecord) {
|
||||
throw new Error('No S/MIME key bound to this identity');
|
||||
}
|
||||
// S/MIME binds to the identity's key; sending from an override address
|
||||
// would produce a signature whose Subject differs from the visible
|
||||
// From, which most clients reject or flag. Refuse up front.
|
||||
if (overrideActive) {
|
||||
throw new Error('Cannot use From override with S/MIME - disable one to send.');
|
||||
}
|
||||
|
||||
// 2. Ensure key is unlocked for signing
|
||||
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
|
||||
@@ -1194,8 +1453,9 @@ export function EmailComposer({
|
||||
htmlBody: outgoing.htmlBody || undefined,
|
||||
draftId: finalDraftId || undefined,
|
||||
fromEmail,
|
||||
fromName: currentIdentity?.name || undefined,
|
||||
fromName,
|
||||
identityId: outgoing.identityId || currentIdentity?.id,
|
||||
envelopeMailFrom,
|
||||
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
|
||||
inReplyTo: threadingHeaders?.inReplyTo,
|
||||
references: threadingHeaders?.references,
|
||||
@@ -1220,6 +1480,7 @@ export function EmailComposer({
|
||||
setBcc("");
|
||||
setSubject("");
|
||||
setBody("");
|
||||
draftIdRef.current = null;
|
||||
setDraftId(null);
|
||||
setSubAddressTag("");
|
||||
setValidationErrors({});
|
||||
@@ -1227,7 +1488,7 @@ export function EmailComposer({
|
||||
setScheduleValue('');
|
||||
setScheduleError('');
|
||||
// Clear ref so unmount effect doesn't re-save
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||
} catch (err) {
|
||||
debug.error('Failed to send email:', err);
|
||||
toast.error(err instanceof Error ? err.message : t('send_failed'));
|
||||
@@ -1251,7 +1512,7 @@ export function EmailComposer({
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
@@ -1261,7 +1522,7 @@ export function EmailComposer({
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
await saveDraft();
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
@@ -1273,7 +1534,7 @@ export function EmailComposer({
|
||||
if (draftId && onDiscardDraft) {
|
||||
onDiscardDraft(draftId);
|
||||
}
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
@@ -1392,7 +1653,25 @@ export function EmailComposer({
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
|
||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('from')}:</span>
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
{identities.length > 1 ? (
|
||||
{fromOverrideEnabled ? (
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
<Input
|
||||
value={fromOverrideName}
|
||||
onChange={(e) => setFromOverrideName(e.target.value)}
|
||||
placeholder={t('from_override.name_placeholder')}
|
||||
className="h-7 text-sm w-32 md:w-40 shrink-0"
|
||||
aria-label={t('from_override.name_label')}
|
||||
/>
|
||||
<Input
|
||||
value={fromOverrideEmail}
|
||||
onChange={(e) => setFromOverrideEmail(e.target.value)}
|
||||
placeholder={t('from_override.email_placeholder')}
|
||||
type="email"
|
||||
className="h-7 text-sm flex-1 min-w-0 font-mono"
|
||||
aria-label={t('from_override.email_label')}
|
||||
/>
|
||||
</div>
|
||||
) : identities.length > 1 ? (
|
||||
<select
|
||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||
@@ -1424,16 +1703,18 @@ export function EmailComposer({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<SubAddressHelper
|
||||
baseEmail={
|
||||
(selectedIdentityId
|
||||
? identities.find(id => id.id === selectedIdentityId)?.email
|
||||
: primaryIdentity?.email) || ''
|
||||
}
|
||||
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
|
||||
onSelectTag={setSubAddressTag}
|
||||
/>
|
||||
{subAddressTag && (
|
||||
{!fromOverrideEnabled && (
|
||||
<SubAddressHelper
|
||||
baseEmail={
|
||||
(selectedIdentityId
|
||||
? identities.find(id => id.id === selectedIdentityId)?.email
|
||||
: primaryIdentity?.email) || ''
|
||||
}
|
||||
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
|
||||
onSelectTag={setSubAddressTag}
|
||||
/>
|
||||
)}
|
||||
{!fromOverrideEnabled && subAddressTag && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -1445,6 +1726,28 @@ export function EmailComposer({
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant={fromOverrideEnabled ? 'outline' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (fromOverrideEnabled) {
|
||||
setFromOverrideEnabled(false);
|
||||
} else {
|
||||
setFromOverrideEnabled(true);
|
||||
if (!fromOverrideEmail && currentIdentity?.email) {
|
||||
setFromOverrideEmail(currentIdentity.email);
|
||||
}
|
||||
if (!fromOverrideName && currentIdentity?.name) {
|
||||
setFromOverrideName(currentIdentity.name);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-6 px-2 text-xs shrink-0"
|
||||
title={t('from_override.toggle_tooltip')}
|
||||
>
|
||||
{fromOverrideEnabled ? t('from_override.toggle_on') : t('from_override.toggle_off')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1591,20 +1894,24 @@ export function EmailComposer({
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
onEditorReady={(ed) => { editorRef.current = ed; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plainTextMode ? (
|
||||
getPlainTextSignature(currentIdentity) ? (
|
||||
{/* Hide the visual signature preview when the signature has already been
|
||||
embedded into the body above the quote (otherwise it would appear twice). */}
|
||||
{((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') ? null
|
||||
: plainTextMode ? (
|
||||
getPlainTextSignature(signatureIdentity) ? (
|
||||
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
|
||||
{'-- \n'}{getPlainTextSignature(currentIdentity)}
|
||||
{signatureSeparatorEnabled ? '-- \n' : ''}{getPlainTextSignature(signatureIdentity)}
|
||||
</div>
|
||||
) : null
|
||||
) : composerSignatureHtml ? (
|
||||
<div
|
||||
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
|
||||
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
|
||||
dangerouslySetInnerHTML={{ __html: `${signatureSeparatorEnabled ? '<div>-- </div>' : ''}${composerSignatureHtml}` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1662,7 +1969,7 @@ export function EmailComposer({
|
||||
)}
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background shrink-0">
|
||||
<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)]">
|
||||
{/* Left side actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback } from "react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -21,6 +21,7 @@ interface EmailListItemProps {
|
||||
email: Email;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
@@ -30,7 +31,7 @@ interface EmailListItemProps {
|
||||
onMarkAsSpam?: () => void;
|
||||
}
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
@@ -51,7 +52,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
|
||||
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||
const colorTagIds = getEmailColorTags(email.keywords);
|
||||
@@ -124,12 +126,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<div
|
||||
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
@@ -160,11 +168,11 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
@@ -295,7 +303,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{email.preview || "No preview available"}
|
||||
{trimmedPreview || t('no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -24,6 +24,7 @@ interface EmailListProps {
|
||||
emails: Email[];
|
||||
selectedEmailId?: string;
|
||||
onEmailSelect?: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
@@ -50,6 +51,7 @@ export function EmailList({
|
||||
emails,
|
||||
selectedEmailId,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
className,
|
||||
isLoading = false,
|
||||
onOpenConversation,
|
||||
@@ -121,7 +123,7 @@ export function EmailList({
|
||||
|
||||
const estimateSize = useCallback(() => {
|
||||
if (isFocusedMailLayout) {
|
||||
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density];
|
||||
return { 'extra-compact': 28, compact: 40, regular: 56, comfortable: 64 }[density];
|
||||
}
|
||||
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
||||
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
||||
@@ -494,6 +496,7 @@ export function EmailList({
|
||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||
|
||||
+711
-479
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,11 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
const phones = contact?.phones ? Object.values(contact.phones) : [];
|
||||
const orgs = contact?.organizations ? Object.values(contact.organizations) : [];
|
||||
|
||||
const handleOpen = () => {
|
||||
const handleToggle = () => {
|
||||
if (isOpen) {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const popoverWidth = 300;
|
||||
@@ -125,9 +129,9 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={handleOpen}
|
||||
onClick={handleToggle}
|
||||
className={cn(
|
||||
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors",
|
||||
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors min-w-0 break-words",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback, useState, useRef } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Paragraph from "@tiptap/extension-paragraph";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
@@ -44,6 +46,51 @@ export interface InlineImageUpload {
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
// Pasted email content (signatures, replies, quoted text) commonly carries
|
||||
// inline styles on block elements. StarterKit's default Paragraph/Heading
|
||||
// drop unknown attributes; extend them to round-trip `style` and `class` so
|
||||
// signature formatting survives the editor.
|
||||
const styledBlockAttributes = {
|
||||
style: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("style"),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs.style ? { style: attrs.style } : {},
|
||||
},
|
||||
class: {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("class"),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs.class ? { class: attrs.class } : {},
|
||||
},
|
||||
"data-signature-block": {
|
||||
default: null as string | null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-signature-block"),
|
||||
renderHTML: (attrs: Record<string, string | null>) =>
|
||||
attrs["data-signature-block"]
|
||||
? { "data-signature-block": attrs["data-signature-block"] }
|
||||
: {},
|
||||
},
|
||||
};
|
||||
|
||||
const StyledParagraph = Paragraph.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
...styledBlockAttributes,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const StyledHeading = Heading.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
...styledBlockAttributes,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
onChange: (html: string) => void;
|
||||
@@ -51,6 +98,7 @@ interface RichTextEditorProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
hasError?: boolean;
|
||||
onEditorReady?: (editor: Editor) => void;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
@@ -131,17 +179,23 @@ export function RichTextEditor({
|
||||
placeholder,
|
||||
className,
|
||||
hasError,
|
||||
onEditorReady,
|
||||
}: RichTextEditorProps) {
|
||||
const onImageUploadRef = React.useRef(onImageUpload);
|
||||
onImageUploadRef.current = onImageUpload;
|
||||
const onEditorReadyRef = React.useRef(onEditorReady);
|
||||
onEditorReadyRef.current = onEditorReady;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2] },
|
||||
heading: false,
|
||||
paragraph: false,
|
||||
link: false,
|
||||
underline: false,
|
||||
}),
|
||||
StyledParagraph,
|
||||
StyledHeading.configure({ levels: [1, 2] }),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
@@ -239,6 +293,12 @@ export function RichTextEditor({
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
// Expose the editor instance once it's ready so parents can target
|
||||
// specific nodes (e.g. swap the embedded signature on identity change).
|
||||
useEffect(() => {
|
||||
if (editor) onEditorReadyRef.current?.(editor);
|
||||
}, [editor]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const previousUrl = editor.getAttributes("link").href;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
@@ -331,7 +331,7 @@ function EmailCard({
|
||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
|
||||
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
|
||||
// Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody —
|
||||
// Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody -
|
||||
// in that case there is no real plain-text alternative, so always render the HTML.
|
||||
const textPartId = email.textBody?.[0]?.partId;
|
||||
const htmlPartId = email.htmlBody[0].partId;
|
||||
@@ -440,6 +440,50 @@ function EmailCard({
|
||||
return { html: "", isHtml: false };
|
||||
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
|
||||
|
||||
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
|
||||
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
|
||||
// the host page. CSP <meta> is defense-in-depth in case the sanitizer ever
|
||||
// emits a <script> tag through a parser quirk.
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const emailIframeSrcDoc = useMemo(() => {
|
||||
if (!emailContent.isHtml || !emailContent.html) return '';
|
||||
const csp = "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
|
||||
return `<!DOCTYPE html><html><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
||||
<style>
|
||||
html, body { overflow: hidden; }
|
||||
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
img { max-width: 100% !important; height: auto !important; }
|
||||
a { color: #1a73e8; }
|
||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||
td, th { word-break: break-word; padding: 0.5rem; }
|
||||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||
</style></head><body>${emailContent.html}</body></html>`;
|
||||
}, [emailContent.isHtml, emailContent.html]);
|
||||
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc?.body) return;
|
||||
const resize = () => {
|
||||
iframe.style.height = doc.documentElement.scrollHeight + 'px';
|
||||
};
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(doc.body);
|
||||
doc.querySelectorAll('a').forEach((a) => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
} catch {
|
||||
// contentDocument may be inaccessible under stricter sandboxes; ignore.
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border border-border overflow-hidden transition-all duration-200",
|
||||
@@ -483,7 +527,7 @@ function EmailCard({
|
||||
</div>
|
||||
{!isExpanded && density !== 'extra-compact' && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{email.preview || "No preview available"}
|
||||
{email.preview || t('email_viewer.no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -534,18 +578,31 @@ function EmailCard({
|
||||
|
||||
{/* Email Body */}
|
||||
<div style={{ padding: 'var(--density-card-p)' }}>
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm max-w-none",
|
||||
!emailAlwaysLightMode && "dark:prose-invert",
|
||||
"prose-p:my-2 prose-headings:my-3",
|
||||
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
|
||||
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
|
||||
"[&_img]:max-w-full [&_img]:h-auto"
|
||||
)}
|
||||
style={!emailContent.isHtml ? { whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' } : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
/>
|
||||
{emailContent.isHtml ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={emailIframeSrcDoc}
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
title="Email content"
|
||||
className="w-full border-0 block"
|
||||
scrolling="no"
|
||||
style={{ minHeight: '60px' }}
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm max-w-none",
|
||||
!emailAlwaysLightMode && "dark:prose-invert",
|
||||
"prose-p:my-2 prose-headings:my-3",
|
||||
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
|
||||
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
|
||||
"[&_img]:max-w-full [&_img]:h-auto"
|
||||
)}
|
||||
style={{ whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace', fontSize: '13px' }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(emailContent.html) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -17,6 +18,7 @@ interface ThreadEmailItemProps {
|
||||
selected?: boolean;
|
||||
isLast?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
}
|
||||
|
||||
@@ -25,8 +27,10 @@ export function ThreadEmailItem({
|
||||
selected,
|
||||
isLast = false,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
}: ThreadEmailItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
@@ -94,6 +98,12 @@ export function ThreadEmailItem({
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
@@ -177,7 +187,7 @@ export function ThreadEmailItem({
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/70"
|
||||
)}>
|
||||
{email.preview || "No preview"}
|
||||
{email.preview || t('no_preview_available')}
|
||||
</span>
|
||||
|
||||
{/* Date */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -25,6 +25,7 @@ interface ThreadListItemProps {
|
||||
expandedEmails?: Email[];
|
||||
onToggleExpand: () => void;
|
||||
onEmailSelect: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
@@ -39,6 +40,7 @@ interface SingleEmailItemProps {
|
||||
email: Email;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
@@ -51,7 +53,8 @@ interface SingleEmailItemProps {
|
||||
}
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
@@ -71,7 +74,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
|
||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
@@ -144,12 +148,18 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<div
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
@@ -178,11 +188,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
@@ -316,7 +326,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{email.preview || "No preview available"}
|
||||
{trimmedPreview || t('no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -349,6 +359,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
expandedEmails,
|
||||
onToggleExpand,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
onContextMenu,
|
||||
onOpenConversation,
|
||||
onToggleStar,
|
||||
@@ -359,6 +370,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onMarkAsSpam,
|
||||
}, ref) {
|
||||
const t = useTranslations('threads');
|
||||
const tEmailViewer = useTranslations('email_viewer');
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -366,7 +378,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
@@ -416,6 +429,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
email={latestEmail}
|
||||
selected={selectedEmailId === latestEmail.id}
|
||||
onClick={() => onEmailSelect(latestEmail)}
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
@@ -502,12 +516,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onEmailDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onEmailDoubleClick(latestEmail);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<div
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
|
||||
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox for thread selection - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
@@ -562,11 +582,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
{density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={avatarPerson?.name}
|
||||
email={avatarPerson?.email}
|
||||
size="md"
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
/>
|
||||
@@ -722,7 +742,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{latestEmail.preview || "No preview available"}
|
||||
{trimmedPreview || tEmailViewer('no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -758,6 +778,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
selected={email.id === selectedEmailId}
|
||||
isLast={index === emailsToShow.length - 1}
|
||||
onClick={() => onEmailSelect(email)}
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -182,7 +182,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center overflow-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex-1 flex items-center justify-center overflow-auto p-4">
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
@@ -194,13 +194,19 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
)}
|
||||
|
||||
{!loading && !error && (fileType === "text") && content !== null && (
|
||||
<pre className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words">
|
||||
<pre
|
||||
className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm font-mono whitespace-pre-wrap break-words"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "markdown" && content !== null && (
|
||||
<div className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm">
|
||||
<div
|
||||
className="bg-background rounded-lg p-6 max-w-4xl w-full max-h-full overflow-auto text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SimpleMarkdown content={content} />
|
||||
</div>
|
||||
)}
|
||||
@@ -211,6 +217,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
alt={name}
|
||||
className="max-w-full max-h-full object-contain rounded-lg bg-background"
|
||||
draggable={false}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -220,6 +227,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
sandbox=""
|
||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||
title={name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -229,6 +237,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
type="application/pdf"
|
||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||
aria-label={name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button onClick={() => void onDownload()}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
@@ -238,14 +247,19 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "audio" && objectUrl && (
|
||||
<div className="bg-background rounded-lg p-8 max-w-lg w-full">
|
||||
<div className="bg-background rounded-lg p-8 max-w-lg w-full" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="text-sm font-medium mb-4 text-center">{name}</p>
|
||||
<audio controls className="w-full" src={objectUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "video" && objectUrl && (
|
||||
<video controls className="max-w-4xl max-h-full rounded-lg" src={objectUrl} />
|
||||
<video
|
||||
controls
|
||||
className="max-w-4xl max-h-full rounded-lg"
|
||||
src={objectUrl}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,13 +8,38 @@ import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
|
||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
||||
|
||||
// JMAP Identity/set caps signature fields at 2047 UTF-8 bytes per RFC 8621 §6.1.
|
||||
const SIGNATURE_MAX_BYTES = 2047;
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
function utf8ByteLength(s: string): number {
|
||||
return utf8Encoder.encode(s).length;
|
||||
}
|
||||
|
||||
function truncateToUtf8Bytes(s: string, maxBytes: number): string {
|
||||
if (utf8ByteLength(s) <= maxBytes) return s;
|
||||
let lo = 0;
|
||||
let hi = s.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >>> 1;
|
||||
if (utf8ByteLength(s.slice(0, mid)) <= maxBytes) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
// Don't split a surrogate pair: if we landed right after a high surrogate, back off one code unit.
|
||||
if (lo > 0) {
|
||||
const prev = s.charCodeAt(lo - 1);
|
||||
if (prev >= 0xD800 && prev <= 0xDBFF) lo -= 1;
|
||||
}
|
||||
return s.slice(0, lo);
|
||||
}
|
||||
|
||||
interface IdentityFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo?: EmailAddress[];
|
||||
bcc?: EmailAddress[];
|
||||
textSignature?: string;
|
||||
htmlSignature?: string;
|
||||
replyTo?: EmailAddress[] | null;
|
||||
bcc?: EmailAddress[] | null;
|
||||
textSignature?: string | null;
|
||||
htmlSignature?: string | null;
|
||||
}
|
||||
|
||||
interface IdentityFormProps {
|
||||
@@ -96,14 +121,16 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Sanitize HTML signature before sending to server
|
||||
// JMAP needs explicit null to clear a field; undefined would be dropped
|
||||
// from the JSON payload and leave the server-side value untouched.
|
||||
const trimmedText = formData.textSignature?.trim() ?? '';
|
||||
const trimmedHtml = formData.htmlSignature?.trim() ?? '';
|
||||
const sanitizedData: IdentityFormData = {
|
||||
...formData,
|
||||
replyTo: parseEmailList(replyToInput),
|
||||
bcc: parseEmailList(bccInput),
|
||||
htmlSignature: formData.htmlSignature
|
||||
? sanitizeSignatureHtml(formData.htmlSignature)
|
||||
: undefined,
|
||||
textSignature: trimmedText ? formData.textSignature : null,
|
||||
htmlSignature: trimmedHtml ? sanitizeSignatureHtml(formData.htmlSignature!) : null,
|
||||
replyTo: parseEmailList(replyToInput) ?? null,
|
||||
bcc: parseEmailList(bccInput) ?? null,
|
||||
};
|
||||
|
||||
await onSave(sanitizedData);
|
||||
@@ -246,14 +273,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
</label>
|
||||
<textarea
|
||||
id="identity-text-sig"
|
||||
maxLength={2000}
|
||||
value={formData.textSignature}
|
||||
onChange={(e) => setFormData({ ...formData, textSignature: e.target.value })}
|
||||
value={formData.textSignature ?? ''}
|
||||
onChange={(e) => setFormData({ ...formData, textSignature: truncateToUtf8Bytes(e.target.value, SIGNATURE_MAX_BYTES) })}
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
aria-label={t('text_signature_label')}
|
||||
aria-describedby="identity-text-sig-counter"
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<SignatureByteCounter id="identity-text-sig-counter" value={formData.textSignature || ''} />
|
||||
</div>
|
||||
|
||||
{/* HTML Signature */}
|
||||
@@ -263,14 +291,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
</label>
|
||||
<textarea
|
||||
id="identity-html-sig"
|
||||
maxLength={5000}
|
||||
value={formData.htmlSignature}
|
||||
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
|
||||
value={formData.htmlSignature ?? ''}
|
||||
onChange={(e) => setFormData({ ...formData, htmlSignature: truncateToUtf8Bytes(e.target.value, SIGNATURE_MAX_BYTES) })}
|
||||
rows={5}
|
||||
disabled={isSubmitting}
|
||||
aria-label={t('html_signature_label')}
|
||||
aria-describedby="identity-html-sig-counter"
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<SignatureByteCounter id="identity-html-sig-counter" value={formData.htmlSignature || ''} />
|
||||
{formData.htmlSignature && (
|
||||
<div className="mt-2 p-2 border rounded bg-muted">
|
||||
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
|
||||
@@ -304,3 +333,26 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureByteCounter({ id, value }: { id: string; value: string }) {
|
||||
const t = useTranslations('identities.form');
|
||||
const bytes = utf8ByteLength(value);
|
||||
const atLimit = bytes >= SIGNATURE_MAX_BYTES;
|
||||
const nearLimit = !atLimit && bytes >= Math.floor(SIGNATURE_MAX_BYTES * 0.9);
|
||||
const tone = atLimit
|
||||
? 'text-destructive'
|
||||
: nearLimit
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-muted-foreground';
|
||||
return (
|
||||
<p
|
||||
id={id}
|
||||
className={`text-xs mt-1 tabular-nums ${tone}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })}
|
||||
{atLimit && <span className="ml-1">{t('signature_byte_limit_reached')}</span>}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,10 +32,10 @@ function emailMatchesUsername(email: string, username: string): boolean {
|
||||
interface IdentityFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
replyTo?: EmailAddress[];
|
||||
bcc?: EmailAddress[];
|
||||
textSignature?: string;
|
||||
htmlSignature?: string;
|
||||
replyTo?: EmailAddress[] | null;
|
||||
bcc?: EmailAddress[] | null;
|
||||
textSignature?: string | null;
|
||||
htmlSignature?: string | null;
|
||||
}
|
||||
|
||||
interface IdentityManagerModalProps {
|
||||
|
||||
@@ -6,9 +6,10 @@ import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-reac
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getInitials, getMaxAccounts } from "@/lib/account-utils";
|
||||
import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface AccountSwitcherProps {
|
||||
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
|
||||
@@ -17,17 +18,15 @@ interface AccountSwitcherProps {
|
||||
}
|
||||
|
||||
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) {
|
||||
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||
const sizeClasses = size === "sm" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-full flex items-center justify-center text-white font-medium flex-shrink-0", sizeClasses)}
|
||||
style={{ backgroundColor: account.avatarColor }}
|
||||
title={account.label}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
<Avatar
|
||||
name={account.displayName || account.label}
|
||||
email={account.email || account.username}
|
||||
size="sm"
|
||||
className={cn("flex-shrink-0", size === "md" && "w-9 h-9 text-sm")}
|
||||
disableFavicon
|
||||
fallbackColor={account.avatarColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +48,6 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||
const primaryIdentity = useAuthStore((s) => s.primaryIdentity);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!buttonRef.current) return;
|
||||
@@ -115,9 +113,11 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
setDefaultAccount(accountId);
|
||||
};
|
||||
|
||||
// Display name for the active account
|
||||
const displayName = primaryIdentity?.name || activeAccount?.displayName || activeAccount?.label || "";
|
||||
const displayEmail = primaryIdentity?.email || activeAccount?.email || activeAccount?.username || "";
|
||||
// Show the account's own identity, not the preferred sending identity -
|
||||
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
|
||||
// the actually logged-in account (info@linusrath.de).
|
||||
const displayName = activeAccount?.displayName || activeAccount?.label || "";
|
||||
const displayEmail = activeAccount?.email || activeAccount?.username || "";
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Pencil,
|
||||
FolderX,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Position {
|
||||
@@ -84,6 +85,7 @@ interface MailboxContextMenuProps {
|
||||
onCreateFolder?: () => void;
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
@@ -102,6 +104,7 @@ export function MailboxContextMenu({
|
||||
onCreateFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onRefresh,
|
||||
}: MailboxContextMenuProps) {
|
||||
const t = useTranslations("mailbox_context_menu");
|
||||
@@ -149,6 +152,7 @@ export function MailboxContextMenu({
|
||||
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
|
||||
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
|
||||
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
|
||||
const canAddItems = mailbox.myRights?.mayAddItems !== false;
|
||||
|
||||
const fullPath = getMailboxPath(mailbox, mailboxes);
|
||||
|
||||
@@ -190,6 +194,15 @@ export function MailboxContextMenu({
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
icon={Upload}
|
||||
label={t("import_email")}
|
||||
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
|
||||
disabled={!onImportEmail || !canAddItems}
|
||||
/>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
icon={FolderX}
|
||||
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Menu, ArrowLeft, Plus, Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -25,6 +26,12 @@ export function MobileHeader({
|
||||
}: MobileHeaderProps) {
|
||||
const t = useTranslations('sidebar');
|
||||
const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
|
||||
// Pane-aware: in Pro split mode the viewport is desktop-wide while the
|
||||
// pane is narrow. The Tailwind `lg:hidden` variant alone would never fire
|
||||
// there, so we additionally hide via JS when the surrounding pane is
|
||||
// desktop-sized. Outside of Pro this still returns the viewport value.
|
||||
const isPaneDesktop = useIsDesktop();
|
||||
if (isPaneDesktop) return null;
|
||||
|
||||
const handleLeftAction = () => {
|
||||
if (showBack && onBack) {
|
||||
@@ -40,7 +47,6 @@ export function MobileHeader({
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 h-14 border-b border-border bg-background shrink-0",
|
||||
"lg:hidden", // Only visible on mobile/tablet
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -118,12 +124,13 @@ export function MobileViewerHeader({
|
||||
className,
|
||||
}: MobileViewerHeaderProps) {
|
||||
const t = useTranslations('sidebar');
|
||||
const isPaneDesktop = useIsDesktop();
|
||||
if (isPaneDesktop) return null;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center justify-between px-2 h-14 border-b border-border bg-background shrink-0",
|
||||
"lg:hidden", // Only visible on mobile/tablet
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -17,11 +17,12 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { getInitials, getMaxAccounts } from "@/lib/account-utils";
|
||||
import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -44,6 +45,19 @@ interface NavigationRailProps {
|
||||
onInlineApp?: (appId: string, url: string, name: string) => void;
|
||||
onCloseInlineApp?: () => void;
|
||||
activeAppId?: string | null;
|
||||
/**
|
||||
* If provided, intercepts the rail's built-in route navigation. Return
|
||||
* `true` to prevent the underlying `<Link>` from navigating — used by the
|
||||
* Pro interface to open the route as a tab instead. The visual rail is
|
||||
* unchanged.
|
||||
*/
|
||||
onNavigate?: (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => boolean | void;
|
||||
/**
|
||||
* When `onNavigate` is in use, this controls which nav item the rail
|
||||
* highlights as active (since the URL alone no longer reflects the
|
||||
* active app).
|
||||
*/
|
||||
activeItemId?: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null;
|
||||
}
|
||||
|
||||
function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) {
|
||||
@@ -159,6 +173,8 @@ export function NavigationRail({
|
||||
onInlineApp,
|
||||
onCloseInlineApp,
|
||||
activeAppId,
|
||||
onNavigate,
|
||||
activeItemId,
|
||||
}: NavigationRailProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const pathname = usePathname();
|
||||
@@ -174,6 +190,7 @@ export function NavigationRail({
|
||||
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
|
||||
const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled'));
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
|
||||
const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : [];
|
||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||
const [isStalwartAdmin, setIsStalwartAdmin] = useState(false);
|
||||
@@ -253,37 +270,58 @@ export function NavigationRail({
|
||||
const navItems: NavItem[] = [
|
||||
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
|
||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts },
|
||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts || !contactsEnabled },
|
||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
|
||||
];
|
||||
|
||||
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
||||
// When the host (e.g. the Pro shell) takes over navigation via `onNavigate`,
|
||||
// it tells us which item is active; otherwise we infer it from the URL.
|
||||
const isSettingsActive = onNavigate
|
||||
? activeItemId === 'settings'
|
||||
: !activeAppId && pathname.startsWith("/settings");
|
||||
|
||||
const visibleItems = navItems.filter((item) => !item.hidden);
|
||||
|
||||
const getIsActive = (href: string) => {
|
||||
const getIsActive = (href: string, itemId: string) => {
|
||||
if (activeAppId) return false;
|
||||
if (onNavigate) {
|
||||
return activeItemId === itemId;
|
||||
}
|
||||
if (href === "/") {
|
||||
return pathname === "/" || pathname === "";
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
const handleNavClick = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') =>
|
||||
(e: React.MouseEvent) => {
|
||||
if (onNavigate) {
|
||||
const intercepted = onNavigate(itemId);
|
||||
if (intercepted !== false) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (activeAppId) {
|
||||
onCloseInlineApp?.();
|
||||
}
|
||||
};
|
||||
|
||||
if (orientation === "horizontal") {
|
||||
return (
|
||||
<nav
|
||||
className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden", className)}
|
||||
className={cn("flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden pb-[calc(env(safe-area-inset-bottom)/2)]", className)}
|
||||
role="navigation"
|
||||
aria-label={t("nav_label")}
|
||||
>
|
||||
{visibleItems.map((item) => {
|
||||
const isActive = getIsActive(item.href);
|
||||
const isActive = getIsActive(item.href, item.id);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"transition-colors duration-150",
|
||||
@@ -373,7 +411,7 @@ export function NavigationRail({
|
||||
{/* Settings */}
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
onClick={handleNavClick('settings')}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"transition-colors duration-150",
|
||||
@@ -427,13 +465,13 @@ export function NavigationRail({
|
||||
aria-label={t("nav_label")}
|
||||
>
|
||||
{visibleItems.map((item) => {
|
||||
const isActive = getIsActive(item.href);
|
||||
const isActive = getIsActive(item.href, item.id);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
|
||||
data-tour={`nav-${item.id}`}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||
@@ -553,7 +591,7 @@ export function NavigationRail({
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
onClick={handleNavClick('settings')}
|
||||
data-tour="nav-settings"
|
||||
className={cn(
|
||||
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
|
||||
@@ -610,7 +648,6 @@ export function NavigationRail({
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{accounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||
return (
|
||||
<button
|
||||
key={account.id}
|
||||
@@ -618,15 +655,20 @@ export function NavigationRail({
|
||||
if (!isActive) switchAccount(account.id);
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0",
|
||||
"relative w-8 h-8 rounded-full transition-all flex-shrink-0",
|
||||
isActive
|
||||
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||
: "opacity-70 hover:opacity-100"
|
||||
)}
|
||||
style={{ backgroundColor: account.avatarColor }}
|
||||
title={`${account.displayName || account.label} (${account.email || account.username})`}
|
||||
>
|
||||
{initials}
|
||||
<Avatar
|
||||
name={account.displayName || account.label}
|
||||
email={account.email || account.username}
|
||||
size="sm"
|
||||
disableFavicon
|
||||
fallbackColor={account.avatarColor}
|
||||
/>
|
||||
{isActive && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="w-2 h-2 text-primary-foreground" />
|
||||
|
||||
@@ -8,38 +8,46 @@ interface ResizeHandleProps {
|
||||
onResize: (delta: number) => void;
|
||||
onResizeEnd?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
orientation?: "vertical" | "horizontal";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const KEYBOARD_STEP = 10;
|
||||
|
||||
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, className }: ResizeHandleProps) {
|
||||
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, orientation = "vertical", className }: ResizeHandleProps) {
|
||||
const isDragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startPos = useRef(0);
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
document.body.style.cursor = "col-resize";
|
||||
startPos.current = isHorizontal ? e.clientY : e.clientX;
|
||||
document.body.style.cursor = isHorizontal ? "row-resize" : "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
onResizeStart?.();
|
||||
}, [onResizeStart]);
|
||||
}, [onResizeStart, isHorizontal]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
let delta = 0;
|
||||
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
|
||||
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
|
||||
else return;
|
||||
if (isHorizontal) {
|
||||
if (e.key === "ArrowUp") delta = -KEYBOARD_STEP;
|
||||
else if (e.key === "ArrowDown") delta = KEYBOARD_STEP;
|
||||
else return;
|
||||
} else {
|
||||
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
|
||||
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
|
||||
else return;
|
||||
}
|
||||
e.preventDefault();
|
||||
onResize(delta);
|
||||
onResizeEnd?.();
|
||||
}, [onResize, onResizeEnd]);
|
||||
}, [onResize, onResizeEnd, isHorizontal]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
const delta = e.clientX - startX.current;
|
||||
const delta = (isHorizontal ? e.clientY : e.clientX) - startPos.current;
|
||||
onResize(delta);
|
||||
};
|
||||
|
||||
@@ -57,24 +65,25 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [onResize, onResizeEnd]);
|
||||
}, [onResize, onResizeEnd, isHorizontal]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-orientation={isHorizontal ? "horizontal" : "vertical"}
|
||||
aria-label="Resize"
|
||||
tabIndex={0}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
className={cn(
|
||||
"w-1 flex-shrink-0 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
|
||||
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
|
||||
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||
isHorizontal ? "h-1 cursor-row-resize bg-border" : "w-1 cursor-col-resize",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-1 -right-1" />
|
||||
<div className={cn("absolute", isHorizontal ? "inset-x-0 -top-1 -bottom-1" : "inset-y-0 -left-1 -right-1")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
User,
|
||||
Users,
|
||||
Palmtree,
|
||||
Settings,
|
||||
X,
|
||||
@@ -28,7 +29,10 @@ import {
|
||||
FlaskConical,
|
||||
PlayCircle,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
NotebookPen,
|
||||
CalendarClock,
|
||||
BellOff,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
@@ -67,6 +71,7 @@ interface SidebarProps {
|
||||
onCreateFolder?: () => void;
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefreshMailboxes?: () => void;
|
||||
scheduledTotal?: number;
|
||||
className?: string;
|
||||
@@ -89,6 +94,11 @@ const getIconForMailbox = (role?: string, name?: string, hasChildren?: boolean,
|
||||
if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return Trash2;
|
||||
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return Ban;
|
||||
if (role === "archive" || lowerName.includes("archive")) return Archive;
|
||||
if (role === "shared" || lowerName.includes("shared")) return Users;
|
||||
if (role === "important" || lowerName.includes("important")) return AlertTriangle;
|
||||
if (role === "memos" || lowerName.includes("memo")) return NotebookPen;
|
||||
if (role === "scheduled" || lowerName.includes("scheduled")) return CalendarClock;
|
||||
if (role === "snoozed" || lowerName.includes("snoozed")) return BellOff;
|
||||
if (lowerName.includes("star") || lowerName.includes("flag")) return Star;
|
||||
|
||||
if (hasChildren) {
|
||||
@@ -105,6 +115,11 @@ const ROLE_ICON_COLOR: Record<string, string> = {
|
||||
trash: "text-muted-foreground",
|
||||
junk: "text-red-600/80 dark:text-red-400/80",
|
||||
archive: "text-amber-600/80 dark:text-amber-400/80",
|
||||
shared: "text-cyan-600/80 dark:text-cyan-400/80",
|
||||
important: "text-orange-600/80 dark:text-orange-400/80",
|
||||
memos: "text-yellow-600/80 dark:text-yellow-400/80",
|
||||
scheduled: "text-sky-600/80 dark:text-sky-400/80",
|
||||
snoozed: "text-slate-500/80 dark:text-slate-400/80",
|
||||
};
|
||||
|
||||
function resolveRoleKey(role?: string, name?: string): string | undefined {
|
||||
@@ -115,6 +130,11 @@ function resolveRoleKey(role?: string, name?: string): string | undefined {
|
||||
if (role === "trash" || lowerName.includes("trash") || lowerName.includes("deleted")) return "trash";
|
||||
if (role === "junk" || role === "spam" || lowerName.includes("junk") || lowerName.includes("spam")) return "junk";
|
||||
if (role === "archive" || lowerName.includes("archive")) return "archive";
|
||||
if (role === "shared" || lowerName.includes("shared")) return "shared";
|
||||
if (role === "important" || lowerName.includes("important")) return "important";
|
||||
if (role === "memos" || lowerName.includes("memo")) return "memos";
|
||||
if (role === "scheduled" || lowerName.includes("scheduled")) return "scheduled";
|
||||
if (role === "snoozed" || lowerName.includes("snoozed")) return "snoozed";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -638,6 +658,7 @@ export function Sidebar({
|
||||
onCreateFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onRefreshMailboxes,
|
||||
scheduledTotal = 0,
|
||||
className,
|
||||
@@ -1051,6 +1072,7 @@ export function Sidebar({
|
||||
onCreateFolder={onCreateFolder}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onImportEmail={onImportEmail}
|
||||
onRefresh={onRefreshMailboxes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { PluginSlot } from '@/components/plugins/plugin-slot';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
/**
|
||||
* Mounts the `app-top-banner` plugin slot with the current session
|
||||
* username + serverUrl as extraProps. Drop this at the top of every
|
||||
* authenticated page so plugins like impersonation-notice render
|
||||
* everywhere, not just on the mail page.
|
||||
*/
|
||||
export function AppTopBannerSlot() {
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const serverUrl = useAuthStore((s) => s.serverUrl);
|
||||
return <PluginSlot name="app-top-banner" extraProps={{ username, serverUrl }} />;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
// Modal shown the first time a plugin is enabled, listing every permission
|
||||
// the plugin's manifest declares. Accepting persists the grant on the
|
||||
// plugin record so future enables skip the prompt.
|
||||
|
||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||
import { head, resolveHead, subscribe, describePermission } from '@/lib/plugin-sandbox/consent';
|
||||
|
||||
export function PluginConsentDialog(): React.JSX.Element | null {
|
||||
const current = useSyncExternalStore(subscribe, head, () => null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
resolveHead(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => document.removeEventListener('keydown', onKey, true);
|
||||
}, [current]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="plugin-consent-title"
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 100001,
|
||||
}}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) resolveHead(false); }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--background, #fff)',
|
||||
color: 'var(--foreground, #0f172a)',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
maxWidth: 560,
|
||||
width: '92%',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
|
||||
}}>
|
||||
<h2 id="plugin-consent-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 6px 0' }}>
|
||||
Allow “{current.pluginName}” to access your data?
|
||||
</h2>
|
||||
<p style={{ fontSize: 12, color: 'var(--muted-foreground, #64748b)', margin: '0 0 14px 0' }}>
|
||||
This plugin is requesting the permissions below. You can revoke them by uninstalling the plugin.
|
||||
</p>
|
||||
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 16px 0', maxHeight: 320, overflowY: 'auto' }}>
|
||||
{current.permissions.map((perm) => {
|
||||
const desc = describePermission(perm);
|
||||
return (
|
||||
<li
|
||||
key={perm}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
marginBottom: 6,
|
||||
borderRadius: 8,
|
||||
background: 'var(--accent, #f1f5f9)',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}>{desc.title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground, #64748b)' }}>{desc.body}</div>
|
||||
<code style={{ fontSize: 10, color: 'var(--muted-foreground, #94a3b8)', display: 'block', marginTop: 4 }}>{perm}</code>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resolveHead(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
onClick={() => resolveHead(true)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
background: '#3b82f6',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
Plugin: {current.pluginId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
// Host-rendered modal for plugin-requested confirm/alert dialogs.
|
||||
// Subscribes to the host-dialog queue and renders the head request, one at
|
||||
// a time. Closing the modal advances the queue.
|
||||
|
||||
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
|
||||
|
||||
export function PluginDialogHost(): React.JSX.Element | null {
|
||||
const current = useSyncExternalStore(subscribe, head, () => null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
resolveHead(false);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
resolveHead(true);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => document.removeEventListener('keydown', onKey, true);
|
||||
}, [current]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : 'Confirm');
|
||||
const cancelLabel = current.cancelLabel ?? 'Cancel';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="plugin-dialog-title"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 100000,
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) resolveHead(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--background, #fff)',
|
||||
color: 'var(--foreground, #0f172a)',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
maxWidth: 480,
|
||||
width: '92%',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{current.kind === 'confirm' && (
|
||||
<button
|
||||
type="button"
|
||||
autoFocus={!!current.danger}
|
||||
onClick={() => resolveHead(false)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
autoFocus={current.kind === 'alert' || !current.danger}
|
||||
onClick={() => resolveHead(true)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
background: current.danger ? '#dc2626' : '#3b82f6',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||
From plugin: {current.pluginId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
// Sandboxed slot mount. One iframe per (plugin, slot) — created lazily after
|
||||
// the background instance confirms `shouldShow(context)` (if defined). The
|
||||
// iframe renders the plugin's slot component using the plugin's bundle in a
|
||||
// null-origin context; its height is pushed back via postMessage and applied
|
||||
// to a wrapper <div>.
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { SlotName } from '@/lib/plugin-types';
|
||||
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
|
||||
|
||||
interface Props {
|
||||
pluginId: string;
|
||||
slot: SlotName;
|
||||
extraProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const instanceRef = useRef<SandboxInstance | null>(null);
|
||||
const [height, setHeight] = useState<number>(0);
|
||||
// null = pending, true/false = decided
|
||||
const [show, setShow] = useState<boolean | null>(null);
|
||||
|
||||
// Decide whether to mount based on the plugin's shouldShow (background-side).
|
||||
useEffect(() => {
|
||||
const active = getActivePlugin(pluginId);
|
||||
if (!active) { setShow(false); return; }
|
||||
const offer = active.slotOffers.find((o) => o.name === slot);
|
||||
if (!offer) { setShow(false); return; }
|
||||
if (!offer.hasShouldShow) { setShow(true); return; }
|
||||
let cancelled = false;
|
||||
active.background
|
||||
.evaluateShouldShow(slot, extraProps ?? {})
|
||||
.then((s) => { if (!cancelled) setShow(s); })
|
||||
.catch(() => { if (!cancelled) setShow(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [pluginId, slot, extraProps]);
|
||||
|
||||
// Spawn / tear down the slot iframe.
|
||||
useEffect(() => {
|
||||
if (show !== true) return;
|
||||
const active = getActivePlugin(pluginId);
|
||||
if (!active || !wrapperRef.current) return;
|
||||
const locale = (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ ?? 'en';
|
||||
const inst = createSlotInstance({
|
||||
plugin: active.plugin,
|
||||
slot,
|
||||
code: active.code,
|
||||
locale,
|
||||
extraProps: extraProps ?? {},
|
||||
hostContainer: wrapperRef.current,
|
||||
onResize: (h) => setHeight(h),
|
||||
});
|
||||
instanceRef.current = inst;
|
||||
return () => {
|
||||
try { inst.destroy(); } catch { /* ignore */ }
|
||||
instanceRef.current = null;
|
||||
};
|
||||
// We intentionally don't depend on extraProps here — propagating prop
|
||||
// changes happens via postMessage below to avoid iframe churn.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [show, pluginId, slot]);
|
||||
|
||||
// Push prop updates without remount.
|
||||
useEffect(() => {
|
||||
instanceRef.current?.updateProps(extraProps ?? {});
|
||||
}, [extraProps]);
|
||||
|
||||
if (show !== true) return null;
|
||||
return <div ref={wrapperRef} style={{ height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} />;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useSyncExternalStore } from 'react';
|
||||
import type { SlotName } from '@/lib/plugin-types';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { PluginSlotRenderer } from './plugin-slot-renderer';
|
||||
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
|
||||
import { PluginIframeSlot } from './plugin-iframe-slot';
|
||||
|
||||
interface PluginSlotProps {
|
||||
name: SlotName;
|
||||
@@ -12,16 +12,18 @@ interface PluginSlotProps {
|
||||
}
|
||||
|
||||
export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
|
||||
const registrations = usePluginStore(s => s.slots[name]);
|
||||
const getSnapshot = () => offersForSlot(name);
|
||||
const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
if (!registrations || registrations.length === 0) return null;
|
||||
if (offers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={className} data-plugin-slot={name}>
|
||||
{registrations.map((reg, i) => (
|
||||
<PluginSlotRenderer
|
||||
key={`${reg.pluginId}-${i}`}
|
||||
registration={reg}
|
||||
{offers.map((offer) => (
|
||||
<PluginIframeSlot
|
||||
key={offer.pluginId}
|
||||
pluginId={offer.pluginId}
|
||||
slot={name}
|
||||
extraProps={extraProps}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProComposeTabData } from "@/stores/pro-tab-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
|
||||
interface ProComposeTabBodyProps {
|
||||
tabId: string;
|
||||
data: ProComposeTabData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a standalone `<EmailComposer />` inside its own Pro tab. Sending,
|
||||
* draft autosave, and discard all flow through the shared `email-store`, so
|
||||
* the result is identical to composing inline in the mail page — the
|
||||
* composer is just hosted in its own tab instead of in the right pane.
|
||||
*/
|
||||
export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
const t = useTranslations();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const sendEmail = useEmailStore((s) => s.sendEmail);
|
||||
const fetchEmails = useEmailStore((s) => s.fetchEmails);
|
||||
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
|
||||
|
||||
// Keep stable references for the callbacks below so the composer's
|
||||
// `key={sessionId}` doesn't churn.
|
||||
const tabIdRef = useRef(tabId);
|
||||
tabIdRef.current = tabId;
|
||||
|
||||
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await sendEmail(
|
||||
client,
|
||||
sendData.to,
|
||||
sendData.subject,
|
||||
sendData.body,
|
||||
sendData.cc,
|
||||
sendData.bcc,
|
||||
sendData.identityId,
|
||||
sendData.fromEmail,
|
||||
sendData.draftId,
|
||||
sendData.fromName,
|
||||
sendData.htmlBody,
|
||||
sendData.attachments,
|
||||
sendData.inReplyTo,
|
||||
sendData.references,
|
||||
sendData.delayedUntil,
|
||||
sendData.envelopeMailFrom,
|
||||
);
|
||||
|
||||
// Mark the original message as $answered / $forwarded so the standard
|
||||
// viewer and list reflect the action (same behaviour as inline compose).
|
||||
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
|
||||
try {
|
||||
await client.setKeyword(data.sourceEmailId, '$answered');
|
||||
} catch (e) {
|
||||
debug.error('Failed to set $answered keyword:', e);
|
||||
}
|
||||
} else if (data.sourceEmailId && data.mode === 'forward') {
|
||||
try {
|
||||
await client.setKeyword(data.sourceEmailId, '$forwarded');
|
||||
} catch (e) {
|
||||
debug.error('Failed to set $forwarded keyword:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the currently-active mail list so the new sent message /
|
||||
// updated keyword status shows up.
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
closeTab(tabIdRef.current);
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error);
|
||||
toast.error(t('notifications.error_sending'));
|
||||
}
|
||||
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closeTab(tabIdRef.current);
|
||||
}, [closeTab]);
|
||||
|
||||
const handleDiscardDraft = useCallback(async (draftId: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.deleteEmail(draftId);
|
||||
} catch (error) {
|
||||
console.error('Failed to discard draft:', error);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const handleSaveState = useCallback((state: ComposerDraftData) => {
|
||||
updateComposeDraft(tabIdRef.current, state);
|
||||
// Keep the tab title in sync with the working subject.
|
||||
const subject = state.subject?.trim() || t('email_composer.new_message');
|
||||
updateTabTitle(tabIdRef.current, subject);
|
||||
}, [updateComposeDraft, updateTabTitle, t]);
|
||||
|
||||
// On first mount, ensure the tab title matches whatever subject we were
|
||||
// initialised with (replies start with "Re: …", forwards with "Fwd: …").
|
||||
useEffect(() => {
|
||||
const initialSubject = data.initialData?.subject?.trim()
|
||||
?? data.replyTo?.subject
|
||||
?? '';
|
||||
const title = initialSubject || t('email_composer.new_message');
|
||||
updateTabTitle(tabIdRef.current, title);
|
||||
// Run once on mount only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
<ErrorBoundary fallback={ComposerErrorFallback}>
|
||||
<EmailComposer
|
||||
key={data.sessionId}
|
||||
mode={data.initialData?.mode ?? data.mode}
|
||||
replyTo={data.replyTo}
|
||||
initialDraftText={data.initialDraftText}
|
||||
initialData={data.initialData}
|
||||
onSend={handleSend}
|
||||
onClose={handleClose}
|
||||
onDiscardDraft={handleDiscardDraft}
|
||||
onSaveState={handleSaveState}
|
||||
className="flex-1"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
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";
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
data: ProEmailTabData;
|
||||
}
|
||||
|
||||
function buildReplyContext(email: Email): ProReplyContext {
|
||||
const textPartId = email.textBody?.[0]?.partId ?? '';
|
||||
const htmlPartId = email.htmlBody?.[0]?.partId ?? '';
|
||||
return {
|
||||
from: email.from,
|
||||
replyToAddresses: email.replyTo,
|
||||
to: email.to,
|
||||
cc: email.cc,
|
||||
bcc: email.bcc,
|
||||
subject: email.subject,
|
||||
body: email.bodyValues?.[textPartId]?.value || email.preview || '',
|
||||
htmlBody: email.bodyValues?.[htmlPartId]?.value || undefined,
|
||||
receivedAt: email.receivedAt,
|
||||
accountId: email.accountId,
|
||||
attachments: email.attachments,
|
||||
messageId: email.messageId,
|
||||
inReplyTo: email.inReplyTo,
|
||||
references: email.references,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single email in its own Pro tab. Fetches the email content on
|
||||
* mount via `email-store.fetchEmailContent` so the tab is self-sufficient —
|
||||
* it doesn't depend on what the Mail tab has selected.
|
||||
*/
|
||||
export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
const t = useTranslations();
|
||||
const tNotifications = useTranslations('notifications');
|
||||
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const fetchEmailContent = useEmailStore((s) => s.fetchEmailContent);
|
||||
const deleteEmail = useEmailStore((s) => s.deleteEmail);
|
||||
const markAsRead = useEmailStore((s) => s.markAsRead);
|
||||
const toggleStar = useEmailStore((s) => s.toggleStar);
|
||||
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
||||
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
|
||||
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
||||
|
||||
const [email, setEmail] = useState<Email | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const composerSessionIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
fetchEmailContent(client, data.emailId)
|
||||
.then((loaded) => {
|
||||
if (cancelled) return;
|
||||
setEmail(loaded);
|
||||
if (loaded?.subject) {
|
||||
updateTabTitle(tabId, loaded.subject);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch email for Pro tab:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, data.emailId, fetchEmailContent, tabId, updateTabTitle]);
|
||||
|
||||
const currentMailboxRole = useMemo(() => {
|
||||
if (!email) return undefined;
|
||||
const mb = email.mailboxIds
|
||||
? Object.keys(email.mailboxIds).map((id) => mailboxes.find((m) => m.id === id)).find(Boolean)
|
||||
: undefined;
|
||||
return mb?.role;
|
||||
}, [email, mailboxes]);
|
||||
|
||||
const handleReply = useCallback((draftText?: string) => {
|
||||
if (!email) return;
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'reply',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
initialDraftText: draftText,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleReplyAll = useCallback(() => {
|
||||
if (!email) return;
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'replyAll',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Re: ${email.subject || t('email_composer.new_message')}`,
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleForward = useCallback(() => {
|
||||
if (!email) return;
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'forward',
|
||||
replyTo: buildReplyContext(email),
|
||||
sourceEmailId: email.id,
|
||||
title: `Fwd: ${email.subject || t('email_composer.new_message')}`,
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
await deleteEmail(client, email.id);
|
||||
closeTab(tabId);
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
toast.error(tNotifications('error_deleting'));
|
||||
}
|
||||
}, [client, email, deleteEmail, closeTab, tabId, tNotifications]);
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
const archiveMb = mailboxes.find((m) => m.role === 'archive');
|
||||
if (!archiveMb) return;
|
||||
try {
|
||||
await moveToMailbox(client, email.id, archiveMb.id);
|
||||
toast.success(tNotifications('email_archived'));
|
||||
closeTab(tabId);
|
||||
} catch (err) {
|
||||
console.error('Archive failed:', err);
|
||||
}
|
||||
}, [client, email, mailboxes, moveToMailbox, closeTab, tabId, tNotifications]);
|
||||
|
||||
const handleToggleStar = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
await toggleStar(client, email.id);
|
||||
// Reflect locally — the viewer re-reads from email-store's selectedEmail
|
||||
// shape only for the mail tab; here we update our local copy too.
|
||||
setEmail((prev) => prev ? {
|
||||
...prev,
|
||||
keywords: {
|
||||
...prev.keywords,
|
||||
$flagged: !prev.keywords?.$flagged,
|
||||
},
|
||||
} : prev);
|
||||
} catch (err) {
|
||||
console.error('Toggle star failed:', err);
|
||||
}
|
||||
}, [client, email, toggleStar]);
|
||||
|
||||
const handleMarkAsRead = useCallback(async (emailId: string, read: boolean) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await markAsRead(client, emailId, read);
|
||||
setEmail((prev) => prev && prev.id === emailId ? {
|
||||
...prev,
|
||||
keywords: { ...prev.keywords, $seen: read },
|
||||
} : prev);
|
||||
} catch (err) {
|
||||
console.error('Mark as read failed:', err);
|
||||
}
|
||||
}, [client, markAsRead]);
|
||||
|
||||
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
|
||||
if (!email || email.id !== emailId) return;
|
||||
// Drop existing color keywords, optionally add the new one. Matches the
|
||||
// mail page's local optimistic update.
|
||||
const keywords = { ...(email.keywords ?? {}) };
|
||||
for (const kw of settingsKeywords) {
|
||||
delete keywords[`$label:${kw.id}`];
|
||||
}
|
||||
if (color) {
|
||||
const def = settingsKeywords.find((k) => k.color === color);
|
||||
if (def) keywords[`$label:${def.id}`] = true;
|
||||
}
|
||||
setEmailKeywordsLocal(emailId, keywords);
|
||||
setEmail({ ...email, keywords });
|
||||
}, [email, settingsKeywords, setEmailKeywordsLocal]);
|
||||
|
||||
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
await moveToMailbox(client, email.id, mailboxId);
|
||||
closeTab(tabId);
|
||||
} catch (err) {
|
||||
console.error('Move failed:', err);
|
||||
}
|
||||
}, [client, email, moveToMailbox, closeTab, tabId]);
|
||||
|
||||
const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.downloadBlob(blobId, name, type);
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const handleQuickReply = useCallback(async (body: string) => {
|
||||
handleReply(body);
|
||||
}, [handleReply]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||
<EmailViewer
|
||||
email={email}
|
||||
isLoading={isLoading}
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
onMoveToMailbox={handleMoveToMailbox}
|
||||
currentUserEmail={client?.getUsername()}
|
||||
currentUserName={client?.getUsername()?.split('@')[0]}
|
||||
currentMailboxRole={currentMailboxRole}
|
||||
mailboxes={mailboxes}
|
||||
className="flex-1"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, X, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProTabStore, type ProTab, type ProTabKind } from "@/stores/pro-tab-store";
|
||||
|
||||
/** Custom MIME type used to carry the dragged Pro tab id between handlers. */
|
||||
export const PRO_TAB_DRAG_MIME = "application/x-pro-tab-id";
|
||||
|
||||
interface ProTabBarProps {
|
||||
/** All tabs (both panes). Order in the array is the order in the bar. */
|
||||
tabs: ProTab[];
|
||||
activeMainTabId: string | null;
|
||||
activeSplitTabId: string | null;
|
||||
onActivate: (id: string) => void;
|
||||
onClose: (id: string) => void;
|
||||
onDragStateChange?: (dragging: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TAB_ICONS: Record<ProTabKind, LucideIcon> = {
|
||||
mail: Mail,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
settings: Settings,
|
||||
compose: PenSquare,
|
||||
email: MailOpen,
|
||||
};
|
||||
|
||||
type DropIndicator = { targetId: string; edge: "before" | "after" } | null;
|
||||
|
||||
export function ProTabBar({
|
||||
tabs,
|
||||
activeMainTabId,
|
||||
activeSplitTabId,
|
||||
onActivate,
|
||||
onClose,
|
||||
onDragStateChange,
|
||||
className,
|
||||
}: ProTabBarProps) {
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const reorderTab = useProTabStore((s) => s.reorderTab);
|
||||
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
|
||||
|
||||
const [dropIndicator, setDropIndicator] = useState<DropIndicator>(null);
|
||||
const dragLeaveTimer = useRef<number | null>(null);
|
||||
|
||||
const isProTabDrag = (e: DragEvent) =>
|
||||
e.dataTransfer.types.includes(PRO_TAB_DRAG_MIME);
|
||||
|
||||
const handleDragStart = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
|
||||
e.dataTransfer.setData(PRO_TAB_DRAG_MIME, tab.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
onDragStateChange?.(true);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDropIndicator(null);
|
||||
onDragStateChange?.(false);
|
||||
};
|
||||
|
||||
const handleTabDragOver = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
|
||||
if (!isProTabDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const edge: "before" | "after" =
|
||||
e.clientX < rect.left + rect.width / 2 ? "before" : "after";
|
||||
setDropIndicator((prev) =>
|
||||
prev && prev.targetId === tab.id && prev.edge === edge
|
||||
? prev
|
||||
: { targetId: tab.id, edge },
|
||||
);
|
||||
if (dragLeaveTimer.current !== null) {
|
||||
window.clearTimeout(dragLeaveTimer.current);
|
||||
dragLeaveTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStripDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (next && e.currentTarget.contains(next)) return;
|
||||
if (dragLeaveTimer.current !== null) window.clearTimeout(dragLeaveTimer.current);
|
||||
dragLeaveTimer.current = window.setTimeout(() => {
|
||||
setDropIndicator(null);
|
||||
dragLeaveTimer.current = null;
|
||||
}, 40);
|
||||
};
|
||||
|
||||
const handleTabDrop = (e: DragEvent<HTMLDivElement>, tab: ProTab) => {
|
||||
if (!isProTabDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
|
||||
if (!draggedId || draggedId === tab.id) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
const edge = dropIndicator?.targetId === tab.id ? dropIndicator.edge : "after";
|
||||
reorderTab(draggedId, tab.id, edge);
|
||||
handleDragEnd();
|
||||
};
|
||||
|
||||
const handleStripEndDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isProTabDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
|
||||
if (!draggedId) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
const last = tabs[tabs.length - 1];
|
||||
if (last && last.id !== draggedId) {
|
||||
reorderTab(draggedId, last.id, "after");
|
||||
}
|
||||
handleDragEnd();
|
||||
};
|
||||
|
||||
const handleStripEndDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isProTabDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const last = tabs[tabs.length - 1];
|
||||
if (last) {
|
||||
setDropIndicator({ targetId: last.id, edge: "after" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-stretch h-9 bg-secondary px-1 overflow-x-auto scroll-hidden flex-shrink-0",
|
||||
className,
|
||||
)}
|
||||
style={{ borderBottom: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||
role="tablist"
|
||||
onDragLeave={handleStripDragLeave}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const Icon = TAB_ICONS[tab.kind];
|
||||
const isActiveMain = tab.id === activeMainTabId && tab.paneId === 'main';
|
||||
const isActiveSplit = tab.id === activeSplitTabId && tab.paneId === 'split';
|
||||
const isActive = isActiveMain || isActiveSplit;
|
||||
const isFocusedActive =
|
||||
(isActiveMain && focusedPaneId === 'main')
|
||||
|| (isActiveSplit && focusedPaneId === 'split');
|
||||
const label = tab.title ?? tSidebar(tab.labelKey);
|
||||
const showBefore = dropIndicator?.targetId === tab.id && dropIndicator.edge === "before";
|
||||
const showAfter = dropIndicator?.targetId === tab.id && dropIndicator.edge === "after";
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
data-tab-id={tab.id}
|
||||
data-pane-id={tab.paneId}
|
||||
draggable
|
||||
onClick={() => onActivate(tab.id)}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1 && tab.closeable) {
|
||||
e.preventDefault();
|
||||
onClose(tab.id);
|
||||
}
|
||||
}}
|
||||
onDragStart={(e) => handleDragStart(e, tab)}
|
||||
onDragOver={(e) => handleTabDragOver(e, tab)}
|
||||
onDrop={(e) => handleTabDrop(e, tab)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-1.5 px-3 h-9 text-sm cursor-pointer select-none transition-colors",
|
||||
"min-w-0 flex-1 basis-0 max-w-[200px] [min-width:80px]",
|
||||
"border-r border-border first:border-l",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
isFocusedActive && "font-medium",
|
||||
)}
|
||||
style={
|
||||
isActive
|
||||
? { borderRightColor: 'rgba(128, 128, 128, 0.3)', borderLeftColor: 'rgba(128, 128, 128, 0.3)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon className={cn("w-4 h-4 flex-shrink-0", isFocusedActive && "text-primary")} />
|
||||
<span className="truncate flex-1 min-w-0" title={label}>{label}</span>
|
||||
|
||||
{tab.closeable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(tab.id);
|
||||
}}
|
||||
className={cn(
|
||||
"ml-1 flex items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
|
||||
"text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground",
|
||||
!isActive && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
aria-label={tSidebar("close")}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute left-0 right-0 -bottom-px h-px bg-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBefore && (
|
||||
<span
|
||||
className="pointer-events-none absolute top-1 bottom-1 left-0 w-0.5 -translate-x-1/2 bg-primary rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{showAfter && (
|
||||
<span
|
||||
className="pointer-events-none absolute top-1 bottom-1 right-0 w-0.5 translate-x-1/2 bg-primary rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Trailing area soaks up drops past the last tab. */}
|
||||
<div
|
||||
className="flex-1 min-w-[8px]"
|
||||
onDragOver={handleStripEndDragOver}
|
||||
onDrop={handleStripEndDrop}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { parseMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
function getProtocolPathPrefix(): string {
|
||||
const marker = "/protocol/mailto";
|
||||
const index = window.location.pathname.indexOf(marker);
|
||||
return index > 0 ? window.location.pathname.slice(0, index) : "";
|
||||
}
|
||||
|
||||
function returnToSourcePage() {
|
||||
window.close();
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function openFallbackAppTab(raw: string): boolean {
|
||||
const url = `${getProtocolPathPrefix()}/protocol/mailto?url=${encodeURIComponent(raw)}&fallback=1`;
|
||||
const opened = window.open(url, "_blank");
|
||||
if (!opened) return false;
|
||||
opened.opener = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldOpenFallbackAppTab(): boolean {
|
||||
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
return !standalone && window.history.length > 1;
|
||||
}
|
||||
|
||||
async function focusExistingClient() {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const worker = navigator.serviceWorker.controller ?? registration.active;
|
||||
worker?.postMessage({ type: "focus-existing-mailto-client" });
|
||||
} catch {
|
||||
// Focusing is a progressive enhancement; the composer handoff still works.
|
||||
}
|
||||
}
|
||||
|
||||
interface MailtoProtocolClientProps {
|
||||
openingText: string;
|
||||
}
|
||||
|
||||
export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps) {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function handleMailto() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("url");
|
||||
const isFallbackAppTab = params.get("fallback") === "1";
|
||||
const openMode = useSettingsStore.getState().protocolOpenMode;
|
||||
const parsed = raw ? parseMailto(raw) : null;
|
||||
|
||||
if (parsed) {
|
||||
if (!isFallbackAppTab && openMode === "new-tab") {
|
||||
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
|
||||
returnToSourcePage();
|
||||
return;
|
||||
}
|
||||
} else if (!isFallbackAppTab) {
|
||||
const delivered = await requestOpenMailtoInExistingClient(parsed);
|
||||
if (cancelled) return;
|
||||
|
||||
if (delivered) {
|
||||
void focusExistingClient();
|
||||
returnToSourcePage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) {
|
||||
returnToSourcePage();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
savePendingMailto(parsed);
|
||||
}
|
||||
|
||||
window.location.replace(`${getProtocolPathPrefix()}/`);
|
||||
}
|
||||
|
||||
void handleMailto();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p>{openingText}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import type { AccountEntry } from "@/stores/account-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
type ProtocolAccountPickerProps = {
|
||||
accounts: AccountEntry[];
|
||||
activeAccountId: string | null;
|
||||
isSwitching?: boolean;
|
||||
onSelect: (accountId: string) => void;
|
||||
onCancel: () => void;
|
||||
} & (
|
||||
| { kind: "mailto"; operation?: ParsedMailto }
|
||||
| { kind: "webcal"; operation?: ParsedWebcal }
|
||||
);
|
||||
|
||||
function getHost(value: string): string {
|
||||
try {
|
||||
return new URL(value).hostname;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProtocolAccountPicker({
|
||||
kind,
|
||||
accounts,
|
||||
activeAccountId,
|
||||
isSwitching = false,
|
||||
onSelect,
|
||||
onCancel,
|
||||
operation,
|
||||
}: ProtocolAccountPickerProps) {
|
||||
const t = useTranslations("protocol_handlers");
|
||||
const tCommon = useTranslations("common");
|
||||
const details = operation
|
||||
? kind === "mailto"
|
||||
? [
|
||||
{ label: t("detail_to"), value: operation.to.join(", ") || "-" },
|
||||
{ label: t("detail_subject"), value: operation.subject || t("detail_no_subject") },
|
||||
]
|
||||
: [
|
||||
{ label: t("detail_calendar"), value: operation.suggestedName },
|
||||
{ label: t("detail_source"), value: getHost(operation.subscriptionUrl) },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onCancel} aria-hidden="true" />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("select_account_title")}
|
||||
className="relative w-full max-w-md rounded-lg border border-border bg-background shadow-xl animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">{t("select_account_title")}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{kind === "mailto" ? t("select_mailto_account") : t("select_webcal_account")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={tCommon("close")}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{details.length > 0 && (
|
||||
<div className="border-b border-border bg-muted/40 px-5 py-3">
|
||||
<dl className="space-y-1.5 text-sm">
|
||||
{details.map((detail) => (
|
||||
<div key={detail.label} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{detail.label}</dt>
|
||||
<dd className="truncate text-foreground" title={detail.value}>{detail.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-80 overflow-y-auto p-2">
|
||||
{accounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
let host = account.serverUrl;
|
||||
try {
|
||||
host = new URL(account.serverUrl).hostname;
|
||||
} catch {
|
||||
// Keep the configured value when it is not an absolute URL.
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={account.id}
|
||||
type="button"
|
||||
disabled={isSwitching}
|
||||
onClick={() => onSelect(account.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors",
|
||||
isActive ? "bg-accent/50" : "hover:bg-muted",
|
||||
isSwitching && "cursor-wait opacity-70"
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
name={account.displayName || account.label}
|
||||
email={account.email || account.username}
|
||||
size="md"
|
||||
className="shrink-0"
|
||||
disableFavicon
|
||||
fallbackColor={account.avatarColor}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{account.displayName || account.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
{t("active_account")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">{account.email || account.username}</p>
|
||||
<p className="truncate text-[10px] text-muted-foreground">{host}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border px-5 py-3">
|
||||
{isSwitching ? (
|
||||
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("switching_account")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t("select_account_note")}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSwitching}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter } from "@/i18n/navigation";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { parseMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import {
|
||||
listenForMailtoRequests,
|
||||
notifyPendingMailto,
|
||||
notifyPendingWebcal,
|
||||
requestOpenMailtoInExistingClient,
|
||||
savePendingMailto,
|
||||
savePendingWebcal,
|
||||
} from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type LaunchParams = { targetURL?: string };
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
launchQueue?: {
|
||||
setConsumer: (consumer: (launchParams: LaunchParams) => void) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getProtocolLaunch(targetURL: string):
|
||||
| { kind: "mailto"; raw: string }
|
||||
| { kind: "webcal"; raw: string }
|
||||
| null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(targetURL, window.location.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.origin !== window.location.origin) return null;
|
||||
|
||||
const raw = url.searchParams.get("url");
|
||||
if (!raw) return null;
|
||||
|
||||
if (url.pathname.includes("/protocol/mailto")) return { kind: "mailto", raw };
|
||||
if (url.pathname.includes("/protocol/webcal")) return { kind: "webcal", raw };
|
||||
return null;
|
||||
}
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
return window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
}
|
||||
|
||||
function openProtocolInNewTab(protocol: "mailto" | "webcal", raw: string): boolean {
|
||||
const url = `${getPathPrefix()}/protocol/${protocol}?url=${encodeURIComponent(raw)}&fallback=1`;
|
||||
const opened = window.open(url, "_blank");
|
||||
if (!opened) return false;
|
||||
opened.opener = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ProtocolLaunchHandlerProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandlerProviderProps) {
|
||||
const t = useTranslations("protocol_handlers");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/protocol/")) return;
|
||||
|
||||
return listenForMailtoRequests((pending) => {
|
||||
savePendingMailto(pending);
|
||||
notifyPendingMailto();
|
||||
if (pathname !== "/") router.push("/");
|
||||
}, () => ({
|
||||
path: pathname,
|
||||
standalone: isStandaloneDisplayMode(),
|
||||
focusNotificationTitle: t("focus_notification_title"),
|
||||
focusNotificationBody: t("focus_notification_body"),
|
||||
}));
|
||||
}, [pathname, router, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.launchQueue) return;
|
||||
|
||||
window.launchQueue.setConsumer((launchParams) => {
|
||||
if (!launchParams.targetURL) return;
|
||||
|
||||
const launch = getProtocolLaunch(launchParams.targetURL);
|
||||
if (!launch) return;
|
||||
|
||||
if (launch.kind === "mailto") {
|
||||
const parsed = parseMailto(launch.raw);
|
||||
if (!parsed) return;
|
||||
|
||||
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
|
||||
if (openProtocolInNewTab("mailto", launch.raw)) return;
|
||||
savePendingMailto(parsed);
|
||||
notifyPendingMailto();
|
||||
if (pathname !== "/") router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
void requestOpenMailtoInExistingClient(parsed).then((delivered) => {
|
||||
if (delivered) return;
|
||||
savePendingMailto(parsed);
|
||||
notifyPendingMailto();
|
||||
if (pathname !== "/") router.push("/");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseWebcal(launch.raw);
|
||||
if (!parsed) return;
|
||||
|
||||
if (useSettingsStore.getState().protocolOpenMode === "new-tab") {
|
||||
if (openProtocolInNewTab("webcal", launch.raw)) return;
|
||||
}
|
||||
|
||||
savePendingWebcal(parsed);
|
||||
notifyPendingWebcal();
|
||||
if (pathname !== "/calendar") router.push("/calendar");
|
||||
});
|
||||
}, [pathname, router]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import { savePendingWebcal } from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
function getProtocolPathPrefix(): string {
|
||||
const marker = "/protocol/webcal";
|
||||
const index = window.location.pathname.indexOf(marker);
|
||||
return index > 0 ? window.location.pathname.slice(0, index) : "";
|
||||
}
|
||||
|
||||
function returnToSourcePage() {
|
||||
window.close();
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function openFallbackAppTab(raw: string): boolean {
|
||||
const url = `${getProtocolPathPrefix()}/protocol/webcal?url=${encodeURIComponent(raw)}&fallback=1`;
|
||||
const opened = window.open(url, "_blank");
|
||||
if (!opened) return false;
|
||||
opened.opener = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldOpenFallbackAppTab(): boolean {
|
||||
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
return !standalone && window.history.length > 1;
|
||||
}
|
||||
|
||||
interface WebcalProtocolClientProps {
|
||||
openingText: string;
|
||||
}
|
||||
|
||||
export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps) {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("url");
|
||||
const isFallbackAppTab = params.get("fallback") === "1";
|
||||
|
||||
if (raw) {
|
||||
const parsed = parseWebcal(raw);
|
||||
if (parsed) {
|
||||
if (!isFallbackAppTab
|
||||
&& useSettingsStore.getState().protocolOpenMode === "new-tab"
|
||||
&& shouldOpenFallbackAppTab()
|
||||
&& openFallbackAppTab(raw)) {
|
||||
returnToSourcePage();
|
||||
return;
|
||||
}
|
||||
|
||||
savePendingWebcal(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
window.location.replace(`${getProtocolPathPrefix()}/calendar`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p>{openingText}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,14 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode
|
||||
|
||||
useEffect(() => {
|
||||
if (!embeddedMode || !isEmbedded()) return;
|
||||
// Refuse to attach the listener without a pinned parent origin —
|
||||
// otherwise any cross-origin frame could forge sso:trigger-logout.
|
||||
if (!parentOrigin) {
|
||||
console.error(
|
||||
"[embedded-bridge] embeddedMode is enabled but parentOrigin is not configured; refusing to attach message listener",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = listenFromParent((msg) => {
|
||||
switch (msg.type) {
|
||||
@@ -26,7 +34,7 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode
|
||||
logout();
|
||||
break;
|
||||
}
|
||||
}, parentOrigin || undefined);
|
||||
}, parentOrigin);
|
||||
|
||||
return unsubscribe;
|
||||
}, [embeddedMode, parentOrigin, logout]);
|
||||
|
||||
@@ -4,13 +4,14 @@ import { useEffect, useState } from 'react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import csMessages from '@/locales/cs/common.json';
|
||||
import daMessages from '@/locales/da/common.json';
|
||||
import deMessages from '@/locales/de/common.json';
|
||||
import enMessages from '@/locales/en/common.json';
|
||||
import esMessages from '@/locales/es/common.json';
|
||||
import frMessages from '@/locales/fr/common.json';
|
||||
import itMessages from '@/locales/it/common.json';
|
||||
import jaMessages from '@/locales/ja/common.json';
|
||||
import koMessages from '@/locales/ko/common.json';
|
||||
import esMessages from '@/locales/es/common.json';
|
||||
import itMessages from '@/locales/it/common.json';
|
||||
import deMessages from '@/locales/de/common.json';
|
||||
import lvMessages from '@/locales/lv/common.json';
|
||||
import nlMessages from '@/locales/nl/common.json';
|
||||
import plMessages from '@/locales/pl/common.json';
|
||||
@@ -23,13 +24,14 @@ import zhMessages from '@/locales/zh/common.json';
|
||||
// Pre-loaded translations (loaded at build time, not runtime)
|
||||
const ALL_MESSAGES = {
|
||||
cs: csMessages,
|
||||
da: daMessages,
|
||||
de: deMessages,
|
||||
en: enMessages,
|
||||
es: esMessages,
|
||||
fr: frMessages,
|
||||
it: itMessages,
|
||||
ja: jaMessages,
|
||||
ko: koMessages,
|
||||
es: esMessages,
|
||||
it: itMessages,
|
||||
de: deMessages,
|
||||
lv: lvMessages,
|
||||
nl: nlMessages,
|
||||
pl: plMessages,
|
||||
|
||||
@@ -10,5 +10,20 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
initializeTheme();
|
||||
}, [initializeTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'production') return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
|
||||
e.preventDefault();
|
||||
const { resolvedTheme, setTheme } = useThemeStore.getState();
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type Ap
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeI18nHtml } from '@/lib/email-sanitization';
|
||||
|
||||
function PasswordChangeSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
@@ -671,7 +672,7 @@ export function AccountSecuritySettings() {
|
||||
if (isStalwart === false) {
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: t('not_available') }} />
|
||||
<div className="text-sm text-muted-foreground py-4" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t('not_available')) }} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AppearanceSettings() {
|
||||
const tAdvanced = useTranslations('settings.advanced');
|
||||
const tTour = useTranslations('tour');
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, updateSetting } = useSettingsStore();
|
||||
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, showOnboardingOnNewDevices, updateSetting } = useSettingsStore();
|
||||
const { startTour, resetTourCompletion } = useTour();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
|
||||
@@ -145,6 +145,13 @@ export function AppearanceSettings() {
|
||||
{tTour('restart_button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={tTour('show_on_new_devices_title')} description={tTour('show_on_new_devices_desc')}>
|
||||
<ToggleSwitch
|
||||
checked={showOnboardingOnNewDevices}
|
||||
onChange={(checked) => updateSetting('showOnboardingOnNewDevices', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -10,7 +10,7 @@ import { SettingsSection } from './settings-section';
|
||||
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react';
|
||||
import { ShareCollectionDialog } from './share-collection-dialog';
|
||||
import type { CalendarRights } from '@/lib/jmap/types';
|
||||
import { cn, formatDateTime } from '@/lib/utils';
|
||||
import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils';
|
||||
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
@@ -155,7 +155,14 @@ export { CalendarColorPicker, CALENDAR_COLORS };
|
||||
export function CalendarManagementSettings() {
|
||||
const t = useTranslations('calendar.management');
|
||||
const { client, serverUrl, username } = useAuthStore();
|
||||
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||
// Subscriptions are persisted globally but scoped per JMAP account via
|
||||
// accountId. Legacy entries with no accountId show in the active account.
|
||||
const currentAccountId = client?.getAccountId();
|
||||
const icalSubscriptions = useMemo(
|
||||
() => allSubs.filter(s => !s.accountId || s.accountId === currentAccountId),
|
||||
[allSubs, currentAccountId],
|
||||
);
|
||||
|
||||
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
|
||||
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
|
||||
@@ -652,9 +659,14 @@ export function CalendarManagementSettings() {
|
||||
<Globe className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block">{sub.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate block" title={sub.url}>
|
||||
{sub.url}
|
||||
</span>
|
||||
{(() => {
|
||||
const safeUrl = redactUrlCredentials(sub.url);
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground truncate block" title={safeUrl}>
|
||||
{safeUrl}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{sub.lastRefreshed && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { SendDelaySeconds } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { Mail, X } from 'lucide-react';
|
||||
import { getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
||||
isSupportedSubAddressDelimiter,
|
||||
@@ -20,8 +18,6 @@ const DEFAULT_CUSTOM_DELIMITER = '~';
|
||||
|
||||
export function ComposingSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const { appName } = useConfig();
|
||||
const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
|
||||
const {
|
||||
@@ -30,22 +26,13 @@ export function ComposingSettings() {
|
||||
attachmentReminderKeywords,
|
||||
sendDelaySeconds,
|
||||
subAddressDelimiter,
|
||||
signaturePosition,
|
||||
signatureSeparatorEnabled,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { client } = useAuthStore();
|
||||
const delayedSendSupported = client?.hasDelayedSend() ?? false;
|
||||
|
||||
const handleSetDefaultMailProgram = useCallback(() => {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
|
||||
navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`);
|
||||
setDefaultMailStatus('success');
|
||||
}
|
||||
} catch {
|
||||
setDefaultMailStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
|
||||
@@ -73,6 +60,24 @@ export function ComposingSettings() {
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('signature_position.label')} description={t('signature_position.description')}>
|
||||
<Select
|
||||
value={signaturePosition}
|
||||
onChange={(value) => updateSetting('signaturePosition', value as 'above_quote' | 'below_quote')}
|
||||
options={[
|
||||
{ value: 'above_quote', label: t('signature_position.above_quote') },
|
||||
{ value: 'below_quote', label: t('signature_position.below_quote') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('signature_separator.label')} description={t('signature_separator.description')}>
|
||||
<ToggleSwitch
|
||||
checked={signatureSeparatorEnabled}
|
||||
onChange={(checked) => updateSetting('signatureSeparatorEnabled', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('sub_address_delimiter.label')}
|
||||
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
|
||||
@@ -171,24 +176,6 @@ export function ComposingSettings() {
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
onClick={handleSetDefaultMailProgram}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
|
||||
</button>
|
||||
{defaultMailStatus === 'success' && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
|
||||
)}
|
||||
{defaultMailStatus === 'error' && (
|
||||
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useContactStore } from '@/stores/contact-store';
|
||||
export function ContentSendersSettings() {
|
||||
const t = useTranslations('settings.email_behavior');
|
||||
const [showTrustedModal, setShowTrustedModal] = useState(false);
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
|
||||
const {
|
||||
externalContentPolicy,
|
||||
@@ -32,7 +32,7 @@ export function ContentSendersSettings() {
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{!isSettingHidden('externalContentPolicy') && (
|
||||
{isFeatureEnabled('externalContentEnabled') && !isSettingHidden('externalContentPolicy') && (
|
||||
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
|
||||
<Select
|
||||
value={externalContentPolicy}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Inbox, Send, FileText, Trash, ShieldAlert, Archive,
|
||||
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
|
||||
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
|
||||
AlertTriangle, NotebookPen, CalendarClock, BellOff,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn, buildMailboxTree, type MailboxNode } from '@/lib/utils';
|
||||
@@ -27,6 +28,11 @@ const ROLE_ICONS: Record<string, LucideIcon> = {
|
||||
trash: Trash,
|
||||
junk: ShieldAlert,
|
||||
archive: Archive,
|
||||
shared: Users,
|
||||
important: AlertTriangle,
|
||||
memos: NotebookPen,
|
||||
scheduled: CalendarClock,
|
||||
snoozed: BellOff,
|
||||
};
|
||||
|
||||
const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
|
||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||
@@ -13,6 +15,12 @@ const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false },
|
||||
];
|
||||
|
||||
const MAIL_LAYOUT_PREVIEW_ROWS_FOCUS = [
|
||||
...MAIL_LAYOUT_PREVIEW_ROWS,
|
||||
{ sender: 'Sam', subject: 'Lunch?', preview: '', selected: false },
|
||||
{ sender: 'Newsletter', subject: 'Weekly digest', preview: '', selected: false },
|
||||
];
|
||||
|
||||
function MailLayoutPreview({
|
||||
value,
|
||||
t,
|
||||
@@ -20,8 +28,6 @@ function MailLayoutPreview({
|
||||
value: MailLayout;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const isSplit = value === 'split';
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-border bg-background p-3">
|
||||
<div>
|
||||
@@ -33,7 +39,7 @@ function MailLayoutPreview({
|
||||
<div className="flex h-28">
|
||||
<div className="w-11 border-r border-border bg-muted/40" />
|
||||
|
||||
{isSplit ? (
|
||||
{value === 'split' && (
|
||||
<>
|
||||
<div className="w-28 border-r border-border bg-background">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||
@@ -56,15 +62,36 @@ function MailLayoutPreview({
|
||||
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 bg-background px-2 py-2">
|
||||
<div className="space-y-1.5">
|
||||
)}
|
||||
|
||||
{value === 'focus' && (
|
||||
<div className="flex-1 bg-background">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS_FOCUS.map((row) => (
|
||||
<div
|
||||
key={row.subject}
|
||||
className={cn(
|
||||
'border-b border-border px-2 py-1 text-[10px] last:border-b-0',
|
||||
row.selected && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="truncate text-foreground">
|
||||
<span className="font-medium">{row.sender}</span>
|
||||
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value === 'horizontal' && (
|
||||
<div className="flex-1 flex flex-col bg-background">
|
||||
<div className="border-b border-border bg-background">
|
||||
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
|
||||
<div
|
||||
key={row.subject}
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-[10px]',
|
||||
row.selected ? 'bg-primary/10' : 'bg-muted/20'
|
||||
'border-b border-border px-2 py-1 text-[10px] last:border-b-0',
|
||||
row.selected && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="truncate text-foreground">
|
||||
@@ -74,6 +101,11 @@ function MailLayoutPreview({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 bg-background px-3 py-2">
|
||||
<div className="h-2 w-20 rounded bg-foreground/10" />
|
||||
<div className="mt-1.5 h-1.5 w-full rounded bg-foreground/10" />
|
||||
<div className="mt-1 h-1.5 w-5/6 rounded bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -85,9 +117,10 @@ function MailLayoutPreview({
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, updateSetting } = useSettingsStore();
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -100,6 +133,7 @@ export function LayoutSettings() {
|
||||
options={[
|
||||
{ value: 'split', label: tEmail('mail_layout.split') },
|
||||
{ value: 'focus', label: tEmail('mail_layout.focus') },
|
||||
{ value: 'horizontal', label: tEmail('mail_layout.horizontal') },
|
||||
]}
|
||||
/>
|
||||
<MailLayoutPreview value={mailLayout} t={tEmail} />
|
||||
@@ -157,6 +191,23 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
|
||||
<div className="flex items-center gap-3">
|
||||
{proInterface && isDesktop && (
|
||||
<Link
|
||||
href="/pro"
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{t('pro_interface.open_label')}
|
||||
</Link>
|
||||
)}
|
||||
<ToggleSwitch
|
||||
checked={proInterface}
|
||||
onChange={(v) => updateSetting('proInterface', v)}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,11 +52,14 @@ export function NotificationSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
if (!client) return;
|
||||
const accountId = client.getAccountId();
|
||||
if (!accountId) return;
|
||||
void (async () => {
|
||||
const enabled = await isWebPushEnabled();
|
||||
if (enabled) setPushStatus({ kind: 'enabled' });
|
||||
const enabled = await isWebPushEnabled(accountId);
|
||||
setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
|
||||
})();
|
||||
}, [supported]);
|
||||
}, [supported, client]);
|
||||
|
||||
const trimmedRelay = relayUrl.trim().replace(/\/+$/, '');
|
||||
const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { ProtocolOpenMode } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { SettingsSection, SettingItem, Select } from "./settings-section";
|
||||
|
||||
type Protocol = "mailto" | "webcal";
|
||||
|
||||
function canRegisterProtocolHandler(): boolean {
|
||||
return typeof navigator !== "undefined"
|
||||
&& "registerProtocolHandler" in navigator
|
||||
&& typeof window !== "undefined"
|
||||
&& window.isSecureContext;
|
||||
}
|
||||
|
||||
function getProtocolHandlerUrl(protocol: Protocol) {
|
||||
return `${window.location.origin}${getPathPrefix()}/protocol/${protocol}?url=%s`;
|
||||
}
|
||||
|
||||
function registerProtocolHandler(protocol: Protocol) {
|
||||
navigator.registerProtocolHandler(
|
||||
protocol,
|
||||
getProtocolHandlerUrl(protocol),
|
||||
);
|
||||
}
|
||||
|
||||
interface ProtocolHandlerSettingsProps {
|
||||
supportsCalendar: boolean;
|
||||
}
|
||||
|
||||
export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSettingsProps) {
|
||||
const t = useTranslations("protocol_handlers");
|
||||
const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode);
|
||||
const updateSetting = useSettingsStore((state) => state.updateSetting);
|
||||
const [supported, setSupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSupported(canRegisterProtocolHandler());
|
||||
}, []);
|
||||
|
||||
const handleOpenModeChange = async (value: string) => {
|
||||
const openMode = value as ProtocolOpenMode;
|
||||
|
||||
if (openMode === "active-session"
|
||||
&& typeof window !== "undefined"
|
||||
&& "Notification" in window
|
||||
&& Notification.permission === "default") {
|
||||
await Notification.requestPermission();
|
||||
}
|
||||
|
||||
updateSetting("protocolOpenMode", openMode);
|
||||
};
|
||||
|
||||
const handleRegister = (protocol: Protocol) => {
|
||||
try {
|
||||
registerProtocolHandler(protocol);
|
||||
toast.success(protocol === "mailto" ? t("mailto_registered") : t("webcal_registered"));
|
||||
} catch {
|
||||
toast.error(t("registration_failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const renderRegistrationControl = (protocol: Protocol) => {
|
||||
return (
|
||||
<Button size="sm" onClick={() => handleRegister(protocol)} disabled={!supported}>
|
||||
{protocol === "mailto" ? t("register_mailto") : t("register_webcal")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
{!supported && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
{t("unsupported")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label={t("mailto_label")} description={t("mailto_description")}>
|
||||
{renderRegistrationControl("mailto")}
|
||||
</SettingItem>
|
||||
|
||||
{supportsCalendar && (
|
||||
<SettingItem label={t("webcal_label")} description={t("webcal_description")}>
|
||||
{renderRegistrationControl("webcal")}
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={t("protocol_open_mode_label")} description={t("protocol_open_mode_description")}>
|
||||
<Select
|
||||
value={protocolOpenMode}
|
||||
onChange={handleOpenModeChange}
|
||||
options={[
|
||||
{ value: "new-tab", label: t("protocol_open_mode_new_tab") },
|
||||
{ value: "active-session", label: t("protocol_open_mode_active_session") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t("browser_note")}</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,6 @@ export function SmimeSettings() {
|
||||
identityKeyBindings,
|
||||
defaultSignIdentity,
|
||||
defaultEncrypt,
|
||||
rememberUnlockedKeys,
|
||||
autoImportSignerCerts,
|
||||
isLoading,
|
||||
error,
|
||||
@@ -44,7 +43,6 @@ export function SmimeSettings() {
|
||||
lockKey,
|
||||
setSignDefault,
|
||||
setEncryptDefault,
|
||||
setRememberUnlockedKeys,
|
||||
setAutoImportSignerCerts,
|
||||
isKeyUnlocked,
|
||||
setError,
|
||||
@@ -465,16 +463,6 @@ export function SmimeSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t("remember_unlocked")}
|
||||
description={t("remember_unlocked_desc")}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={rememberUnlockedKeys}
|
||||
onChange={setRememberUnlockedKeys}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t("auto_import_signer_certs")}
|
||||
description={t("auto_import_signer_certs_desc")}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter, usePathname } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { getTourSteps, type TourStep } from "./tour-steps";
|
||||
import { TourOverlay } from "./tour-overlay";
|
||||
|
||||
@@ -38,6 +39,9 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const { isDemoMode } = useAuthStore();
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { supportsWebDAV } = useWebDAVStore();
|
||||
const tourCompleted = useSettingsStore((s) => s.tourCompleted);
|
||||
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
@@ -46,10 +50,29 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
|
||||
|
||||
useEffect(() => {
|
||||
// One-time migration: if the legacy per-device flag is set but the synced
|
||||
// setting isn't yet, mirror it into synced state.
|
||||
try {
|
||||
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
|
||||
const legacy = localStorage.getItem(TOUR_COMPLETED_KEY) === "true";
|
||||
if (legacy && !tourCompleted) {
|
||||
updateSetting("tourCompleted", true);
|
||||
}
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
}, [tourCompleted, updateSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tourCompleted) {
|
||||
setHasCompletedTour(false);
|
||||
return;
|
||||
}
|
||||
if (showOnboardingOnNewDevices) {
|
||||
try {
|
||||
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
|
||||
return;
|
||||
} catch { /* */ }
|
||||
}
|
||||
setHasCompletedTour(true);
|
||||
}, [tourCompleted, showOnboardingOnNewDevices]);
|
||||
|
||||
const startTour = useCallback(() => {
|
||||
let resumeStep = 0;
|
||||
@@ -85,11 +108,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const completeTour = useCallback(() => {
|
||||
setIsActive(false);
|
||||
setHasCompletedTour(true);
|
||||
updateSetting("tourCompleted", true);
|
||||
try {
|
||||
localStorage.setItem(TOUR_COMPLETED_KEY, "true");
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
}, [updateSetting]);
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (currentStep >= steps.length - 1) {
|
||||
@@ -131,11 +155,12 @@ export function TourProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const resetTourCompletion = useCallback(() => {
|
||||
setHasCompletedTour(false);
|
||||
updateSetting("tourCompleted", false);
|
||||
try {
|
||||
localStorage.removeItem(TOUR_COMPLETED_KEY);
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
}, [updateSetting]);
|
||||
|
||||
const value: TourContextValue = {
|
||||
isActive,
|
||||
|
||||
@@ -142,9 +142,13 @@ interface AvatarProps {
|
||||
className?: string;
|
||||
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
|
||||
disableImages?: boolean;
|
||||
/** When true, do not fall through to the sender's domain favicon. Use for the user's own account avatar where the mail-provider logo is not meaningful. */
|
||||
disableFavicon?: boolean;
|
||||
/** Background color used when no image source resolves. Overrides the hash-based default. */
|
||||
fallbackColor?: string;
|
||||
}
|
||||
|
||||
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) {
|
||||
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false, disableFavicon = false, fallbackColor }: AvatarProps) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
||||
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
|
||||
@@ -226,16 +230,15 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
|
||||
const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null;
|
||||
const showFavicon =
|
||||
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
||||
!disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
|
||||
|
||||
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
|
||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||
const imgSrc = disableImages
|
||||
? null
|
||||
: !imgError && !domainFailed
|
||||
? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
|
||||
: (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
|
||||
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null;
|
||||
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
|
||||
const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
|
||||
|
||||
const handleImgError = useCallback(() => {
|
||||
// If the plugin avatar just failed, mark it and fall through to the next source
|
||||
@@ -257,7 +260,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: imgSrc ? "#ffffff" : getBackgroundColor() }}
|
||||
style={{ backgroundColor: imgSrc ? (isFavicon ? "#ffffff" : "transparent") : (fallbackColor ?? getBackgroundColor()) }}
|
||||
title={name || email}
|
||||
>
|
||||
{imgSrc ? (
|
||||
|
||||
@@ -201,15 +201,27 @@ export function FlagCS(props: FlagProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Denmark – Red with a white Nordic cross */
|
||||
export function FlagDK(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 28" width={W} height={H} className={flagClass} {...props}>
|
||||
<path fill="#C8102E" d="M0,0H37V28H0Z" />
|
||||
<path stroke="#fff" strokeWidth="4" d="M0,14h37M14,0v28" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map locale codes to flag components */
|
||||
export const flagComponents: Record<string, (props: FlagProps) => ReactElement> = {
|
||||
cs: FlagCS,
|
||||
da: FlagDK,
|
||||
de: FlagDE,
|
||||
en: FlagGB,
|
||||
es: FlagES,
|
||||
fr: FlagFR,
|
||||
it: FlagIT,
|
||||
ja: FlagJP,
|
||||
ko: FlagKR,
|
||||
es: FlagES,
|
||||
it: FlagIT,
|
||||
de: FlagDE,
|
||||
lv: FlagLV,
|
||||
nl: FlagNL,
|
||||
pl: FlagPL,
|
||||
@@ -218,5 +230,4 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
||||
tr: FlagTR,
|
||||
uk: FlagUA,
|
||||
zh: FlagCN,
|
||||
cs: FlagCS,
|
||||
};
|
||||
|
||||
@@ -9,20 +9,21 @@ import { flagComponents } from './flag-icons';
|
||||
|
||||
const languages = [
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'da', label: 'Dansk' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'lv', label: 'Latviešu' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'tr', label: 'Türkçe' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'uk', label: 'Українська' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'zh', label: '简体中文' },
|
||||
];
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { X, Lightbulb, Settings, PlayCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
const ONBOARDING_KEY = "onboarding_completed";
|
||||
|
||||
@@ -13,23 +14,47 @@ export function WelcomeBanner() {
|
||||
const t = useTranslations("welcome");
|
||||
const router = useRouter();
|
||||
const { startTour } = useTour();
|
||||
const onboardingCompleted = useSettingsStore((s) => s.onboardingCompleted);
|
||||
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// One-time migration: if the legacy per-device flag is set but the synced
|
||||
// setting isn't yet, mirror it into synced state so the user isn't shown
|
||||
// the banner again on this device after the upgrade.
|
||||
try {
|
||||
if (!localStorage.getItem(ONBOARDING_KEY)) {
|
||||
setVisible(true);
|
||||
const legacy = localStorage.getItem(ONBOARDING_KEY) === "true";
|
||||
if (legacy && !onboardingCompleted) {
|
||||
updateSetting("onboardingCompleted", true);
|
||||
}
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}, []);
|
||||
}, [onboardingCompleted, updateSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onboardingCompleted) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
if (showOnboardingOnNewDevices) {
|
||||
try {
|
||||
if (localStorage.getItem(ONBOARDING_KEY) !== "true") {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}
|
||||
setVisible(false);
|
||||
}, [onboardingCompleted, showOnboardingOnNewDevices]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setDismissed(true);
|
||||
updateSetting("onboardingCompleted", true);
|
||||
try {
|
||||
localStorage.setItem(ONBOARDING_KEY, "true");
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}, []);
|
||||
}, [updateSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
Reference in New Issue
Block a user