Merge pull request #168 from Coffey-Labs/feat/create-appointment-from-email

Make an event out of a message
This commit is contained in:
Coffey Labs
2026-08-31 22:57:04 -07:00
committed by GitHub
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
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…*.
+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",
"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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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": "タイトルを追加",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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": "Добавить название",
+1
View File
@@ -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": "Додати назву",
+1
View File
@@ -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": "添加标题",
+24
View File
@@ -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 });
},
}));
/**
+12
View File
@@ -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));
+8 -2
View File
@@ -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");
+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 { 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>
+8 -1
View File
@@ -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 />