Invite the people the message was already between
Follow-up to #167: an event made from a mail now opens with the sender and everyone it was addressed to already in the guest list, so a thread becomes a meeting without retyping the room. Two things are deliberately left out. The reader's own addresses, since they are the organiser and an organiser among their own guests is an invitation to your own appointment. And a blind copy, on a message the reader sent: a guest list is visible to every guest, so promoting a Bcc to a guest would tell the room about a copy the sender chose to hide. That is not something a menu item may do quietly. *Send invitation emails to guests* now starts off when the guests were inherited rather than typed, and on everywhere else -- which is every event whose guests somebody chose one at a time. The reason is the case the issue opened with: a reminder made out of a bill carries the biller and everyone else on the mail. Left on, the primary button reads Send invites and the first press mails all of them an invitation to what was meant as a note to self. The switch sits right there under the list and says what it does, so inviting them is one deliberate click. Un-sending is not.
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