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:
+6
-2
@@ -400,8 +400,12 @@ status (confirmed / tentative / cancelled), show-as (busy / free), visibility
|
||||
- **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.
|
||||
body the description; the sender and everyone the message was addressed to
|
||||
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
|
||||
*Edit…*.
|
||||
|
||||
|
||||
@@ -68,3 +68,42 @@ describe("what is copied from the message", () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { useMail } from "@/store/mail";
|
||||
import { uniqueAddresses } from "./address";
|
||||
import { toLocalDateOnly } from "./dates";
|
||||
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
|
||||
* 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 body = bodyText(email).trim();
|
||||
return {
|
||||
@@ -57,6 +74,7 @@ export function appointmentDraft(email: Email, now: Date = new Date()): EventDra
|
||||
start,
|
||||
end: new Date(start.getTime() + 3600_000),
|
||||
allDay: false,
|
||||
attendees: guests(email, ownEmails),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,8 +86,12 @@ export function appointmentDraft(email: Email, now: Date = new Date()): EventDra
|
||||
* 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);
|
||||
const mail = useMail.getState();
|
||||
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);
|
||||
navigate(`/calendar/day/${toLocalDateOnly(draft.start)}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
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 { settings, useSettings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
@@ -238,6 +238,7 @@ export interface EventDraft {
|
||||
start: Date;
|
||||
end: Date;
|
||||
allDay: boolean;
|
||||
attendees: EmailAddress[];
|
||||
}
|
||||
|
||||
interface CalendarState {
|
||||
|
||||
@@ -77,8 +77,8 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!cal.draft) return;
|
||||
const { title, description, ...when } = cal.draft;
|
||||
setEditor({ ...when, seed: { title, description } });
|
||||
const { title, description, attendees, ...when } = cal.draft;
|
||||
setEditor({ ...when, seed: { title, description, attendees } });
|
||||
cal.setDraft(null);
|
||||
}, [cal.draft]);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface EditorInit {
|
||||
* 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 };
|
||||
seed?: { title?: string; description?: string; attendees?: EmailAddress[] };
|
||||
}
|
||||
|
||||
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 [attendees, setAttendees] = useState<EmailAddress[]>(() =>
|
||||
Object.entries(ev?.participants ?? {})
|
||||
ev
|
||||
? Object.entries(ev.participants ?? {})
|
||||
.filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee))
|
||||
.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) }))
|
||||
.filter((a) => a.email),
|
||||
.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 [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)));
|
||||
|
||||
Reference in New Issue
Block a user