Files
inbuxa-webmail/web/src/lib/calendar/appointment.ts
T
jcoffey-dev bd6a605d61 Group six more clusters out of web/src/lib
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.
2026-09-15 23:17:50 -07:00

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)}`);
}