From 7ba749148fccdfb32565bba70c446ffa9be065de Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 31 Aug 2026 22:52:59 -0700 Subject: [PATCH] Make an event out of a message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- FEATURES.md | 8 ++- web/src/lib/__tests__/appointment.test.ts | 70 +++++++++++++++++++++ web/src/lib/appointment.ts | 75 +++++++++++++++++++++++ web/src/locales/de.ts | 1 + web/src/locales/es.ts | 1 + web/src/locales/fr.ts | 1 + web/src/locales/ja.ts | 1 + web/src/locales/nl.ts | 1 + web/src/locales/pt-BR.ts | 1 + web/src/locales/ru.ts | 1 + web/src/locales/uk.ts | 1 + web/src/locales/zh-Hans.ts | 1 + web/src/store/calendar.ts | 24 ++++++++ web/src/views/calendar/CalendarView.tsx | 12 ++++ web/src/views/calendar/EventEditor.tsx | 10 ++- web/src/views/mail/MessageList.tsx | 22 ++++++- web/src/views/mail/MessageView.tsx | 9 ++- 17 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/__tests__/appointment.test.ts create mode 100644 web/src/lib/appointment.ts diff --git a/FEATURES.md b/FEATURES.md index ce5c190..3deaaaa 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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…*. diff --git a/web/src/lib/__tests__/appointment.test.ts b/web/src/lib/__tests__/appointment.test.ts new file mode 100644 index 0000000..9cf1ebf --- /dev/null +++ b/web/src/lib/__tests__/appointment.test.ts @@ -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 { + 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", "

Renews Friday

")); + 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); + }); +}); diff --git a/web/src/lib/appointment.ts b/web/src/lib/appointment.ts new file mode 100644 index 0000000..47a53ff --- /dev/null +++ b/web/src/lib/appointment.ts @@ -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 { + 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)}`); +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index e38ec4a..7f1a068 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -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", diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index e9603f4..af3f0bd 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -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", diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 4dda9f8..16118e8 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -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", diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 9ff773e..d21bf1c 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -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": "タイトルを追加", diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index c292297..202ebac 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -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", diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 657ee93..47b2358 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -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", diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index c344a71..602cfef 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -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": "Добавить название", diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 87a72bc..757fc4f 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -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": "Додати назву", diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index ff87e11..164fba6 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -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": "添加标题", diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index bb0a507..32738d4 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -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; + /** Waiting to be opened in the editor; see `EventDraft`. */ + draft: EventDraft | null; init(): Promise; loadCalendars(): Promise; @@ -267,6 +285,7 @@ interface CalendarState { importEvent(event: Partial, calendarId: Id): Promise; applyChanges(types: Set): void; invalidate(): void; + setDraft(draft: EventDraft | null): void; } /** @@ -295,6 +314,7 @@ export const useCalendar = create((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((set, get) => ({ void get().loadRange(new Date(s), new Date(e), true); } }, + + setDraft(draft) { + set({ draft }); + }, })); /** diff --git a/web/src/views/calendar/CalendarView.tsx b/web/src/views/calendar/CalendarView.tsx index 50ffef3..4cdd49c 100644 --- a/web/src/views/calendar/CalendarView.tsx +++ b/web/src/views/calendar/CalendarView.tsx @@ -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)); diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index e846b37..bb19e32 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -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>(ev?.status ?? "confirmed"); const [privacy, setPrivacy] = useState>(ev?.privacy ?? "public"); const [freeBusy, setFreeBusy] = useState>(ev?.freeBusyStatus ?? "busy"); diff --git a/web/src/views/mail/MessageList.tsx b/web/src/views/mail/MessageList.tsx index 8d3eff6..6725b89 100644 --- a/web/src/views/mail/MessageList.tsx +++ b/web/src/views/mail/MessageList.tsx @@ -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(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(null); @@ -277,6 +282,20 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on /> } label={t("Mark as unread")} onClick={() => void actions.read(false)} /> } 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 && ( + } + 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)); }} + /> + )} } label={t("Select all")} onClick={selectAll} /> } label={t("Clear selection")} onClick={clearSelection} /> @@ -472,6 +491,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on } label={t("Label…")} kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} /> } label={t("Filter messages like this…")} onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} /> + {hasCalendar && } 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)); }} />} {filterFrom && setFilterFrom(null)} />} diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index 8b968fe..a3bfccd 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -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 } label={translate("Download (.eml)")} onClick={downloadEml} /> } label={translate("Print")} onClick={() => window.print()} /> } label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} /> + {hasCalendar && } label={translate("Create event…")} onClick={() => void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message))} />} {from && ( <>