@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.
- package/apps/admin/mailboxes/[uid].tsx +2 -1
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
- package/apps/shared/components/admin/setup/SetupWizard.tsx +1 -1
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
- package/apps/shared/components/calendar/EventDescriptionView.tsx +37 -5
- package/apps/shared/components/calendar/EventDetails.tsx +15 -6
- package/apps/shared/components/calendar/EventEditor.tsx +5 -2
- package/apps/shared/components/calendar/EventFormParts.tsx +45 -39
- package/apps/shared/components/calendar/EventQuickForm.tsx +3 -2
- package/apps/shared/components/calendar/FindATime.tsx +3 -2
- package/apps/shared/components/calendar/TimeComboBox.tsx +143 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +3 -1
- package/apps/shared/components/calendar/eventFormat.ts +15 -0
- package/apps/shared/components/calendar/timePicker.ts +106 -0
- package/apps/shared/components/mail/ConversationList.tsx +18 -5
- package/apps/shared/components/mail/ConversationThreadPane.tsx +7 -2
- package/apps/shared/components/mail/MessageDetailPane.tsx +37 -18
- package/apps/shared/components/mail/invite/InviteRowChip.tsx +8 -12
- package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +2 -2
- package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
- package/apps/shared/components/mail/reading/conversationPrint.ts +76 -0
- package/apps/shared/components/mail/reading/printMessage.ts +44 -7
- package/apps/shared/keyboard/keymap.ts +1 -0
- package/apps/shared/mail/decryptedMessages.ts +116 -0
- package/apps/www/calendar/index.tsx +5 -2
- package/apps/www/index.tsx +11 -0
- package/apps/www/settings/profile/index.tsx +8 -12
- package/dist/apps/admin/mailboxes/[uid].js +2 -1
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +2 -2
- package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +4 -4
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +1 -1
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +6 -1
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +34 -4
- package/dist/apps/shared/components/calendar/EventDetails.js +4 -4
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -2
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventFormParts.js +9 -3
- package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -2
- package/dist/apps/shared/components/calendar/FindATime.js +3 -2
- package/dist/apps/shared/components/calendar/TimeComboBox.d.ts +25 -0
- package/dist/apps/shared/components/calendar/TimeComboBox.js +77 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +1 -1
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +4 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +13 -0
- package/dist/apps/shared/components/calendar/timePicker.d.ts +31 -0
- package/dist/apps/shared/components/calendar/timePicker.js +89 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +6 -2
- package/dist/apps/shared/components/mail/MessageDetailPane.js +31 -18
- package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +2 -2
- package/dist/apps/shared/components/mail/invite/InviteRowChip.js +7 -9
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +1 -1
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +2 -2
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +15 -0
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +69 -0
- package/dist/apps/shared/components/mail/reading/printMessage.d.ts +7 -1
- package/dist/apps/shared/components/mail/reading/printMessage.js +33 -6
- package/dist/apps/shared/keyboard/keymap.d.ts +6 -0
- package/dist/apps/shared/keyboard/keymap.js +1 -0
- package/dist/apps/shared/mail/decryptedMessages.d.ts +27 -0
- package/dist/apps/shared/mail/decryptedMessages.js +97 -0
- package/dist/apps/www/calendar/index.js +5 -2
- package/dist/apps/www/index.js +10 -0
- package/dist/apps/www/settings/profile/index.js +6 -5
- package/dist/lib/auth/session.d.ts +14 -1
- package/dist/lib/auth/session.js +40 -3
- package/dist/lib/components/pickers/TimeZonePicker.d.ts +20 -0
- package/dist/lib/components/pickers/TimeZonePicker.js +100 -0
- package/dist/lib/util/api.d.ts +6 -0
- package/dist/lib/util/api.js +22 -1
- package/dist/lib/util/timeZone.d.ts +19 -1
- package/dist/lib/util/timeZone.js +46 -2
- package/lib/auth/session.ts +48 -3
- package/lib/components/pickers/TimeZonePicker.tsx +180 -0
- package/lib/util/api.ts +24 -1
- package/lib/util/timeZone.ts +50 -2
- 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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
811
|
-
*
|
|
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
|
|
816
|
-
const
|
|
817
|
-
subject:
|
|
818
|
-
headers:
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
|
15
|
-
* Accept, Decline and
|
|
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 {
|
|
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
|
|
18
|
-
* Accept, Decline and
|
|
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
|
-
|
|
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 "
|
|
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: "
|
|
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 "
|
|
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
|
*
|