Merge pull request #169 from Coffey-Labs/feat/event-guests-from-message

Invite the people the message was already between
This commit is contained in:
Coffey Labs
2026-08-31 23:09:38 -07:00
committed by GitHub
6 changed files with 94 additions and 15 deletions
+6 -2
View File
@@ -400,8 +400,12 @@ status (confirmed / tentative / cancelled), show-as (busy / free), visibility
- **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, - **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 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, body the description; the sender and everyone the message was addressed to
because when it happens is the one thing the message cannot say. become guests, minus your own addresses and never a blind copy. The editor
opens on the next half hour for an hour, because when it happens is the one
thing the message cannot say — and with *Send invitation emails* off, since
a guest list you inherited rather than typed should not mail itself on the
first press.
- **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…*.
+39
View File
@@ -68,3 +68,42 @@ describe("what is copied from the message", () => {
expect(d.description.endsWith("…")).toBe(true); expect(d.description.endsWith("…")).toBe(true);
}); });
}); });
const between = (parts: Partial<Email>) => email({ subject: "Kickoff", ...parts });
const addr = (email: string, name: string | null = null) => ({ name, email });
describe("who is invited", () => {
it("carries the sender and everyone it was addressed to", () => {
const d = appointmentDraft(
between({ from: [addr("[email protected]", "Grace")], to: [addr("[email protected]"), addr("[email protected]")], cc: [addr("[email protected]")] }),
new Date(),
["[email protected]"],
);
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]", "[email protected]", "[email protected]"]);
expect(d.attendees[0]?.name).toBe("Grace");
});
it("leaves the reader out, whatever case their address was written in", () => {
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")] }), new Date(), ["[email protected]"]);
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]"]);
});
it("counts someone once, however many headers they appear in", () => {
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")], cc: [addr("[email protected]")] }));
expect(d.attendees).toHaveLength(1);
});
/*
* On a message the reader sent, a blind copy is still a recipient — and
* putting one on a guest list shows them to every other guest. Turning a
* hidden copy into a visible one is not something a menu item may do.
*/
it("never turns a blind copy into a guest", () => {
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")], bcc: [addr("[email protected]")] }), new Date(), ["[email protected]"]);
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]"]);
});
it("invites nobody when the message has no addresses at all", () => {
expect(appointmentDraft(between({})).attendees).toEqual([]);
});
});
+26 -4
View File
@@ -1,6 +1,7 @@
import type { Email } from "@/jmap/types"; import type { Email, EmailAddress } from "@/jmap/types";
import { useCalendar, type EventDraft } from "@/store/calendar"; import { useCalendar, type EventDraft } from "@/store/calendar";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { uniqueAddresses } from "./address";
import { toLocalDateOnly } from "./dates"; import { toLocalDateOnly } from "./dates";
import { htmlToText } from "./text"; import { htmlToText } from "./text";
@@ -48,7 +49,23 @@ function bodyText(email: Email): string {
* so the editor opens with the reader's cursor on a form they finish, rather * so the editor opens with the reader's cursor on a form they finish, rather
* than a guess they have to check. * than a guess they have to check.
*/ */
export function appointmentDraft(email: Email, now: Date = new Date()): EventDraft { /**
* Everyone the message was between, as guests: the sender and the people it
* was addressed to.
*
* The reader's own addresses come out -- they are the organiser, and an
* organiser listed among their own guests is an event that invites you to your
* own appointment. Bcc stays out too, on a message the reader sent themselves:
* a blind recipient added to a guest list is visible to every other guest, and
* turning a hidden copy into a public one is not something a menu item should
* do quietly.
*/
function guests(email: Email, ownEmails: string[]): EmailAddress[] {
const own = new Set(ownEmails.map((e) => e.toLowerCase()));
return uniqueAddresses([...(email.from ?? []), ...(email.to ?? []), ...(email.cc ?? [])]).filter((a) => !own.has(a.email.trim().toLowerCase()));
}
export function appointmentDraft(email: Email, now: Date = new Date(), ownEmails: string[] = []): EventDraft {
const start = nextHalfHour(now); const start = nextHalfHour(now);
const body = bodyText(email).trim(); const body = bodyText(email).trim();
return { return {
@@ -57,6 +74,7 @@ export function appointmentDraft(email: Email, now: Date = new Date()): EventDra
start, start,
end: new Date(start.getTime() + 3600_000), end: new Date(start.getTime() + 3600_000),
allDay: false, allDay: false,
attendees: guests(email, ownEmails),
}; };
} }
@@ -68,8 +86,12 @@ export function appointmentDraft(email: Email, now: Date = new Date()): EventDra
* has already been read. * has already been read.
*/ */
export async function startAppointment(email: Email, navigate: (to: string) => void): Promise<void> { export async function startAppointment(email: Email, navigate: (to: string) => void): Promise<void> {
const full = (await useMail.getState().getEmails([email.id], true))[0] ?? email; const mail = useMail.getState();
const draft = appointmentDraft(full); const full = (await mail.getEmails([email.id], true))[0] ?? email;
// Which addresses are the reader's own decides who is a guest, so they are
// worth a round trip when the session has not loaded them yet.
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
const draft = appointmentDraft(full, new Date(), identities.map((i) => i.email));
useCalendar.getState().setDraft(draft); useCalendar.getState().setDraft(draft);
navigate(`/calendar/day/${toLocalDateOnly(draft.start)}`); navigate(`/calendar/day/${toLocalDateOnly(draft.start)}`);
} }
+2 -1
View File
@@ -1,6 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { CAP, client, setErrorMessage } from "@/jmap/client"; import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types"; import type { BusyPeriod, Calendar, CalendarEvent, EmailAddress, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates"; import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
import { settings, useSettings } from "./settings"; import { settings, useSettings } from "./settings";
import { useSession } from "./session"; import { useSession } from "./session";
@@ -238,6 +238,7 @@ export interface EventDraft {
start: Date; start: Date;
end: Date; end: Date;
allDay: boolean; allDay: boolean;
attendees: EmailAddress[];
} }
interface CalendarState { interface CalendarState {
+2 -2
View File
@@ -77,8 +77,8 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
*/ */
useEffect(() => { useEffect(() => {
if (!cal.draft) return; if (!cal.draft) return;
const { title, description, ...when } = cal.draft; const { title, description, attendees, ...when } = cal.draft;
setEditor({ ...when, seed: { title, description } }); setEditor({ ...when, seed: { title, description, attendees } });
cal.setDraft(null); cal.setDraft(null);
}, [cal.draft]); }, [cal.draft]);
+19 -6
View File
@@ -27,7 +27,7 @@ export interface EditorInit {
* so far. Not an event: this is still a form the reader has to finish, so * 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. * `editing` stays false and the dialog says New event / Create.
*/ */
seed?: { title?: string; description?: string }; seed?: { title?: string; description?: string; attendees?: EmailAddress[] };
} }
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];
@@ -127,12 +127,25 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
}); });
const myKeys = ev ? myParticipantKeys(ev, cal.identities) : []; const myKeys = ev ? myParticipantKeys(ev, cal.identities) : [];
const [attendees, setAttendees] = useState<EmailAddress[]>(() => const [attendees, setAttendees] = useState<EmailAddress[]>(() =>
Object.entries(ev?.participants ?? {}) ev
.filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee)) ? Object.entries(ev.participants ?? {})
.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })) .filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee))
.filter((a) => a.email), .map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) }))
.filter((a) => a.email)
: (init.seed?.attendees ?? []),
); );
const [sendInvites, setSendInvites] = useState(true); /*
* Off when the guest list was not typed but inherited -- from a message, so
* far -- and on everywhere else, which is every event whose guests somebody
* chose one at a time.
*
* A reminder made out of a bill carries the biller and everyone else the
* mail went to. Left on, the primary button reads Send invites, and the
* first press mails all of them an invitation to the reader's private note
* to self. The switch is right there and says what it does, so inviting them
* is one deliberate click; un-sending is not.
*/
const [sendInvites, setSendInvites] = useState(!init.seed?.attendees?.length);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [fb, setFb] = useState<Record<string, BusyPeriod[]>>({}); const [fb, setFb] = useState<Record<string, BusyPeriod[]>>({});
const [showMore, setShowMore] = useState(Boolean(ev && (ev.privacy !== "public" || ev.freeBusyStatus === "free" || ev.color || ev.status !== "confirmed" || Object.keys(ev.categories ?? {}).length))); const [showMore, setShowMore] = useState(Boolean(ev && (ev.privacy !== "public" || ev.freeBusyStatus === "free" || ev.color || ev.status !== "confirmed" || Object.keys(ev.categories ?? {}).length)));