Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.
lib/mailbox/ archiveDate, emptyFolder, folderMove, labelTree,
mailboxName, mailboxRoute
lib/sieve/ sieve, sieveApply, sieveFolders
lib/input/ keyboard, swipe, touch, listSelection, dropUpload
lib/notify/ notify, webpush, webpushEnable
lib/sw/ swCache, swFacts, staleBuild
lib/text/ html, markdown, text, emlName
FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:
- appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
*Files*, where the client keeps signature images and synced settings.
It stays flat.
- format holds no formatting of text. It re-exports the date and clock
formatters, so it belongs with dates/datetime, not with text/.
- preview is the file viewer deciding what it can show without
downloading, and source is where to point someone asking for this
instance's AGPL source. Neither is about text.
- notify is not Web Push. It is the tab title, the favicon badge and
the new-mail sound -- in-app notification, which is why it sits with
webpush rather than under sw/ with the service worker's own concerns.
threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.
No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
98 lines
4.2 KiB
TypeScript
98 lines
4.2 KiB
TypeScript
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/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.
|
|
*/
|
|
/**
|
|
* 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 organizer, and an
|
|
* organizer 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 {
|
|
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,
|
|
attendees: guests(email, ownEmails),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 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)}`);
|
|
}
|