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 && ( <>