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.
|
- **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…*.
|
||||||
|
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
Reference in New Issue
Block a user