@rapidmx/web-client 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/apps/admin/mailboxes/[uid].tsx +2 -1
  2. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
  3. package/apps/shared/components/admin/setup/SetupWizard.tsx +1 -1
  4. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
  5. package/apps/shared/components/calendar/EventDescriptionView.tsx +37 -5
  6. package/apps/shared/components/calendar/EventDetails.tsx +15 -6
  7. package/apps/shared/components/calendar/EventEditor.tsx +5 -2
  8. package/apps/shared/components/calendar/EventFormParts.tsx +45 -39
  9. package/apps/shared/components/calendar/EventQuickForm.tsx +3 -2
  10. package/apps/shared/components/calendar/FindATime.tsx +3 -2
  11. package/apps/shared/components/calendar/TimeComboBox.tsx +143 -0
  12. package/apps/shared/components/calendar/TimeGridView.tsx +3 -1
  13. package/apps/shared/components/calendar/eventFormat.ts +15 -0
  14. package/apps/shared/components/calendar/timePicker.ts +106 -0
  15. package/apps/shared/components/mail/ConversationList.tsx +18 -5
  16. package/apps/shared/components/mail/ConversationThreadPane.tsx +7 -2
  17. package/apps/shared/components/mail/MessageDetailPane.tsx +37 -18
  18. package/apps/shared/components/mail/invite/InviteRowChip.tsx +8 -12
  19. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +2 -2
  20. package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
  21. package/apps/shared/components/mail/reading/conversationPrint.ts +76 -0
  22. package/apps/shared/components/mail/reading/printMessage.ts +44 -7
  23. package/apps/shared/keyboard/keymap.ts +1 -0
  24. package/apps/shared/mail/decryptedMessages.ts +116 -0
  25. package/apps/www/calendar/index.tsx +5 -2
  26. package/apps/www/index.tsx +11 -0
  27. package/apps/www/settings/profile/index.tsx +8 -12
  28. package/dist/apps/admin/mailboxes/[uid].js +2 -1
  29. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +2 -2
  30. package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +4 -4
  31. package/dist/apps/shared/components/admin/setup/SetupWizard.js +1 -1
  32. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
  33. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +6 -1
  34. package/dist/apps/shared/components/calendar/EventDescriptionView.js +34 -4
  35. package/dist/apps/shared/components/calendar/EventDetails.js +4 -4
  36. package/dist/apps/shared/components/calendar/EventEditor.js +5 -2
  37. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
  38. package/dist/apps/shared/components/calendar/EventFormParts.js +9 -3
  39. package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -2
  40. package/dist/apps/shared/components/calendar/FindATime.js +3 -2
  41. package/dist/apps/shared/components/calendar/TimeComboBox.d.ts +25 -0
  42. package/dist/apps/shared/components/calendar/TimeComboBox.js +77 -0
  43. package/dist/apps/shared/components/calendar/TimeGridView.js +1 -1
  44. package/dist/apps/shared/components/calendar/eventFormat.d.ts +4 -0
  45. package/dist/apps/shared/components/calendar/eventFormat.js +13 -0
  46. package/dist/apps/shared/components/calendar/timePicker.d.ts +31 -0
  47. package/dist/apps/shared/components/calendar/timePicker.js +89 -0
  48. package/dist/apps/shared/components/mail/ConversationList.js +17 -4
  49. package/dist/apps/shared/components/mail/ConversationThreadPane.js +6 -2
  50. package/dist/apps/shared/components/mail/MessageDetailPane.js +31 -18
  51. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +2 -2
  52. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +7 -9
  53. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +1 -1
  54. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +2 -2
  55. package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
  56. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +15 -0
  57. package/dist/apps/shared/components/mail/reading/conversationPrint.js +69 -0
  58. package/dist/apps/shared/components/mail/reading/printMessage.d.ts +7 -1
  59. package/dist/apps/shared/components/mail/reading/printMessage.js +33 -6
  60. package/dist/apps/shared/keyboard/keymap.d.ts +6 -0
  61. package/dist/apps/shared/keyboard/keymap.js +1 -0
  62. package/dist/apps/shared/mail/decryptedMessages.d.ts +27 -0
  63. package/dist/apps/shared/mail/decryptedMessages.js +97 -0
  64. package/dist/apps/www/calendar/index.js +5 -2
  65. package/dist/apps/www/index.js +10 -0
  66. package/dist/apps/www/settings/profile/index.js +6 -5
  67. package/dist/lib/auth/session.d.ts +14 -1
  68. package/dist/lib/auth/session.js +40 -3
  69. package/dist/lib/components/pickers/TimeZonePicker.d.ts +20 -0
  70. package/dist/lib/components/pickers/TimeZonePicker.js +100 -0
  71. package/dist/lib/util/api.d.ts +6 -0
  72. package/dist/lib/util/api.js +22 -1
  73. package/dist/lib/util/timeZone.d.ts +19 -1
  74. package/dist/lib/util/timeZone.js +46 -2
  75. package/lib/auth/session.ts +48 -3
  76. package/lib/components/pickers/TimeZonePicker.tsx +180 -0
  77. package/lib/util/api.ts +24 -1
  78. package/lib/util/timeZone.ts +50 -2
  79. package/package.json +1 -1
@@ -9,7 +9,8 @@ import { FREE_BUSY_MAX_ADDRESSES, availabilityDuring, availabilityOf, getFreeBus
9
9
  import Button from "../../../../lib/components/buttons/Button.js";
10
10
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
11
11
  import { addDaysToKey } from "./allDay.js";
12
- import { formatFormWhen, localDateFromKey, msToWallString, wallStringToMs } from "./eventFormat.js";
12
+ import { formatFormWhen, localDateFromKey, msToWallString, wallStringToMs, zoneDate } from "./eventFormat.js";
13
+ import { describeTimeZone } from "../../../../lib/util/timeZone.js";
13
14
  import { SEARCH_DAYS, WORK_END_HOUR, WORK_START_HOUR, clockLabel, dayStartMs, freeBusyErrorMessage, isDayKey, minuteOfDay, summaryLine, workingWindows, } from "./findTimeGrid.js";
14
15
  /** How long the grid waits after the last change of day or guests before asking the server, so typing a list of guests is one look-up. */
15
16
  export const FIND_TIME_DEBOUNCE_MS = 300;
@@ -167,7 +168,7 @@ export default function FindATime({ c }) {
167
168
  .map((block) => `${block.tentative ? "Tentatively busy" : "Busy"} ${clockLabel(block.startMs, formZone, deviceZone)} to ${clockLabel(block.endMs, formZone, deviceZone)}`)
168
169
  .join(", ");
169
170
  }
170
- return (_jsxs("div", { className: "flex flex-col gap-3 min-w-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("button", { type: "button", "aria-label": "Previous day", onClick: () => setDayKey((key) => addDaysToKey(key, -1)), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: "\u2039" }), _jsx("span", { className: "text-sm font-medium min-w-[9rem] text-center", children: dayLabel }), _jsx("button", { type: "button", "aria-label": "Next day", onClick: () => setDayKey((key) => addDaysToKey(key, 1)), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: "\u203A" }), _jsx("span", { className: "text-xs text-text-muted", children: formZone.replace(/_/g, " ") })] }), lookUp.status === "error" && (_jsxs("div", { role: "alert", className: "flex flex-wrap items-center gap-3 text-sm text-danger", children: [_jsx("span", { children: lookUp.message }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setRetry((count) => count + 1), children: "Try again" })] })), _jsx("div", { ref: scroller, role: "group", "aria-label": "Guests' availability", "aria-busy": lookUp.status === "loading", className: "overflow-x-auto border border-border rounded-md", children: _jsxs("div", { className: "relative", style: { width: NAME_WIDTH + TIMELINE_WIDTH }, children: [_jsxs("div", { className: "flex bg-surface border-b border-border", style: { height: HEADER_HEIGHT }, children: [_jsx("div", { className: "sticky left-0 z-20 shrink-0 bg-surface border-r border-border", style: { width: NAME_WIDTH } }), HOURS.map((hour) => (_jsx("button", { type: "button", "aria-label": `Start at ${hourLabel(hour)}`, title: `Start at ${hourLabel(hour)}`, onClick: () => startAt(hour * 60), className: "shrink-0 text-[10px] text-text-muted hover:bg-surface-alt hover:text-text text-left pl-1", style: { width: HOUR_WIDTH }, children: hourLabel(hour) }, hour)))] }), rows.map((row) => {
171
+ return (_jsxs("div", { className: "flex flex-col gap-3 min-w-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("button", { type: "button", "aria-label": "Previous day", onClick: () => setDayKey((key) => addDaysToKey(key, -1)), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: "\u2039" }), _jsx("span", { className: "text-sm font-medium min-w-[9rem] text-center", children: dayLabel }), _jsx("button", { type: "button", "aria-label": "Next day", onClick: () => setDayKey((key) => addDaysToKey(key, 1)), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: "\u203A" }), _jsx("span", { className: "text-xs text-text-muted", children: describeTimeZone(formZone, zoneDate(dayKey)) })] }), lookUp.status === "error" && (_jsxs("div", { role: "alert", className: "flex flex-wrap items-center gap-3 text-sm text-danger", children: [_jsx("span", { children: lookUp.message }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setRetry((count) => count + 1), children: "Try again" })] })), _jsx("div", { ref: scroller, role: "group", "aria-label": "Guests' availability", "aria-busy": lookUp.status === "loading", className: "overflow-x-auto border border-border rounded-md", children: _jsxs("div", { className: "relative", style: { width: NAME_WIDTH + TIMELINE_WIDTH }, children: [_jsxs("div", { className: "flex bg-surface border-b border-border", style: { height: HEADER_HEIGHT }, children: [_jsx("div", { className: "sticky left-0 z-20 shrink-0 bg-surface border-r border-border", style: { width: NAME_WIDTH } }), HOURS.map((hour) => (_jsx("button", { type: "button", "aria-label": `Start at ${hourLabel(hour)}`, title: `Start at ${hourLabel(hour)}`, onClick: () => startAt(hour * 60), className: "shrink-0 text-[10px] text-text-muted hover:bg-surface-alt hover:text-text text-left pl-1", style: { width: HOUR_WIDTH }, children: hourLabel(hour) }, hour)))] }), rows.map((row) => {
171
172
  const person = byAddress.get(row.address.toLowerCase());
172
173
  const state = person && timeIsValid && covered ? availabilityDuring(person, eventStart, eventEnd) : undefined;
173
174
  const hidden = person && person.status !== "available" ? person : undefined;
@@ -0,0 +1,25 @@
1
+ import React from "react";
2
+ export interface TimeOption {
3
+ value: string;
4
+ label: string;
5
+ /** Said after the label, muted: the length of the event an end time makes. */
6
+ hint?: string;
7
+ }
8
+ export interface TimeComboBoxProps {
9
+ "aria-label": string;
10
+ /** The time being shown, as it reads in the field. */
11
+ display: string;
12
+ /** The `value` of the option that is the current one, if any of them is. */
13
+ current?: string;
14
+ options: TimeOption[];
15
+ /** An option was picked. */
16
+ onSelect: (value: string) => void;
17
+ /** A time was typed (`HH:mm`, already understood). */
18
+ onType: (time: string) => void;
19
+ className?: string;
20
+ }
21
+ /**
22
+ * A time field that is a text box and a menu at once: focusing it opens a list of times to pick from, and whatever is typed instead - "3:45pm", "1530" -
23
+ * is understood when the field is left or Enter is pressed (and put back as it was when it isn't a time, or on Escape). The list follows the arrow keys.
24
+ */
25
+ export default function TimeComboBox({ "aria-label": label, display, current, options, onSelect, onType, className }: TimeComboBoxProps): React.JSX.Element;
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useId, useRef, useState } from "react";
7
+ import { parseTimeInput } from "./timePicker.js";
8
+ /**
9
+ * A time field that is a text box and a menu at once: focusing it opens a list of times to pick from, and whatever is typed instead - "3:45pm", "1530" -
10
+ * is understood when the field is left or Enter is pressed (and put back as it was when it isn't a time, or on Escape). The list follows the arrow keys.
11
+ */
12
+ export default function TimeComboBox({ "aria-label": label, display, current, options, onSelect, onType, className }) {
13
+ const listId = useId();
14
+ const [draft, setDraft] = useState(null);
15
+ const [open, setOpen] = useState(false);
16
+ const [active, setActive] = useState(-1);
17
+ const selectedRef = useRef(null);
18
+ // Opening puts the current time in view, as the middle of the list.
19
+ useEffect(() => {
20
+ if (open) {
21
+ selectedRef.current?.scrollIntoView?.({ block: "center" });
22
+ }
23
+ }, [open]);
24
+ function close() {
25
+ setOpen(false);
26
+ setActive(-1);
27
+ setDraft(null);
28
+ }
29
+ function commitDraft() {
30
+ const time = draft === null ? null : parseTimeInput(draft);
31
+ if (time !== null) {
32
+ onType(time);
33
+ }
34
+ close();
35
+ }
36
+ function pick(value) {
37
+ onSelect(value);
38
+ close();
39
+ }
40
+ function handleKeyDown(event) {
41
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
42
+ event.preventDefault();
43
+ setOpen(true);
44
+ setActive((index) => {
45
+ const from = index < 0 ? options.findIndex((option) => option.value === current) : index;
46
+ return Math.min(Math.max(from + (event.key === "ArrowDown" ? 1 : -1), 0), options.length - 1);
47
+ });
48
+ }
49
+ else if (event.key === "Enter") {
50
+ // Never the form's own submit: Enter here settles the time.
51
+ event.preventDefault();
52
+ if (open && active >= 0) {
53
+ pick(options[active].value);
54
+ }
55
+ else {
56
+ commitDraft();
57
+ }
58
+ }
59
+ else if (event.key === "Escape" && open) {
60
+ event.stopPropagation();
61
+ close();
62
+ }
63
+ }
64
+ return (_jsxs("div", { className: "relative", children: [_jsx("input", { type: "text", role: "combobox", "aria-label": label, "aria-expanded": open, "aria-controls": listId, "aria-autocomplete": "list", "aria-activedescendant": active >= 0 ? `${listId}-${active}` : undefined, autoComplete: "off", className: className, value: draft ?? display, onClick: () => setOpen(true), onFocus: (event) => {
65
+ event.currentTarget.select();
66
+ setOpen(true);
67
+ }, onChange: (event) => setDraft(event.target.value), onBlur: commitDraft, onKeyDown: handleKeyDown }), open && (_jsx("ul", { id: listId, role: "listbox", "aria-label": `${label} options`, className: "absolute left-0 top-full z-30 mt-1 max-h-56 w-44 overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-lg", children: options.map((option, index) => (_jsxs("li", { id: `${listId}-${index}`, ref: option.value === current ? selectedRef : undefined, role: "option", "aria-selected": option.value === current,
68
+ // Before the field's own blur, which would otherwise close the list and swallow the click.
69
+ onMouseDown: (event) => {
70
+ event.preventDefault();
71
+ pick(option.value);
72
+ }, className: [
73
+ "flex cursor-pointer items-baseline gap-1.5 px-3 py-1.5 text-sm hover:bg-surface-alt",
74
+ option.value === current ? "font-semibold text-primary-dark" : "",
75
+ index === active ? "bg-surface-alt" : "",
76
+ ].join(" "), children: [option.label, option.hint && _jsx("span", { className: "text-xs font-normal text-text-muted", children: option.hint })] }, option.value))) }))] }));
77
+ }
@@ -16,7 +16,7 @@ export default function TimeGridView({ days, occurrences, folderColors, onSelect
16
16
  const timedEvents = occurrences.filter((occ) => !occ.allDay);
17
17
  return (_jsxs("div", { "data-calendar-scroller": true, className: "flex-1 flex flex-col min-h-0 overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: ["flex-1 min-w-0 py-1 text-center text-xs font-semibold border-l border-border", isToday(day) ? "text-primary-dark" : ""].join(" "), children: format(day, "EEE d") }, day.toISOString())))] }), allDayEvents.length > 0 && (_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: "flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border", children: allDayEvents
18
18
  .filter((occ) => occursOnDay(occ, day))
19
- .map((occ) => (_jsx("button", { type: "button", onClick: () => onSelectEvent(occ), style: { backgroundColor: folderColors[occ.folderUid], color: "#fff" }, className: "text-xs text-left truncate rounded-sm px-1.5 py-0.5", children: occ.title }, occ.occurrenceKey))) }, day.toISOString())))] })), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "text-[10px] text-text-muted text-right pr-1.5 -mt-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), days.map((day) => (_jsx(DayColumn, { day: day, occurrences: timedEvents.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString())))] })] }));
19
+ .map((occ) => (_jsx("button", { type: "button", onClick: () => onSelectEvent(occ), style: { backgroundColor: folderColors[occ.folderUid], color: "#fff" }, className: "text-xs text-left truncate rounded-sm px-1.5 py-0.5", children: occ.title }, occ.occurrenceKey))) }, day.toISOString())))] })), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "relative -top-1.5 text-[10px] text-text-muted text-right pr-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), days.map((day) => (_jsx(DayColumn, { day: day, occurrences: timedEvents.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString())))] })] }));
20
20
  }
21
21
  function DayColumn({ day, occurrences, folderColors, onSelectEvent, onSelectSlot }) {
22
22
  const dayStart = startOfDay(day);
@@ -67,6 +67,10 @@ export declare function localDateFromKey(key: string): Date;
67
67
  export declare function formatWhen(start: Date, end: Date, allDay: boolean, now?: Date): string;
68
68
  /** `formatWhen()` for a stored event: instants for a timed one, the date-only keys (with the exclusive end) for an all-day one. */
69
69
  export declare function formatStoredWhen(startDate: string, endDate: string, allDay: boolean, now?: Date): string;
70
+ /** The date the form's start falls on, for reading a zone's offset as it will be for the event (daylight saving); now while there is no date. */
71
+ export declare function zoneDate(start: string): Date;
72
+ /** `formatWhen()` for a stored timed event as `zone`'s clock reads it, whatever zone the viewer is in. */
73
+ export declare function formatStoredWhenInZone(startDate: string, endDate: string, zone: string, now?: Date): string;
70
74
  /** Adds the guests typed or pasted into the guests field to `current` - recipients separated by commas or semicolons, each an address or
71
75
  * `Name <address>` (see `applyGuestChips()`, which keeps who is already there): an address already on the list (in any case) or in
72
76
  * `skipAddresses` is skipped, and what is not an address comes back as `invalid` for the field to keep. */
@@ -169,6 +169,19 @@ export function formatStoredWhen(startDate, endDate, allDay, now = new Date()) {
169
169
  }
170
170
  return formatWhen(new Date(startDate), new Date(endDate), false, now);
171
171
  }
172
+ /** The date the form's start falls on, for reading a zone's offset as it will be for the event (daylight saving); now while there is no date. */
173
+ export function zoneDate(start) {
174
+ const day = new Date(`${start.slice(0, 10)}T12:00:00Z`);
175
+ return Number.isNaN(day.getTime()) ? new Date() : day;
176
+ }
177
+ /** `formatWhen()` for a stored timed event as `zone`'s clock reads it, whatever zone the viewer is in. */
178
+ export function formatStoredWhenInZone(startDate, endDate, zone, now = new Date()) {
179
+ const onZoneClock = (iso) => {
180
+ const wall = new Date(toEventWallClock(new Date(iso).getTime(), zone, false));
181
+ return new Date(wall.getUTCFullYear(), wall.getUTCMonth(), wall.getUTCDate(), wall.getUTCHours(), wall.getUTCMinutes());
182
+ };
183
+ return formatWhen(onZoneClock(startDate), onZoneClock(endDate), false, now);
184
+ }
172
185
  /** Adds the guests typed or pasted into the guests field to `current` - recipients separated by commas or semicolons, each an address or
173
186
  * `Name <address>` (see `applyGuestChips()`, which keeps who is already there): an address already on the list (in any case) or in
174
187
  * `skipAddresses` is skipped, and what is not an address comes back as `invalid` for the field to keep. */
@@ -0,0 +1,31 @@
1
+ /** The step between the times a time field offers. */
2
+ export declare const TIME_STEP_MINUTES = 15;
3
+ /**
4
+ * The start a new event gets when nothing was clicked: the next half hour (11:12 becomes 11:30; a time already on the half hour stays), with the
5
+ * event lasting an hour from there.
6
+ */
7
+ export declare function nextHalfHour(now: Date): Date;
8
+ /** `HH:mm` (24 hour) as a 12 hour clock reading: "13:05" is "1:05 PM". */
9
+ export declare function formatTimeOfDay(time: string): string;
10
+ /**
11
+ * What was typed into a time field as `HH:mm`, or `null` when it isn't a time. Takes 24 hour and 12 hour readings, with or without the colon and
12
+ * the space: "15:45", "3:45 pm", "3:45p", "345pm", "1130" and a bare hour such as "9" (9:00) are all times.
13
+ */
14
+ export declare function parseTimeInput(text: string): string | null;
15
+ /** Every time of a day, a quarter hour apart, as `HH:mm`. */
16
+ export declare function dayTimes(): string[];
17
+ /** A length of time as a menu says it: "15 mins", "1 hr", "1.5 hrs", "2 hrs". */
18
+ export declare function formatDuration(minutes: number): string;
19
+ export interface EndTimeOption {
20
+ /** The end as `YYYY-MM-DDTHH:mm`. */
21
+ value: string;
22
+ /** The time of day, as a 12 hour clock reading. */
23
+ label: string;
24
+ /** How long the event would last, "(1.5 hrs)". */
25
+ hint: string;
26
+ }
27
+ /**
28
+ * The ends a time field offers for an event starting at `start` (`YYYY-MM-DDTHH:mm`): each quarter hour on the clock after the start, up to a
29
+ * day later, with the length of the event that end would make.
30
+ */
31
+ export declare function endTimeOptions(start: string): EndTimeOption[];
@@ -0,0 +1,89 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { format } from "date-fns";
6
+ /** The step between the times a time field offers. */
7
+ export const TIME_STEP_MINUTES = 15;
8
+ const MS_PER_MINUTE = 60000;
9
+ /**
10
+ * The start a new event gets when nothing was clicked: the next half hour (11:12 becomes 11:30; a time already on the half hour stays), with the
11
+ * event lasting an hour from there.
12
+ */
13
+ export function nextHalfHour(now) {
14
+ const slot = 30 * MS_PER_MINUTE;
15
+ return new Date(Math.ceil(now.getTime() / slot) * slot);
16
+ }
17
+ /** `HH:mm` (24 hour) as a 12 hour clock reading: "13:05" is "1:05 PM". */
18
+ export function formatTimeOfDay(time) {
19
+ // A cleared date leaves no time to read: the field is empty, not an error.
20
+ if (!/^\d\d:\d\d$/.test(time)) {
21
+ return "";
22
+ }
23
+ const [hours, minutes] = time.split(":").map(Number);
24
+ return format(new Date(2000, 0, 1, hours, minutes), "h:mm a");
25
+ }
26
+ /**
27
+ * What was typed into a time field as `HH:mm`, or `null` when it isn't a time. Takes 24 hour and 12 hour readings, with or without the colon and
28
+ * the space: "15:45", "3:45 pm", "3:45p", "345pm", "1130" and a bare hour such as "9" (9:00) are all times.
29
+ */
30
+ export function parseTimeInput(text) {
31
+ const match = /^\s*(\d{1,2})(?::?(\d{2}))?\s*(?:([ap])\.?m?\.?)?\s*$/i.exec(text);
32
+ if (!match) {
33
+ return null;
34
+ }
35
+ let hours = Number(match[1]);
36
+ const minutes = match[2] === undefined ? 0 : Number(match[2]);
37
+ const meridiem = match[3]?.toLowerCase();
38
+ if (minutes > 59) {
39
+ return null;
40
+ }
41
+ if (meridiem) {
42
+ if (hours < 1 || hours > 12) {
43
+ return null;
44
+ }
45
+ hours = (hours % 12) + (meridiem === "p" ? 12 : 0);
46
+ }
47
+ else if (hours > 23) {
48
+ return null;
49
+ }
50
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
51
+ }
52
+ /** Every time of a day, a quarter hour apart, as `HH:mm`. */
53
+ export function dayTimes() {
54
+ return Array.from({ length: (24 * 60) / TIME_STEP_MINUTES }, (_, i) => {
55
+ const minutes = i * TIME_STEP_MINUTES;
56
+ return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
57
+ });
58
+ }
59
+ /** A length of time as a menu says it: "15 mins", "1 hr", "1.5 hrs", "2 hrs". */
60
+ export function formatDuration(minutes) {
61
+ if (minutes < 60) {
62
+ return `${minutes} min${minutes === 1 ? "" : "s"}`;
63
+ }
64
+ const hours = Math.round((minutes / 60) * 100) / 100;
65
+ return `${hours} ${hours === 1 ? "hr" : "hrs"}`;
66
+ }
67
+ /** `YYYY-MM-DDTHH:mm` read as a wall clock, in minutes since the epoch (no zone: the daylight saving changes of a zone are not this field's business). */
68
+ function wallMinutes(value) {
69
+ return Date.parse(`${value.slice(0, 16)}:00Z`) / MS_PER_MINUTE;
70
+ }
71
+ function wallString(minutes) {
72
+ return new Date(minutes * MS_PER_MINUTE).toISOString().slice(0, 16);
73
+ }
74
+ /**
75
+ * The ends a time field offers for an event starting at `start` (`YYYY-MM-DDTHH:mm`): each quarter hour on the clock after the start, up to a
76
+ * day later, with the length of the event that end would make.
77
+ */
78
+ export function endTimeOptions(start) {
79
+ if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(start)) {
80
+ return [];
81
+ }
82
+ const startMinutes = wallMinutes(start);
83
+ const options = [];
84
+ for (let at = Math.floor(startMinutes / TIME_STEP_MINUTES + 1) * TIME_STEP_MINUTES; at <= startMinutes + 24 * 60; at += TIME_STEP_MINUTES) {
85
+ const value = wallString(at);
86
+ options.push({ value, label: formatTimeOfDay(value.slice(11, 16)), hint: `(${formatDuration(at - startMinutes)})` });
87
+ }
88
+ return options;
89
+ }
@@ -3,12 +3,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useState } from "react";
6
+ import { useEffect, useState } from "react";
7
7
  import { HiChevronDown, HiChevronRight, HiOutlineFlag, HiOutlinePaperClip } from "react-icons/hi2";
8
8
  import { ApiRequestError } from "../../../../lib/util/api.js";
9
9
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
10
10
  import MailAddress from "./MailAddress.js";
11
- import { EncryptedPreview, conversationLooksEncrypted } from "./reading/EncryptedPreview.js";
11
+ import { EncryptedPreview, conversationLooksEncrypted, displaySubject } from "./reading/EncryptedPreview.js";
12
+ import { decryptForDisplay, useDecryptedMessages, useUnlockEpoch } from "../../mail/decryptedMessages.js";
12
13
  import { listConversationMessages } from "../../../../lib/mail/conversationsApi.js";
13
14
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
14
15
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "./unreadStyle.js";
@@ -37,6 +38,18 @@ function participantList(conversation) {
37
38
  */
38
39
  export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, swipe, newestFirst, }) {
39
40
  const client = useApiClient();
41
+ // What this device has decrypted of the encrypted messages on screen: their real subjects and previews.
42
+ const decrypted = useDecryptedMessages();
43
+ const unlockEpoch = useUnlockEpoch();
44
+ // An encrypted conversation's row stands for its latest message, whose subject only decrypting can tell. Rows are decrypted as they are listed
45
+ // whenever the mailbox is unlocked (and again once it is), the same way the message list does.
46
+ useEffect(() => {
47
+ for (const conversation of conversations) {
48
+ if (conversationLooksEncrypted(conversation)) {
49
+ void decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
50
+ }
51
+ }
52
+ }, [conversations, mailboxUid, unlockEpoch]);
40
53
  const [expanded, setExpanded] = useState(new Set());
41
54
  const [messagesById, setMessagesById] = useState({});
42
55
  const [loadingIds, setLoadingIds] = useState(new Set());
@@ -92,7 +105,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
92
105
  const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
93
106
  const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
94
107
  const ticked = selectedConversationIds?.has(conversation.conversationId) ?? false;
95
- return (_jsxs("li", { children: [_jsxs(SwipeRow, { as: "div", enabled: !!swipe?.enabled && !selectMode, onArchive: () => swipe.onArchive(conversation), onMove: () => swipe.onMove(conversation), "data-message-uid": conversation.latestMessageUid, "data-unread": unread ? "true" : undefined, className: rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: unread }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: ["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" "), children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: unread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: ["flex-1", senderClass(unread)].join(" ") }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: ["text-xs shrink-0", dateClass(unread)].join(" "), children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: ["text-sm truncate", subjectClass(unread)].join(" "), children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversationLooksEncrypted(conversation) ? _jsx(EncryptedPreview, {}) : conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: {
108
+ return (_jsxs("li", { children: [_jsxs(SwipeRow, { as: "div", enabled: !!swipe?.enabled && !selectMode, onArchive: () => swipe.onArchive(conversation), onMove: () => swipe.onMove(conversation), "data-message-uid": conversation.latestMessageUid, "data-unread": unread ? "true" : undefined, className: rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: unread }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: ["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" "), children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: unread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: ["flex-1", senderClass(unread)].join(" ") }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: ["text-xs shrink-0", dateClass(unread)].join(" "), children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: ["text-sm truncate", subjectClass(unread)].join(" "), children: decrypted[conversation.latestMessageUid]?.subject || displaySubject(conversation.subject) || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversationLooksEncrypted(conversation) ? decrypted[conversation.latestMessageUid]?.preview || _jsx(EncryptedPreview, {}) : conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: {
96
109
  uid: conversation.latestMessageUid,
97
110
  meetingMethod: conversation.latestMeetingMethod ?? undefined,
98
111
  meetingResponse: answers[conversation.latestMessageUid] ??
@@ -102,7 +115,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
102
115
  }, onResponded: answered, className: "mr-4" })] })] }), _jsxs("ul", { id: panelId, hidden: !isExpanded, children: [errorsById[id] && (_jsx("li", { role: "alert", className: "pl-8 pr-4 py-2 text-xs text-danger border-b border-border", children: errorsById[id] })), loadingIds.has(id) && (_jsx("li", { className: "pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border", children: "Loading messages\u2026" })), children?.map((fetched) => {
103
116
  const message = messageOverrides?.[fetched.uid] ?? fetched;
104
117
  const messageUnread = isUnread(message);
105
- return (_jsxs("li", { "data-message-uid": message.uid, "data-unread": messageUnread ? "true" : undefined, className: rowClass({ unread: messageUnread, selected: message.uid === selectedUid }), children: [_jsx(UnreadBar, { unread: messageUnread }), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, message.uid), className: ["w-full text-left pl-8 pr-4 py-2", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: messageUnread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(messageUnread) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(messageUnread)].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview || (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: { ...message, meetingResponse: answers[message.uid] ?? message.meetingResponse }, onResponded: (updated) => {
118
+ return (_jsxs("li", { "data-message-uid": message.uid, "data-unread": messageUnread ? "true" : undefined, className: rowClass({ unread: messageUnread, selected: message.uid === selectedUid }), children: [_jsx(UnreadBar, { unread: messageUnread }), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, message.uid), className: ["w-full text-left pl-8 pr-4 py-2", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: messageUnread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(messageUnread) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(messageUnread)].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview || decrypted[message.uid]?.preview || (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: { ...message, meetingResponse: answers[message.uid] ?? message.meetingResponse }, onResponded: (updated) => {
106
119
  answered(updated);
107
120
  onMeetingResponded?.(updated);
108
121
  }, className: "ml-8 mr-4" })] }, message.uid));
@@ -11,7 +11,8 @@ import { listConversationMessages } from "../../../../lib/mail/conversationsApi.
11
11
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
12
12
  import Alert from "../../../../lib/components/feedback/Alert.js";
13
13
  import MessageDetailPane from "./MessageDetailPane.js";
14
- import { EncryptedPreview, displaySubject } from "./reading/EncryptedPreview.js";
14
+ import { ENCRYPTED_SUBJECT_PLACEHOLDER, EncryptedPreview, displaySubject } from "./reading/EncryptedPreview.js";
15
+ import { useDecryptedMessages } from "../../mail/decryptedMessages.js";
15
16
  import { CollapsedCard, SkeletonCards, SubjectCard } from "./reading/MessageCard.js";
16
17
  import PendingMessageCard from "./reading/PendingMessageCard.js";
17
18
  import { useMailShell } from "./layout/MailShell.js";
@@ -127,6 +128,7 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
127
128
  const client = useApiClient();
128
129
  // The replies and forwards this tab has sent, drawn at the top of the thread they continue until the server's own copy is in it.
129
130
  const outgoing = useOutgoingReplies();
131
+ const decryptedMessages = useDecryptedMessages();
130
132
  const [messages, setMessages] = useState([]);
131
133
  const [attachmentsByUid, setAttachmentsByUid] = useState({});
132
134
  const [expandedUids, setExpandedUids] = useState(new Set());
@@ -423,7 +425,9 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
423
425
  if (!conversation) {
424
426
  return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Select a conversation to read it." });
425
427
  }
426
- const subject = displaySubject(conversation.subject) || "(no subject)";
428
+ // An encrypted thread's subject is the placeholder until one of its messages has been decrypted here; the newest one that has says it.
429
+ const decryptedSubject = conversation.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? messages.map((message) => decryptedMessages[message.uid]?.subject).find(Boolean) : undefined;
430
+ const subject = (decryptedSubject ?? displaySubject(conversation.subject)) || "(no subject)";
427
431
  // The newest message that is open carries the Reply / Forward buttons at the foot of its card.
428
432
  const footerUid = messages.find((message) => expandedUids.has(message.uid))?.uid;
429
433
  return (_jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex flex-col", children: [_jsx(SubjectCard, { subject: subject, meta: loading
@@ -43,14 +43,16 @@ import Alert from "../../../../lib/components/feedback/Alert.js";
43
43
  import LabelMenuButton from "./labelMenu.js";
44
44
  import Button from "../../../../lib/components/buttons/Button.js";
45
45
  import EncryptedBody from "./reading/EncryptedBody.js";
46
- import { displaySubject } from "./reading/EncryptedPreview.js";
46
+ import { ENCRYPTED_SUBJECT_PLACEHOLDER, displaySubject } from "./reading/EncryptedPreview.js";
47
+ import { rememberDecrypted } from "../../mail/decryptedMessages.js";
47
48
  import MessageBody, { BodySkeleton } from "./reading/MessageBody.js";
48
49
  import { BODY_FONT_STYLE, CardShell, SenderAvatar, SubjectCard } from "./reading/MessageCard.js";
49
50
  import MessageMoreMenu from "./reading/MessageMoreMenu.js";
50
51
  import MessageSourceDialog from "./reading/MessageSourceDialog.js";
51
52
  import { useMessageActions } from "./reading/useMessageActions.js";
52
53
  import { saveAsEml } from "./reading/messageExport.js";
53
- import { buildPrintDocument, printDocument } from "./reading/printMessage.js";
54
+ import { buildConversationPrintDocument, printDocument } from "./reading/printMessage.js";
55
+ import { olderConversationMessages, printHeaders, printableOlderMessage } from "./reading/conversationPrint.js";
54
56
  import { fetchBodyContent } from "./reading/bodyContent.js";
55
57
  import { useViewOriginal } from "./reading/viewOriginal.js";
56
58
  import { ROW_FOCUS_CLASS, UnreadLabel, dateClass, senderClass } from "./unreadStyle.js";
@@ -781,7 +783,14 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
781
783
  const recordedConflict = keyChanged && sameFingerprint(senderKeyState?.conflict?.observedKey.fingerprint, keyChanged.signerFingerprint)
782
784
  ? senderKeyState.conflict
783
785
  : undefined;
784
- const shownSubject = (protectedSubject ?? displaySubject(message.subject)) || "(no subject)";
786
+ // An encrypted message's outer Subject is only RFC 9788's placeholder: whatever decrypting recovered is the subject, signed or not.
787
+ const decryptedSubject = message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? security?.subject : undefined;
788
+ const shownSubject = (protectedSubject ?? decryptedSubject ?? displaySubject(message.subject)) || "(no subject)";
789
+ useEffect(() => {
790
+ if (decryptedSubject) {
791
+ rememberDecrypted(message.uid, message.mailboxUid, { subject: decryptedSubject });
792
+ }
793
+ }, [decryptedSubject, message.uid, message.mailboxUid]);
785
794
  // What the body area shows. Text or HTML the client recovered from a signed or encrypted message is shown as it is (its own sanitizing
786
795
  // happens in `MessageBody`, like every body's); anything else is the server's own `/content`. An encrypted message has nothing worth
787
796
  // fetching until it has been decrypted (the server only has the ciphertext), so it shows the skeleton meanwhile.
@@ -807,24 +816,28 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
807
816
  const sendingStatus = (_jsxs(_Fragment, { children: [sendInProgress && (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Sending\u2026" })), isSentItems &&
808
817
  (message.recallRequestedAt ? (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Recall requested" })) : (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setConfirming(true), children: "Recall this message" }))), inOutbox && !sendInProgress && message.scheduledSendTime && (_jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsxs("span", { className: "text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt", children: ["Scheduled for ", new Date(message.scheduledSendTime).toLocaleString()] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Cancel" })] })), inOutbox && !sendInProgress && !message.scheduledSendTime && draftsFolderUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Move to Drafts" }))] }));
809
818
  // ---- The "More actions" menu's own rows (Print, View, Save as, Create rule); the rest are `messageActions`. ----
810
- /** Prints this one message: its header lines and the body the pane shows (what was decrypted or verified here, else the server's sanitized
811
- * body), in a frame of its own - see `printMessage.ts`. */
819
+ /** Prints this message and the conversation it belongs to: the message first, then every earlier one, newest to oldest (the messages that
820
+ * came after it are left out). Each is the body the reading pane shows - what was decrypted or verified here for this one, else the server's
821
+ * sanitized body - in a frame of its own; see `printMessage.ts`. */
812
822
  async function handlePrint() {
813
823
  try {
814
824
  const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
815
- const format = (list) => list.map((r) => formatMailAddress(r)).join(", ");
816
- const printable = buildPrintDocument({
817
- subject: shownSubject,
818
- headers: [
819
- { name: "From", value: senderLabel },
820
- { name: "To", value: format(message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")) },
821
- { name: "Cc", value: format(message.recipients.filter((r) => r.type === "cc")) },
822
- { name: "Date", value: new Date(message.receivedDate).toLocaleString() },
823
- ],
824
- content,
825
- attachments,
826
- inlineParts: innerAttachments,
827
- });
825
+ const older = await olderConversationMessages(message, client).catch(() => []);
826
+ const earlier = await Promise.all(older.map((other) => printableOlderMessage(other).catch(() => ({
827
+ subject: other.subject || "(no subject)",
828
+ headers: printHeaders(other, formatMailAddress(other.from)),
829
+ content: { kind: "text", text: "This message could not be loaded, so it is not printed." },
830
+ }))));
831
+ const printable = buildConversationPrintDocument(shownSubject, [
832
+ {
833
+ subject: shownSubject,
834
+ headers: printHeaders(message, senderLabel),
835
+ content,
836
+ attachments,
837
+ inlineParts: innerAttachments,
838
+ },
839
+ ...earlier,
840
+ ]);
828
841
  if (printable === undefined) {
829
842
  notify({ kind: "warning", title: "This message is too large to print here" });
830
843
  return;
@@ -11,8 +11,8 @@ export interface InviteRowChipProps {
11
11
  export declare function showsInviteChip(message: Pick<Message, "meetingMethod" | "encrypted">): boolean;
12
12
  /**
13
13
  * The line a meeting request gets in a list of mail, under its preview, as Outlook draws it: a calendar icon, when the meeting starts (in the reader's
14
- * zone), under that whether it conflicts with the reader's calendar - or, once answered, the answer - and an RSVP button that opens the day view with
15
- * Accept, Decline and Tentative (`InviteRsvpPopover`).
14
+ * zone), under that whether it conflicts with the reader's calendar, and an RSVP button that opens the day view with
15
+ * Accept, Decline and Maybe (`InviteRsvpPopover`). Once the meeting has been answered - from here or anywhere else - the chip is gone.
16
16
  *
17
17
  * The invitation is looked up (once, through the shared cache) only for a row that `showsInviteChip()` - the rest of the list is never asked about. Until
18
18
  * it arrives, or when the lookup fails, the chip says only "Meeting request", with no button.
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useRef, useState } from "react";
7
7
  import { HiOutlineCalendarDays } from "react-icons/hi2";
8
- import { ANSWER_LABEL, formatRowStart } from "./inviteFormat.js";
8
+ import { formatRowStart } from "./inviteFormat.js";
9
9
  import { useMessageInvite } from "./inviteStore.js";
10
10
  import InviteRsvpPopover from "./InviteRsvpPopover.js";
11
11
  /** Whether a list row draws the chip: a meeting request the server can read (an encrypted message's calendar file is inside its ciphertext). */
@@ -14,8 +14,8 @@ export function showsInviteChip(message) {
14
14
  }
15
15
  /**
16
16
  * The line a meeting request gets in a list of mail, under its preview, as Outlook draws it: a calendar icon, when the meeting starts (in the reader's
17
- * zone), under that whether it conflicts with the reader's calendar - or, once answered, the answer - and an RSVP button that opens the day view with
18
- * Accept, Decline and Tentative (`InviteRsvpPopover`).
17
+ * zone), under that whether it conflicts with the reader's calendar, and an RSVP button that opens the day view with
18
+ * Accept, Decline and Maybe (`InviteRsvpPopover`). Once the meeting has been answered - from here or anywhere else - the chip is gone.
19
19
  *
20
20
  * The invitation is looked up (once, through the shared cache) only for a row that `showsInviteChip()` - the rest of the list is never asked about. Until
21
21
  * it arrives, or when the lookup fails, the chip says only "Meeting request", with no button.
@@ -28,10 +28,11 @@ export default function InviteRowChip({ message, onResponded, className = "mx-4"
28
28
  const { invite, setInvite } = useMessageInvite(message.uid, eligible);
29
29
  const [open, setOpen] = useState(false);
30
30
  const rsvpRef = useRef(null);
31
- if (!eligible) {
31
+ const answer = invite?.response ?? message.meetingResponse;
32
+ // Once the reader has answered, the row has nothing left to ask: the chip goes away (the answer stays on the message itself, and can be changed there).
33
+ if (!eligible || answer) {
32
34
  return null;
33
35
  }
34
- const answer = invite?.response ?? message.meetingResponse;
35
36
  const conflicts = invite?.conflicts.length ?? 0;
36
37
  const start = invite ? formatRowStart(invite) : undefined;
37
38
  function answered(updated) {
@@ -39,8 +40,5 @@ export default function InviteRowChip({ message, onResponded, className = "mx-4"
39
40
  onResponded?.({ ...message, meetingResponse: updated.response ?? message.meetingResponse });
40
41
  }
41
42
  const stop = (event) => event.stopPropagation();
42
- return (_jsxs("div", { "data-invite-chip": true, onClick: stop, onTouchStart: stop, onTouchMove: stop, onTouchEnd: stop, onTouchCancel: stop, className: [className, "mb-2 -mt-1 flex items-center gap-2 rounded-sm border border-border bg-surface px-2 py-1.5 text-xs font-normal"].join(" "), children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "truncate text-text", children: start ?? "Meeting request" }), invite && (_jsx("div", { className: [
43
- "truncate",
44
- answer === "accepted" ? "text-success" : answer === "declined" ? "text-danger" : conflicts > 0 ? "text-danger" : "text-text-muted",
45
- ].join(" "), children: answer ? ANSWER_LABEL[answer] : conflicts > 0 ? `Conflicts with ${conflicts} ${conflicts === 1 ? "event" : "events"}` : "No conflicts" }))] }), invite?.canRespond && !invite.isOrganizer && (_jsx("button", { ref: rsvpRef, type: "button", "aria-haspopup": "dialog", "aria-expanded": open, "aria-label": `RSVP to ${invite.summary?.trim() || "this meeting"}`, onClick: () => setOpen((wasOpen) => !wasOpen), className: "shrink-0 py-1 px-3 rounded-sm border border-border text-xs font-semibold text-text hover:bg-surface-alt", children: "RSVP" })), open && invite && (_jsx(InviteRsvpPopover, { messageUid: message.uid, invite: invite, anchorRef: rsvpRef, onDone: answered, onClose: () => setOpen(false) }))] }));
43
+ return (_jsxs("div", { "data-invite-chip": true, onClick: stop, onTouchStart: stop, onTouchMove: stop, onTouchEnd: stop, onTouchCancel: stop, className: [className, "mb-2 -mt-1 flex items-center gap-2 rounded-sm border border-border bg-surface px-2 py-1.5 text-xs font-normal"].join(" "), children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "truncate text-text", children: start ?? "Meeting request" }), invite && (_jsx("div", { className: ["truncate", conflicts > 0 ? "text-danger" : "text-text-muted"].join(" "), children: conflicts > 0 ? `Conflicts with ${conflicts} ${conflicts === 1 ? "event" : "events"}` : "No conflicts" }))] }), invite?.canRespond && !invite.isOrganizer && (_jsx("button", { ref: rsvpRef, type: "button", "aria-haspopup": "dialog", "aria-expanded": open, "aria-label": `RSVP to ${invite.summary?.trim() || "this meeting"}`, onClick: () => setOpen((wasOpen) => !wasOpen), className: "shrink-0 py-1 px-3 rounded-sm border border-border text-xs font-semibold text-text hover:bg-surface-alt", children: "RSVP" })), open && invite && (_jsx(InviteRsvpPopover, { messageUid: message.uid, invite: invite, anchorRef: rsvpRef, onDone: answered, onClose: () => setOpen(false) }))] }));
46
44
  }
@@ -11,7 +11,7 @@ export interface InviteRsvpPopoverProps {
11
11
  }
12
12
  /**
13
13
  * What the RSVP button opens: an anchored popover on a desktop, a dialog on a phone. A day view of the reader's schedule around the meeting, with
14
- * the invitation highlighted and its conflicts marked, and Accept, Decline, a "?" for Tentative and a "..." menu (Propose new time, Open in calendar).
14
+ * the invitation highlighted and its conflicts marked, and Accept, Decline, a "Maybe" for Tentative and a "..." menu (Propose new time, Open in calendar).
15
15
  * An answer is sent to the server, which puts the meeting on the calendar and replies to the organizer; the popover then closes and hands the
16
16
  * updated invitation to its caller. A failure raises a pop-up and leaves the popover open.
17
17
  *
@@ -40,7 +40,7 @@ function estimateHeight(invite, proposing) {
40
40
  const RESPONSES = [
41
41
  { response: "accepted", label: "Accept", text: "Accept", failure: "Couldn't accept this meeting" },
42
42
  { response: "declined", label: "Decline", text: "Decline", failure: "Couldn't decline this meeting" },
43
- { response: "tentative", label: "Tentative", text: "?", failure: "Couldn't respond tentatively to this meeting" },
43
+ { response: "tentative", label: "Tentative", text: "Maybe", failure: "Couldn't respond tentatively to this meeting" },
44
44
  ];
45
45
  /** What is inside the RSVP popover (or dialog): the meeting, a day view around it and the buttons that answer it. */
46
46
  function RsvpPanel({ messageUid, invite, onDone, onClose, onProposingChange }) {
@@ -117,7 +117,7 @@ function RsvpPanel({ messageUid, invite, onDone, onClose, onProposingChange }) {
117
117
  }
118
118
  /**
119
119
  * What the RSVP button opens: an anchored popover on a desktop, a dialog on a phone. A day view of the reader's schedule around the meeting, with
120
- * the invitation highlighted and its conflicts marked, and Accept, Decline, a "?" for Tentative and a "..." menu (Propose new time, Open in calendar).
120
+ * the invitation highlighted and its conflicts marked, and Accept, Decline, a "Maybe" for Tentative and a "..." menu (Propose new time, Open in calendar).
121
121
  * An answer is sent to the server, which puts the meeting on the calendar and replies to the organizer; the popover then closes and hands the
122
122
  * updated invitation to its caller. A failure raises a pop-up and leaves the popover open.
123
123
  *