Make an event out of a message
Asked for in #167: a right-click on a mail that turns it into a calendar entry, the way a bill or a task becomes a reminder. Nothing clever, and deliberately so -- the subject becomes the title, the body becomes the description, and the reader supplies the one thing the message cannot. A due date is exactly that thing. "Due on the 14th" in an invoice is not a date a parser could be trusted with, and a wrong guess quietly scheduled is worse than no guess at all, so the editor opens on the next half hour for an hour and the reader fixes it. Forward rather than now, because a start time that has already passed by the time they press Create is one more thing to correct. The body is capped at 5000 characters. A newsletter is a message too, and its whole body would be stored on the event, synced to every device, and shown in a three-row textarea; what is worth keeping -- the amount, the account, the address -- is near the top. The cut is marked, so a truncated bill is not read as the whole of it. One message only. The list menu acts on the selection everywhere else, but there is no sensible event to make out of five mails, and the mobile entry appears only when exactly one row is held. The editor lives inside CalendarView and the reader is in the mail view when they ask, so the draft waits in the calendar store until that view mounts and takes it -- once, or it would reopen on every later visit. It seeds a form rather than an event: the dialog still says New event and still has to be pressed. Called *Create event…* rather than "appointment", which is the word the issue used: it opens the New event dialog, and each catalogue already has its own settled noun for that -- Termin, événement, 日程. Reachable three ways, since a phone has no right-click: the row context menu, a message's ⋮, and the ⋮ of a held row on mobile. Hidden entirely where the account has no calendar.
This commit is contained in:
+6
-2
@@ -140,7 +140,7 @@ more attempt. A drag that is merely more sideways than not stays a scroll.
|
||||
- **Drag and drop** onto any folder in the tree, moving the selection or the
|
||||
row under the cursor.
|
||||
- **Context menu** on any row: reply, forward, archive, delete, spam, read/unread,
|
||||
star, move to…, label…, and *Filter messages like this…*.
|
||||
star, move to…, label…, *Filter messages like this…*, and *Create event…*.
|
||||
- **Snippets and avatars** are optional; the star is always in the row.
|
||||
- **Skeleton rows** while a page loads, rather than an empty pane.
|
||||
|
||||
@@ -382,7 +382,7 @@ nothing for anybody else.
|
||||
|
||||
Created by clicking an empty slot or dragging across a range; a context menu on
|
||||
empty space offers a timed or all-day event at that moment, or *Go to day* /
|
||||
*Go to week*.
|
||||
*Go to week*. Also from a message — see *Create event…* below.
|
||||
|
||||
The editor covers title, start and end (all-day or timed, with a time zone),
|
||||
calendar, location, meeting link, guests, description, reminders, repeat,
|
||||
@@ -398,6 +398,10 @@ status (confirmed / tentative / cancelled), show-as (busy / free), visibility
|
||||
picker that predated them is gone; a colour comes from the category, or the
|
||||
calendar.)
|
||||
- **Duplicate** an event from the context menu.
|
||||
- **Create event…** from a message, in its context menu and its ⋮ menu (and,
|
||||
on a phone, in the ⋮ of a held row). The subject becomes the title and the
|
||||
body the description; the editor opens on the next half hour for an hour,
|
||||
because when it happens is the one thing the message cannot say.
|
||||
- **Popover** on click with the detail and quick actions; the editor on
|
||||
*Edit…*.
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appointmentDraft, nextHalfHour } from "@/lib/appointment";
|
||||
import type { Email, EmailBodyPart } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* A reminder made out of a mail: the subject becomes the title and the body
|
||||
* becomes the description, and the reader supplies the one thing the message
|
||||
* cannot — when it happens. What these pin is that the copy is faithful and
|
||||
* bounded, because everything else about the event is the editor's job.
|
||||
*/
|
||||
|
||||
function part(partId: string, type: string): EmailBodyPart {
|
||||
return { partId, type } as EmailBodyPart;
|
||||
}
|
||||
|
||||
function email(parts: Partial<Email>): Email {
|
||||
return { id: "m1", subject: null, ...parts } as Email;
|
||||
}
|
||||
|
||||
function body(subject: string, type: "text/plain" | "text/html", value: string): Email {
|
||||
const key = type === "text/plain" ? "textBody" : "htmlBody";
|
||||
return email({ subject, [key]: [part("1", type)], bodyValues: { 1: { value, isEncodingProblem: false, isTruncated: false } } });
|
||||
}
|
||||
|
||||
const text = (value: string) => body("Water bill", "text/plain", value);
|
||||
|
||||
describe("the time an appointment starts", () => {
|
||||
it("rounds up to the next half hour", () => {
|
||||
expect(nextHalfHour(new Date("2026-08-31T09:12:40")).toTimeString().slice(0, 5)).toBe("09:30");
|
||||
expect(nextHalfHour(new Date("2026-08-31T09:41:00")).toTimeString().slice(0, 5)).toBe("10:00");
|
||||
});
|
||||
|
||||
it("moves on from a time already on the boundary, rather than starting now", () => {
|
||||
expect(nextHalfHour(new Date("2026-08-31T09:30:00")).toTimeString().slice(0, 5)).toBe("10:00");
|
||||
});
|
||||
|
||||
it("runs for an hour", () => {
|
||||
const d = appointmentDraft(text("anything"), new Date("2026-08-31T09:12:00"));
|
||||
expect(d.end.getTime() - d.start.getTime()).toBe(3600_000);
|
||||
expect(d.allDay).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what is copied from the message", () => {
|
||||
it("takes the subject as the title and the body as the description", () => {
|
||||
const d = appointmentDraft(text("Due on the 14th.\nAccount 4471.\n"));
|
||||
expect(d.title).toBe("Water bill");
|
||||
expect(d.description).toBe("Due on the 14th.\nAccount 4471.");
|
||||
});
|
||||
|
||||
it("reads an HTML-only message as text, so the description is not markup", () => {
|
||||
const d = appointmentDraft(body("Renewal", "text/html", "<p>Renews <b>Friday</b></p>"));
|
||||
expect(d.description).toBe("Renews Friday");
|
||||
});
|
||||
|
||||
it("leaves the title empty when there is no subject, for the editor to prompt for", () => {
|
||||
expect(appointmentDraft(email({ subject: null })).title).toBe("");
|
||||
});
|
||||
|
||||
/*
|
||||
* A newsletter is a message too. The whole body would be stored on the
|
||||
* event, synced everywhere, and shown in a three-row box, so the tail is
|
||||
* dropped — visibly, so a truncated bill is not read as the whole of it.
|
||||
*/
|
||||
it("truncates a body too long to be a description", () => {
|
||||
const d = appointmentDraft(text("x".repeat(9000)));
|
||||
expect(d.description).toHaveLength(5001);
|
||||
expect(d.description.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Email } from "@/jmap/types";
|
||||
import { useCalendar, type EventDraft } from "@/store/calendar";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { toLocalDateOnly } from "./dates";
|
||||
import { htmlToText } from "./text";
|
||||
|
||||
/**
|
||||
* How much of a message body is copied into an event description.
|
||||
*
|
||||
* The reader is making a reminder out of a mail, and a newsletter is a mail
|
||||
* too: whole bodies run to hundreds of kilobytes, which would be stored on the
|
||||
* event, synced to every device, and shown in a three-row textarea. What is
|
||||
* worth keeping is near the top -- the amount owed, the date, the address --
|
||||
* so the tail is what gets dropped, and visibly, so nobody reads a truncated
|
||||
* bill as the whole of it.
|
||||
*/
|
||||
const MAX_DESCRIPTION = 5000;
|
||||
|
||||
/**
|
||||
* The next half-hour, which is when an appointment made now can start.
|
||||
*
|
||||
* Always forward, never the current instant: the reader still has a form to
|
||||
* fill in, and a start time that is already in the past by the time they press
|
||||
* Create is one they have to fix by hand.
|
||||
*/
|
||||
export function nextHalfHour(now: Date = new Date()): Date {
|
||||
const d = new Date(now);
|
||||
d.setSeconds(0, 0);
|
||||
d.setMinutes(d.getMinutes() + (30 - (d.getMinutes() % 30)));
|
||||
return d;
|
||||
}
|
||||
|
||||
/** The message's body as plain text, however it was sent. */
|
||||
function bodyText(email: Email): string {
|
||||
const textPart = email.textBody?.[0];
|
||||
const text = textPart?.partId ? (email.bodyValues?.[textPart.partId]?.value ?? "") : "";
|
||||
if (text.trim()) return text;
|
||||
const htmlPart = email.htmlBody?.[0];
|
||||
const html = htmlPart?.partId ? (email.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
|
||||
return html ? htmlToText(html) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* An event seeded from a message: its subject, its body, and a time to fix.
|
||||
*
|
||||
* Deliberately nothing clever. The date is the one thing the message cannot
|
||||
* supply -- "the 14th" in a bill is not a due date the parser could trust --
|
||||
* so the editor opens with the reader's cursor on a form they finish, rather
|
||||
* than a guess they have to check.
|
||||
*/
|
||||
export function appointmentDraft(email: Email, now: Date = new Date()): EventDraft {
|
||||
const start = nextHalfHour(now);
|
||||
const body = bodyText(email).trim();
|
||||
return {
|
||||
title: email.subject?.trim() ?? "",
|
||||
description: body.length > MAX_DESCRIPTION ? `${body.slice(0, MAX_DESCRIPTION).trimEnd()}…` : body,
|
||||
start,
|
||||
end: new Date(start.getTime() + 3600_000),
|
||||
allDay: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the calendar's event editor on a draft made from this message.
|
||||
*
|
||||
* The list holds a message without its body -- only a preview -- so the full
|
||||
* one is fetched first; `getEmails` serves it from the cache when the message
|
||||
* has already been read.
|
||||
*/
|
||||
export async function startAppointment(email: Email, navigate: (to: string) => void): Promise<void> {
|
||||
const full = (await useMail.getState().getEmails([email.id], true))[0] ?? email;
|
||||
const draft = appointmentDraft(full);
|
||||
useCalendar.getState().setDraft(draft);
|
||||
navigate(`/calendar/day/${toLocalDateOnly(draft.start)}`);
|
||||
}
|
||||
@@ -248,6 +248,7 @@ export const catalog: Catalog = {
|
||||
"Calendar is not available": "Kalender ist nicht verfügbar",
|
||||
"Event": "Termin",
|
||||
"New event": "Neuer Termin",
|
||||
"Create event…": "Termin erstellen…",
|
||||
"(new event)": "(neuer Termin)",
|
||||
"New all-day event": "Neuer ganztägiger Termin",
|
||||
"Add title": "Titel hinzufügen",
|
||||
|
||||
@@ -240,6 +240,7 @@ export const catalog: Catalog = {
|
||||
"Calendar is not available": "El calendario no está disponible",
|
||||
"Event": "Evento",
|
||||
"New event": "Evento nuevo",
|
||||
"Create event…": "Crear evento…",
|
||||
"(new event)": "(evento nuevo)",
|
||||
"New all-day event": "Evento nuevo de todo el día",
|
||||
"Add title": "Añadir un título",
|
||||
|
||||
@@ -245,6 +245,7 @@ export const catalog: Catalog = {
|
||||
"Calendar is not available": "L'agenda n'est pas disponible",
|
||||
"Event": "Événement",
|
||||
"New event": "Nouvel événement",
|
||||
"Create event…": "Créer un événement…",
|
||||
"(new event)": "(nouvel événement)",
|
||||
"New all-day event": "Nouvel événement sur la journée",
|
||||
"Add title": "Ajouter un titre",
|
||||
|
||||
@@ -239,6 +239,7 @@ export const catalog: Catalog = {
|
||||
"Calendar is not available": "カレンダーは利用できません",
|
||||
"Event": "予定",
|
||||
"New event": "新しい予定",
|
||||
"Create event…": "予定を作成…",
|
||||
"(new event)": "(新しい予定)",
|
||||
"New all-day event": "新しい終日の予定",
|
||||
"Add title": "タイトルを追加",
|
||||
|
||||
@@ -236,6 +236,7 @@ export const catalog: Catalog = {
|
||||
"Calendar is not available": "Agenda is niet beschikbaar",
|
||||
"Event": "Afspraak",
|
||||
"New event": "Nieuwe afspraak",
|
||||
"Create event…": "Afspraak maken…",
|
||||
"(new event)": "(nieuwe afspraak)",
|
||||
"New all-day event": "Nieuwe afspraak voor de hele dag",
|
||||
"Add title": "Titel toevoegen",
|
||||
|
||||
@@ -243,6 +243,7 @@ export const catalog: Catalog = {
|
||||
"Calendar is not available": "A agenda não está disponível",
|
||||
"Event": "Evento",
|
||||
"New event": "Novo evento",
|
||||
"Create event…": "Criar evento…",
|
||||
"(new event)": "(novo evento)",
|
||||
"New all-day event": "Novo evento de dia inteiro",
|
||||
"Add title": "Adicionar um título",
|
||||
|
||||
@@ -242,6 +242,7 @@ export const catalog: Catalog = {
|
||||
"Calendar is not available": "Календарь недоступен",
|
||||
"Event": "Событие",
|
||||
"New event": "Новое событие",
|
||||
"Create event…": "Создать событие…",
|
||||
"(new event)": "(новое событие)",
|
||||
"New all-day event": "Новое событие на весь день",
|
||||
"Add title": "Добавить название",
|
||||
|
||||
@@ -236,6 +236,7 @@ export const catalog: Catalog = {
|
||||
"Calendar is not available": "Календар недоступний",
|
||||
"Event": "Подія",
|
||||
"New event": "Нова подія",
|
||||
"Create event…": "Створити подію…",
|
||||
"(new event)": "(нова подія)",
|
||||
"New all-day event": "Нова подія на весь день",
|
||||
"Add title": "Додати назву",
|
||||
|
||||
@@ -238,6 +238,7 @@ export const catalog: Catalog = {
|
||||
"Calendar is not available": "日历不可用",
|
||||
"Event": "日程",
|
||||
"New event": "新建日程",
|
||||
"Create event…": "创建日程…",
|
||||
"(new event)": "(新建日程)",
|
||||
"New all-day event": "新建全天日程",
|
||||
"Add title": "添加标题",
|
||||
|
||||
@@ -224,6 +224,22 @@ export interface SharedCalendar {
|
||||
/** Shared events are keyed by account too: ids only differ within an account. */
|
||||
export const sharedKey = (accountId: Id, id: Id): string => `${accountId}:${id}`;
|
||||
|
||||
/**
|
||||
* An event begun outside the calendar -- from a message, so far.
|
||||
*
|
||||
* The editor lives inside CalendarView and the reader is somewhere else when
|
||||
* they ask for this, so the draft waits here until that view mounts and takes
|
||||
* it. It is taken exactly once: a draft left behind would reopen the editor
|
||||
* every time the reader came back to the calendar.
|
||||
*/
|
||||
export interface EventDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
}
|
||||
|
||||
interface CalendarState {
|
||||
accountId: Id | null;
|
||||
available: boolean;
|
||||
@@ -241,6 +257,8 @@ interface CalendarState {
|
||||
error: string | null;
|
||||
identities: ParticipantIdentity[];
|
||||
hidden: Record<Id, true>;
|
||||
/** Waiting to be opened in the editor; see `EventDraft`. */
|
||||
draft: EventDraft | null;
|
||||
|
||||
init(): Promise<void>;
|
||||
loadCalendars(): Promise<void>;
|
||||
@@ -267,6 +285,7 @@ interface CalendarState {
|
||||
importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>;
|
||||
applyChanges(types: Set<string>): void;
|
||||
invalidate(): void;
|
||||
setDraft(draft: EventDraft | null): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,6 +314,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
error: null,
|
||||
identities: [],
|
||||
hidden: {},
|
||||
draft: null,
|
||||
|
||||
async init() {
|
||||
// The reader's own: a shared calendar is shown beside theirs, not instead.
|
||||
@@ -670,6 +690,10 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
void get().loadRange(new Date(s), new Date(e), true);
|
||||
}
|
||||
},
|
||||
|
||||
setDraft(draft) {
|
||||
set({ draft });
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,6 +70,18 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cal.available, range.start.getTime(), range.end.getTime()]);
|
||||
|
||||
/*
|
||||
* An event begun elsewhere -- from a message -- opens here, because this is
|
||||
* where the editor lives. Cleared as it is taken: a draft left in the store
|
||||
* would reopen the editor on every later visit to the calendar.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!cal.draft) return;
|
||||
const { title, description, ...when } = cal.draft;
|
||||
setEditor({ ...when, seed: { title, description } });
|
||||
cal.setDraft(null);
|
||||
}, [cal.draft]);
|
||||
|
||||
const go = useCallback((v: View, d: Date) => navigate(`/calendar/${v}/${toLocalDateOnly(d)}`), [navigate]);
|
||||
const step = (n: number) => {
|
||||
if (effectiveView === "month") go(view, addMonths(anchor, n));
|
||||
|
||||
@@ -22,6 +22,12 @@ export interface EditorInit {
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
/**
|
||||
* Values a new event opens with, from wherever it was begun -- a message,
|
||||
* so far. Not an event: this is still a form the reader has to finish, so
|
||||
* `editing` stays false and the dialog says New event / Create.
|
||||
*/
|
||||
seed?: { title?: string; description?: string };
|
||||
}
|
||||
|
||||
const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080];
|
||||
@@ -97,7 +103,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
||||
const baseStart = ev ? zonedToDate(ev.start, ev.showWithoutTime ? null : evTz) : init.start;
|
||||
const baseEnd = ev ? new Date(baseStart.getTime() + (parseDuration(ev.duration) || (ev.showWithoutTime ? 86400 : 3600)) * 1000) : init.end;
|
||||
|
||||
const [title, setTitle] = useState(ev?.title ?? "");
|
||||
const [title, setTitle] = useState(ev?.title ?? init.seed?.title ?? "");
|
||||
const [calendarId, setCalendarId] = useState(initialCal ?? "");
|
||||
const [allDay, setAllDay] = useState(ev ? Boolean(ev.showWithoutTime) : init.allDay);
|
||||
const [start, setStart] = useState(baseStart);
|
||||
@@ -105,7 +111,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
||||
const [tz, setTz] = useState(evTz);
|
||||
const [location, setLocation] = useState(Object.values(ev?.locations ?? {})[0]?.name ?? "");
|
||||
const [vurl, setVurl] = useState(Object.values(ev?.virtualLocations ?? {})[0]?.uri ?? "");
|
||||
const [description, setDescription] = useState(ev?.description ?? "");
|
||||
const [description, setDescription] = useState(ev?.description ?? init.seed?.description ?? "");
|
||||
const [status, setStatus] = useState<NonNullable<CalendarEvent["status"]>>(ev?.status ?? "confirmed");
|
||||
const [privacy, setPrivacy] = useState<NonNullable<CalendarEvent["privacy"]>>(ev?.privacy ?? "public");
|
||||
const [freeBusy, setFreeBusy] = useState<NonNullable<CalendarEvent["freeBusyStatus"]>>(ev?.freeBusyStatus ?? "busy");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { Archive, ArrowLeft, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
|
||||
import { Archive, ArrowLeft, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useMail, type ListState } from "@/store/mail";
|
||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||
@@ -11,6 +11,9 @@ import { displayName, shortName } from "@/lib/address";
|
||||
import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { startAppointment } from "@/lib/appointment";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/touch";
|
||||
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/swipe";
|
||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
@@ -89,6 +92,8 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
const twoLine = isMobile || (paneWidth > 0 && paneWidth < 640);
|
||||
const ctxMenu = useMenu();
|
||||
const [ctxRow, setCtxRow] = useState<Id | null>(null);
|
||||
/** Only offered where there is a calendar to put the appointment in. */
|
||||
const hasCalendar = useCalendar((s) => s.available);
|
||||
const moreMenu = useMenu();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [filterFrom, setFilterFrom] = useState<Email | null>(null);
|
||||
@@ -277,6 +282,20 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
/>
|
||||
<MenuItem icon={<Mail size={16} />} label={t("Mark as unread")} onClick={() => void actions.read(false)} />
|
||||
<MenuItem icon={<Tag size={16} />} label={t("Label…")} onClick={() => actions.label(undefined, { x: window.innerWidth / 2, y: 100 })} />
|
||||
{/*
|
||||
A phone reaches this menu by holding a row, which is also
|
||||
the only way it reaches per-message actions at all -- there
|
||||
is no right-click. Offered for one message only: the draft
|
||||
is one message's subject and body, and there is no sensible
|
||||
event to make out of five of them.
|
||||
*/}
|
||||
{hasCalendar && selCount === 1 && (
|
||||
<MenuItem
|
||||
icon={<CalendarPlus size={16} />}
|
||||
label={t("Create event…")}
|
||||
onClick={() => { const e = emails[Object.keys(selected)[0] as Id]; if (e) void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message)); }}
|
||||
/>
|
||||
)}
|
||||
<MenuSep />
|
||||
<MenuItem icon={<CheckSquare size={16} />} label={t("Select all")} onClick={selectAll} />
|
||||
<MenuItem icon={<X size={16} />} label={t("Clear selection")} onClick={clearSelection} />
|
||||
@@ -472,6 +491,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
<MenuItem icon={<Tag size={16} />} label={t("Label…")} kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<Filter size={16} />} label={t("Filter messages like this…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} />
|
||||
{hasCalendar && <MenuItem icon={<CalendarPlus size={16} />} label={t("Create event…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message)); }} />}
|
||||
</Popover>
|
||||
{filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, CalendarPlus, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { startAppointment } from "@/lib/appointment";
|
||||
import { client } from "@/jmap/client";
|
||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||
import { displayName, formatAddress } from "@/lib/address";
|
||||
@@ -52,6 +55,9 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
const showImages = useCallback(() => setAllowRemote(true), []);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const moreMenu = useMenu();
|
||||
const [, navigate] = useLocation();
|
||||
/** Only offered where there is a calendar to put the appointment in. */
|
||||
const hasCalendar = useCalendar((s) => s.available);
|
||||
const addrMenu = useAddressMenu();
|
||||
const from = e.from?.[0];
|
||||
const senderTrusted = settings.trustedImageSenders.includes((from?.email ?? "").toLowerCase());
|
||||
@@ -193,6 +199,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
<MenuItem icon={<Download size={16} />} label={translate("Download (.eml)")} onClick={downloadEml} />
|
||||
<MenuItem icon={<Printer size={16} />} label={translate("Print")} onClick={() => window.print()} />
|
||||
<MenuItem icon={<Filter size={16} />} label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} />
|
||||
{hasCalendar && <MenuItem icon={<CalendarPlus size={16} />} label={translate("Create event…")} onClick={() => void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message))} />}
|
||||
{from && (
|
||||
<>
|
||||
<MenuSep />
|
||||
|
||||
Reference in New Issue
Block a user