Merge pull request #168 from Coffey-Labs/feat/create-appointment-from-email
Make an event out of a message
This commit is contained in:
+6
-2
@@ -140,7 +140,7 @@ more attempt. A drag that is merely more sideways than not stays a scroll.
|
|||||||
- **Drag and drop** onto any folder in the tree, moving the selection or the
|
- **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…*.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { appointmentDraft, nextHalfHour } from "@/lib/appointment";
|
||||||
|
import type { Email, EmailBodyPart } from "@/jmap/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reminder made out of a mail: the subject becomes the title and the body
|
||||||
|
* becomes the description, and the reader supplies the one thing the message
|
||||||
|
* cannot — when it happens. What these pin is that the copy is faithful and
|
||||||
|
* bounded, because everything else about the event is the editor's job.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function part(partId: string, type: string): EmailBodyPart {
|
||||||
|
return { partId, type } as EmailBodyPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function email(parts: Partial<Email>): Email {
|
||||||
|
return { id: "m1", subject: null, ...parts } as Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
function body(subject: string, type: "text/plain" | "text/html", value: string): Email {
|
||||||
|
const key = type === "text/plain" ? "textBody" : "htmlBody";
|
||||||
|
return email({ subject, [key]: [part("1", type)], bodyValues: { 1: { value, isEncodingProblem: false, isTruncated: false } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (value: string) => body("Water bill", "text/plain", value);
|
||||||
|
|
||||||
|
describe("the time an appointment starts", () => {
|
||||||
|
it("rounds up to the next half hour", () => {
|
||||||
|
expect(nextHalfHour(new Date("2026-08-31T09:12:40")).toTimeString().slice(0, 5)).toBe("09:30");
|
||||||
|
expect(nextHalfHour(new Date("2026-08-31T09:41:00")).toTimeString().slice(0, 5)).toBe("10:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves on from a time already on the boundary, rather than starting now", () => {
|
||||||
|
expect(nextHalfHour(new Date("2026-08-31T09:30:00")).toTimeString().slice(0, 5)).toBe("10:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs for an hour", () => {
|
||||||
|
const d = appointmentDraft(text("anything"), new Date("2026-08-31T09:12:00"));
|
||||||
|
expect(d.end.getTime() - d.start.getTime()).toBe(3600_000);
|
||||||
|
expect(d.allDay).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what is copied from the message", () => {
|
||||||
|
it("takes the subject as the title and the body as the description", () => {
|
||||||
|
const d = appointmentDraft(text("Due on the 14th.\nAccount 4471.\n"));
|
||||||
|
expect(d.title).toBe("Water bill");
|
||||||
|
expect(d.description).toBe("Due on the 14th.\nAccount 4471.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads an HTML-only message as text, so the description is not markup", () => {
|
||||||
|
const d = appointmentDraft(body("Renewal", "text/html", "<p>Renews <b>Friday</b></p>"));
|
||||||
|
expect(d.description).toBe("Renews Friday");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the title empty when there is no subject, for the editor to prompt for", () => {
|
||||||
|
expect(appointmentDraft(email({ subject: null })).title).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A newsletter is a message too. The whole body would be stored on the
|
||||||
|
* event, synced everywhere, and shown in a three-row box, so the tail is
|
||||||
|
* dropped — visibly, so a truncated bill is not read as the whole of it.
|
||||||
|
*/
|
||||||
|
it("truncates a body too long to be a description", () => {
|
||||||
|
const d = appointmentDraft(text("x".repeat(9000)));
|
||||||
|
expect(d.description).toHaveLength(5001);
|
||||||
|
expect(d.description.endsWith("…")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Email } from "@/jmap/types";
|
||||||
|
import { useCalendar, type EventDraft } from "@/store/calendar";
|
||||||
|
import { useMail } from "@/store/mail";
|
||||||
|
import { toLocalDateOnly } from "./dates";
|
||||||
|
import { htmlToText } from "./text";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of a message body is copied into an event description.
|
||||||
|
*
|
||||||
|
* The reader is making a reminder out of a mail, and a newsletter is a mail
|
||||||
|
* too: whole bodies run to hundreds of kilobytes, which would be stored on the
|
||||||
|
* event, synced to every device, and shown in a three-row textarea. What is
|
||||||
|
* worth keeping is near the top -- the amount owed, the date, the address --
|
||||||
|
* so the tail is what gets dropped, and visibly, so nobody reads a truncated
|
||||||
|
* bill as the whole of it.
|
||||||
|
*/
|
||||||
|
const MAX_DESCRIPTION = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next half-hour, which is when an appointment made now can start.
|
||||||
|
*
|
||||||
|
* Always forward, never the current instant: the reader still has a form to
|
||||||
|
* fill in, and a start time that is already in the past by the time they press
|
||||||
|
* Create is one they have to fix by hand.
|
||||||
|
*/
|
||||||
|
export function nextHalfHour(now: Date = new Date()): Date {
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setSeconds(0, 0);
|
||||||
|
d.setMinutes(d.getMinutes() + (30 - (d.getMinutes() % 30)));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The message's body as plain text, however it was sent. */
|
||||||
|
function bodyText(email: Email): string {
|
||||||
|
const textPart = email.textBody?.[0];
|
||||||
|
const text = textPart?.partId ? (email.bodyValues?.[textPart.partId]?.value ?? "") : "";
|
||||||
|
if (text.trim()) return text;
|
||||||
|
const htmlPart = email.htmlBody?.[0];
|
||||||
|
const html = htmlPart?.partId ? (email.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
|
||||||
|
return html ? htmlToText(html) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event seeded from a message: its subject, its body, and a time to fix.
|
||||||
|
*
|
||||||
|
* Deliberately nothing clever. The date is the one thing the message cannot
|
||||||
|
* supply -- "the 14th" in a bill is not a due date the parser could trust --
|
||||||
|
* so the editor opens with the reader's cursor on a form they finish, rather
|
||||||
|
* than a guess they have to check.
|
||||||
|
*/
|
||||||
|
export function appointmentDraft(email: Email, now: Date = new Date()): EventDraft {
|
||||||
|
const start = nextHalfHour(now);
|
||||||
|
const body = bodyText(email).trim();
|
||||||
|
return {
|
||||||
|
title: email.subject?.trim() ?? "",
|
||||||
|
description: body.length > MAX_DESCRIPTION ? `${body.slice(0, MAX_DESCRIPTION).trimEnd()}…` : body,
|
||||||
|
start,
|
||||||
|
end: new Date(start.getTime() + 3600_000),
|
||||||
|
allDay: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the calendar's event editor on a draft made from this message.
|
||||||
|
*
|
||||||
|
* The list holds a message without its body -- only a preview -- so the full
|
||||||
|
* one is fetched first; `getEmails` serves it from the cache when the message
|
||||||
|
* has already been read.
|
||||||
|
*/
|
||||||
|
export async function startAppointment(email: Email, navigate: (to: string) => void): Promise<void> {
|
||||||
|
const full = (await useMail.getState().getEmails([email.id], true))[0] ?? email;
|
||||||
|
const draft = appointmentDraft(full);
|
||||||
|
useCalendar.getState().setDraft(draft);
|
||||||
|
navigate(`/calendar/day/${toLocalDateOnly(draft.start)}`);
|
||||||
|
}
|
||||||
@@ -248,6 +248,7 @@ export const catalog: Catalog = {
|
|||||||
"Calendar is not available": "Kalender ist nicht verfügbar",
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "タイトルを追加",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Добавить название",
|
||||||
|
|||||||
@@ -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": "Додати назву",
|
||||||
|
|||||||
@@ -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": "添加标题",
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user