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:
2026-08-31 22:52:59 -07:00
parent 3517d48a90
commit 7ba749148f
17 changed files with 233 additions and 6 deletions
+6 -2
View File
@@ -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 - **Drag and drop** onto any folder in the tree, moving the selection or the
row under the cursor. row under the cursor.
- **Context menu** on any row: reply, forward, archive, delete, spam, read/unread, - **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. - **Snippets and avatars** are optional; the star is always in the row.
- **Skeleton rows** while a page loads, rather than an empty pane. - **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 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* / 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), The editor covers title, start and end (all-day or timed, with a time zone),
calendar, location, meeting link, guests, description, reminders, repeat, 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 picker that predated them is gone; a colour comes from the category, or the
calendar.) calendar.)
- **Duplicate** an event from the context menu. - **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 - **Popover** on click with the detail and quick actions; the editor on
*Edit…*. *Edit…*.
+70
View File
@@ -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);
});
});
+75
View File
@@ -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)}`);
}
+1
View File
@@ -248,6 +248,7 @@ export const catalog: Catalog = {
"Calendar is not available": "Kalender ist nicht verfügbar", "Calendar is not available": "Kalender ist nicht verfügbar",
"Event": "Termin", "Event": "Termin",
"New event": "Neuer Termin", "New event": "Neuer Termin",
"Create event…": "Termin erstellen…",
"(new event)": "(neuer Termin)", "(new event)": "(neuer Termin)",
"New all-day event": "Neuer ganztägiger Termin", "New all-day event": "Neuer ganztägiger Termin",
"Add title": "Titel hinzufügen", "Add title": "Titel hinzufügen",
+1
View File
@@ -240,6 +240,7 @@ export const catalog: Catalog = {
"Calendar is not available": "El calendario no está disponible", "Calendar is not available": "El calendario no está disponible",
"Event": "Evento", "Event": "Evento",
"New event": "Evento nuevo", "New event": "Evento nuevo",
"Create event…": "Crear evento…",
"(new event)": "(evento nuevo)", "(new event)": "(evento nuevo)",
"New all-day event": "Evento nuevo de todo el día", "New all-day event": "Evento nuevo de todo el día",
"Add title": "Añadir un título", "Add title": "Añadir un título",
+1
View File
@@ -245,6 +245,7 @@ export const catalog: Catalog = {
"Calendar is not available": "L'agenda n'est pas disponible", "Calendar is not available": "L'agenda n'est pas disponible",
"Event": "Événement", "Event": "Événement",
"New event": "Nouvel événement", "New event": "Nouvel événement",
"Create event…": "Créer un événement…",
"(new event)": "(nouvel événement)", "(new event)": "(nouvel événement)",
"New all-day event": "Nouvel événement sur la journée", "New all-day event": "Nouvel événement sur la journée",
"Add title": "Ajouter un titre", "Add title": "Ajouter un titre",
+1
View File
@@ -239,6 +239,7 @@ export const catalog: Catalog = {
"Calendar is not available": "カレンダーは利用できません", "Calendar is not available": "カレンダーは利用できません",
"Event": "予定", "Event": "予定",
"New event": "新しい予定", "New event": "新しい予定",
"Create event…": "予定を作成…",
"(new event)": "(新しい予定)", "(new event)": "(新しい予定)",
"New all-day event": "新しい終日の予定", "New all-day event": "新しい終日の予定",
"Add title": "タイトルを追加", "Add title": "タイトルを追加",
+1
View File
@@ -236,6 +236,7 @@ export const catalog: Catalog = {
"Calendar is not available": "Agenda is niet beschikbaar", "Calendar is not available": "Agenda is niet beschikbaar",
"Event": "Afspraak", "Event": "Afspraak",
"New event": "Nieuwe afspraak", "New event": "Nieuwe afspraak",
"Create event…": "Afspraak maken…",
"(new event)": "(nieuwe afspraak)", "(new event)": "(nieuwe afspraak)",
"New all-day event": "Nieuwe afspraak voor de hele dag", "New all-day event": "Nieuwe afspraak voor de hele dag",
"Add title": "Titel toevoegen", "Add title": "Titel toevoegen",
+1
View File
@@ -243,6 +243,7 @@ export const catalog: Catalog = {
"Calendar is not available": "A agenda não está disponível", "Calendar is not available": "A agenda não está disponível",
"Event": "Evento", "Event": "Evento",
"New event": "Novo evento", "New event": "Novo evento",
"Create event…": "Criar evento…",
"(new event)": "(novo evento)", "(new event)": "(novo evento)",
"New all-day event": "Novo evento de dia inteiro", "New all-day event": "Novo evento de dia inteiro",
"Add title": "Adicionar um título", "Add title": "Adicionar um título",
+1
View File
@@ -242,6 +242,7 @@ export const catalog: Catalog = {
"Calendar is not available": "Календарь недоступен", "Calendar is not available": "Календарь недоступен",
"Event": "Событие", "Event": "Событие",
"New event": "Новое событие", "New event": "Новое событие",
"Create event…": "Создать событие…",
"(new event)": "(новое событие)", "(new event)": "(новое событие)",
"New all-day event": "Новое событие на весь день", "New all-day event": "Новое событие на весь день",
"Add title": "Добавить название", "Add title": "Добавить название",
+1
View File
@@ -236,6 +236,7 @@ export const catalog: Catalog = {
"Calendar is not available": "Календар недоступний", "Calendar is not available": "Календар недоступний",
"Event": "Подія", "Event": "Подія",
"New event": "Нова подія", "New event": "Нова подія",
"Create event…": "Створити подію…",
"(new event)": "(нова подія)", "(new event)": "(нова подія)",
"New all-day event": "Нова подія на весь день", "New all-day event": "Нова подія на весь день",
"Add title": "Додати назву", "Add title": "Додати назву",
+1
View File
@@ -238,6 +238,7 @@ export const catalog: Catalog = {
"Calendar is not available": "日历不可用", "Calendar is not available": "日历不可用",
"Event": "日程", "Event": "日程",
"New event": "新建日程", "New event": "新建日程",
"Create event…": "创建日程…",
"(new event)": "(新建日程)", "(new event)": "(新建日程)",
"New all-day event": "新建全天日程", "New all-day event": "新建全天日程",
"Add title": "添加标题", "Add title": "添加标题",
+24
View File
@@ -224,6 +224,22 @@ export interface SharedCalendar {
/** Shared events are keyed by account too: ids only differ within an account. */ /** Shared events are keyed by account too: ids only differ within an account. */
export const sharedKey = (accountId: Id, id: Id): string => `${accountId}:${id}`; 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 { interface CalendarState {
accountId: Id | null; accountId: Id | null;
available: boolean; available: boolean;
@@ -241,6 +257,8 @@ interface CalendarState {
error: string | null; error: string | null;
identities: ParticipantIdentity[]; identities: ParticipantIdentity[];
hidden: Record<Id, true>; hidden: Record<Id, true>;
/** Waiting to be opened in the editor; see `EventDraft`. */
draft: EventDraft | null;
init(): Promise<void>; init(): Promise<void>;
loadCalendars(): Promise<void>; loadCalendars(): Promise<void>;
@@ -267,6 +285,7 @@ interface CalendarState {
importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>; importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>;
applyChanges(types: Set<string>): void; applyChanges(types: Set<string>): void;
invalidate(): void; invalidate(): void;
setDraft(draft: EventDraft | null): void;
} }
/** /**
@@ -295,6 +314,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
error: null, error: null,
identities: [], identities: [],
hidden: {}, hidden: {},
draft: null,
async init() { async init() {
// The reader's own: a shared calendar is shown beside theirs, not instead. // 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); void get().loadRange(new Date(s), new Date(e), true);
} }
}, },
setDraft(draft) {
set({ draft });
},
})); }));
/** /**
+12
View File
@@ -70,6 +70,18 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [cal.available, range.start.getTime(), range.end.getTime()]); }, [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 go = useCallback((v: View, d: Date) => navigate(`/calendar/${v}/${toLocalDateOnly(d)}`), [navigate]);
const step = (n: number) => { const step = (n: number) => {
if (effectiveView === "month") go(view, addMonths(anchor, n)); if (effectiveView === "month") go(view, addMonths(anchor, n));
+8 -2
View File
@@ -22,6 +22,12 @@ export interface EditorInit {
start: Date; start: Date;
end: Date; end: Date;
allDay: boolean; 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]; 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 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 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 [calendarId, setCalendarId] = useState(initialCal ?? "");
const [allDay, setAllDay] = useState(ev ? Boolean(ev.showWithoutTime) : init.allDay); const [allDay, setAllDay] = useState(ev ? Boolean(ev.showWithoutTime) : init.allDay);
const [start, setStart] = useState(baseStart); const [start, setStart] = useState(baseStart);
@@ -105,7 +111,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
const [tz, setTz] = useState(evTz); const [tz, setTz] = useState(evTz);
const [location, setLocation] = useState(Object.values(ev?.locations ?? {})[0]?.name ?? ""); const [location, setLocation] = useState(Object.values(ev?.locations ?? {})[0]?.name ?? "");
const [vurl, setVurl] = useState(Object.values(ev?.virtualLocations ?? {})[0]?.uri ?? ""); 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 [status, setStatus] = useState<NonNullable<CalendarEvent["status"]>>(ev?.status ?? "confirmed");
const [privacy, setPrivacy] = useState<NonNullable<CalendarEvent["privacy"]>>(ev?.privacy ?? "public"); const [privacy, setPrivacy] = useState<NonNullable<CalendarEvent["privacy"]>>(ev?.privacy ?? "public");
const [freeBusy, setFreeBusy] = useState<NonNullable<CalendarEvent["freeBusyStatus"]>>(ev?.freeBusyStatus ?? "busy"); const [freeBusy, setFreeBusy] = useState<NonNullable<CalendarEvent["freeBusyStatus"]>>(ev?.freeBusyStatus ?? "busy");
+21 -1
View File
@@ -1,6 +1,6 @@
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react"; import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; 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 { useLocation } from "wouter";
import { useMail, type ListState } from "@/store/mail"; import { useMail, type ListState } from "@/store/mail";
import { dateTimeKey, useSettings } from "@/store/settings"; 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 { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { useCompose } from "@/store/compose"; 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 { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/touch";
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/swipe"; import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/swipe";
import { FilterFromMessageDialog } from "./FilterFromMessage"; 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 twoLine = isMobile || (paneWidth > 0 && paneWidth < 640);
const ctxMenu = useMenu(); const ctxMenu = useMenu();
const [ctxRow, setCtxRow] = useState<Id | null>(null); 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 moreMenu = useMenu();
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [filterFrom, setFilterFrom] = useState<Email | null>(null); 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={<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 })} /> <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 /> <MenuSep />
<MenuItem icon={<CheckSquare size={16} />} label={t("Select all")} onClick={selectAll} /> <MenuItem icon={<CheckSquare size={16} />} label={t("Select all")} onClick={selectAll} />
<MenuItem icon={<X size={16} />} label={t("Clear selection")} onClick={clearSelection} /> <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 })} /> <MenuItem icon={<Tag size={16} />} label={t("Label…")} kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
<MenuSep /> <MenuSep />
<MenuItem icon={<Filter size={16} />} label={t("Filter messages like this…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} /> <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> </Popover>
{filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />} {filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />}
</div> </div>
+8 -1
View File
@@ -1,11 +1,14 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { FilterFromMessageDialog } from "./FilterFromMessage";
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types"; import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import { draftFromMailto, useCompose } from "@/store/compose"; import { draftFromMailto, useCompose } from "@/store/compose";
import { useContacts } from "@/store/contacts"; import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar";
import { startAppointment } from "@/lib/appointment";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format"; import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, formatAddress } from "@/lib/address"; 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 showImages = useCallback(() => setAllowRemote(true), []);
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const moreMenu = useMenu(); 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 addrMenu = useAddressMenu();
const from = e.from?.[0]; const from = e.from?.[0];
const senderTrusted = settings.trustedImageSenders.includes((from?.email ?? "").toLowerCase()); 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={<Download size={16} />} label={translate("Download (.eml)")} onClick={downloadEml} />
<MenuItem icon={<Printer size={16} />} label={translate("Print")} onClick={() => window.print()} /> <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)} /> <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 && ( {from && (
<> <>
<MenuSep /> <MenuSep />