@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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { format } from "date-fns";
|
|
6
|
+
|
|
7
|
+
/** The step between the times a time field offers. */
|
|
8
|
+
export const TIME_STEP_MINUTES = 15;
|
|
9
|
+
const MS_PER_MINUTE = 60_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 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
|
|
13
|
+
* event lasting an hour from there.
|
|
14
|
+
*/
|
|
15
|
+
export function nextHalfHour(now: Date): Date {
|
|
16
|
+
const slot = 30 * MS_PER_MINUTE;
|
|
17
|
+
return new Date(Math.ceil(now.getTime() / slot) * slot);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `HH:mm` (24 hour) as a 12 hour clock reading: "13:05" is "1:05 PM". */
|
|
21
|
+
export function formatTimeOfDay(time: string): string {
|
|
22
|
+
// A cleared date leaves no time to read: the field is empty, not an error.
|
|
23
|
+
if (!/^\d\d:\d\d$/.test(time)) {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
const [hours, minutes] = time.split(":").map(Number);
|
|
27
|
+
return format(new Date(2000, 0, 1, hours, minutes), "h:mm a");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 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
|
|
32
|
+
* the space: "15:45", "3:45 pm", "3:45p", "345pm", "1130" and a bare hour such as "9" (9:00) are all times.
|
|
33
|
+
*/
|
|
34
|
+
export function parseTimeInput(text: string): string | null {
|
|
35
|
+
const match = /^\s*(\d{1,2})(?::?(\d{2}))?\s*(?:([ap])\.?m?\.?)?\s*$/i.exec(text);
|
|
36
|
+
if (!match) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
let hours = Number(match[1]);
|
|
40
|
+
const minutes = match[2] === undefined ? 0 : Number(match[2]);
|
|
41
|
+
const meridiem = match[3]?.toLowerCase();
|
|
42
|
+
if (minutes > 59) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (meridiem) {
|
|
46
|
+
if (hours < 1 || hours > 12) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
hours = (hours % 12) + (meridiem === "p" ? 12 : 0);
|
|
50
|
+
} else if (hours > 23) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every time of a day, a quarter hour apart, as `HH:mm`. */
|
|
57
|
+
export function dayTimes(): string[] {
|
|
58
|
+
return Array.from({ length: (24 * 60) / TIME_STEP_MINUTES }, (_, i) => {
|
|
59
|
+
const minutes = i * TIME_STEP_MINUTES;
|
|
60
|
+
return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A length of time as a menu says it: "15 mins", "1 hr", "1.5 hrs", "2 hrs". */
|
|
65
|
+
export function formatDuration(minutes: number): string {
|
|
66
|
+
if (minutes < 60) {
|
|
67
|
+
return `${minutes} min${minutes === 1 ? "" : "s"}`;
|
|
68
|
+
}
|
|
69
|
+
const hours = Math.round((minutes / 60) * 100) / 100;
|
|
70
|
+
return `${hours} ${hours === 1 ? "hr" : "hrs"}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `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). */
|
|
74
|
+
function wallMinutes(value: string): number {
|
|
75
|
+
return Date.parse(`${value.slice(0, 16)}:00Z`) / MS_PER_MINUTE;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function wallString(minutes: number): string {
|
|
79
|
+
return new Date(minutes * MS_PER_MINUTE).toISOString().slice(0, 16);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface EndTimeOption {
|
|
83
|
+
/** The end as `YYYY-MM-DDTHH:mm`. */
|
|
84
|
+
value: string;
|
|
85
|
+
/** The time of day, as a 12 hour clock reading. */
|
|
86
|
+
label: string;
|
|
87
|
+
/** How long the event would last, "(1.5 hrs)". */
|
|
88
|
+
hint: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 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
|
|
93
|
+
* day later, with the length of the event that end would make.
|
|
94
|
+
*/
|
|
95
|
+
export function endTimeOptions(start: string): EndTimeOption[] {
|
|
96
|
+
if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(start)) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const startMinutes = wallMinutes(start);
|
|
100
|
+
const options: EndTimeOption[] = [];
|
|
101
|
+
for (let at = Math.floor(startMinutes / TIME_STEP_MINUTES + 1) * TIME_STEP_MINUTES; at <= startMinutes + 24 * 60; at += TIME_STEP_MINUTES) {
|
|
102
|
+
const value = wallString(at);
|
|
103
|
+
options.push({ value, label: formatTimeOfDay(value.slice(11, 16)), hint: `(${formatDuration(at - startMinutes)})` });
|
|
104
|
+
}
|
|
105
|
+
return options;
|
|
106
|
+
}
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { useState } from "react";
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
6
|
import { HiChevronDown, HiChevronRight, HiOutlineFlag, HiOutlinePaperClip } from "react-icons/hi2";
|
|
7
7
|
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
8
8
|
import { Message } from "../../../../lib/mail/mailApi.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 { ConversationSummary, 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";
|
|
@@ -98,6 +99,18 @@ export default function ConversationList({
|
|
|
98
99
|
newestFirst,
|
|
99
100
|
}: ConversationListProps) {
|
|
100
101
|
const client = useApiClient();
|
|
102
|
+
// What this device has decrypted of the encrypted messages on screen: their real subjects and previews.
|
|
103
|
+
const decrypted = useDecryptedMessages();
|
|
104
|
+
const unlockEpoch = useUnlockEpoch();
|
|
105
|
+
// An encrypted conversation's row stands for its latest message, whose subject only decrypting can tell. Rows are decrypted as they are listed
|
|
106
|
+
// whenever the mailbox is unlocked (and again once it is), the same way the message list does.
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
for (const conversation of conversations) {
|
|
109
|
+
if (conversationLooksEncrypted(conversation)) {
|
|
110
|
+
void decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}, [conversations, mailboxUid, unlockEpoch]);
|
|
101
114
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
102
115
|
const [messagesById, setMessagesById] = useState<Record<string, Message[]>>({});
|
|
103
116
|
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
|
|
@@ -226,10 +239,10 @@ export default function ConversationList({
|
|
|
226
239
|
{new Date(conversation.latestDate).toLocaleDateString()}
|
|
227
240
|
</span>
|
|
228
241
|
</div>
|
|
229
|
-
<div className={["text-sm truncate", subjectClass(unread)].join(" ")}>{conversation.subject || "(no subject)"}</div>
|
|
242
|
+
<div className={["text-sm truncate", subjectClass(unread)].join(" ")}>{decrypted[conversation.latestMessageUid]?.subject || displaySubject(conversation.subject) || "(no subject)"}</div>
|
|
230
243
|
<div className="text-xs text-text-muted truncate font-normal">
|
|
231
244
|
{/* An encrypted latest message has no preview (the server never had its plaintext): say so, with a lock, not nothing. */}
|
|
232
|
-
{conversationLooksEncrypted(conversation) ? <EncryptedPreview /> : conversation.latestPreview}
|
|
245
|
+
{conversationLooksEncrypted(conversation) ? decrypted[conversation.latestMessageUid]?.preview || <EncryptedPreview /> : conversation.latestPreview}
|
|
233
246
|
</div>
|
|
234
247
|
<div className="flex items-center gap-2 mt-1 text-xs text-text-muted font-normal">
|
|
235
248
|
{conversation.messageCount > 1 && <span>{conversation.messageCount} messages</span>}
|
|
@@ -301,7 +314,7 @@ export default function ConversationList({
|
|
|
301
314
|
</span>
|
|
302
315
|
</div>
|
|
303
316
|
<div className="flex items-center gap-2 text-xs text-text-muted font-normal">
|
|
304
|
-
<span className="truncate">{message.bodyPreview || (message.encrypted ? <EncryptedPreview /> : null)}</span>
|
|
317
|
+
<span className="truncate">{message.bodyPreview || decrypted[message.uid]?.preview || (message.encrypted ? <EncryptedPreview /> : null)}</span>
|
|
305
318
|
{message.hasAttachments && (
|
|
306
319
|
<HiOutlinePaperClip size={12} aria-label="Has attachments" />
|
|
307
320
|
)}
|
|
@@ -11,7 +11,8 @@ import { Label } from "../../../../lib/mail/labelsApi.js";
|
|
|
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";
|
|
@@ -192,6 +193,7 @@ export default function ConversationThreadPane({
|
|
|
192
193
|
const client = useApiClient();
|
|
193
194
|
// 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.
|
|
194
195
|
const outgoing = useOutgoingReplies();
|
|
196
|
+
const decryptedMessages = useDecryptedMessages();
|
|
195
197
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
196
198
|
const [attachmentsByUid, setAttachmentsByUid] = useState<Record<string, Attachment[]>>({});
|
|
197
199
|
const [expandedUids, setExpandedUids] = useState<Set<string>>(new Set());
|
|
@@ -495,7 +497,10 @@ export default function ConversationThreadPane({
|
|
|
495
497
|
if (!conversation) {
|
|
496
498
|
return <p className="p-8 text-sm text-text-muted">Select a conversation to read it.</p>;
|
|
497
499
|
}
|
|
498
|
-
|
|
500
|
+
// An encrypted thread's subject is the placeholder until one of its messages has been decrypted here; the newest one that has says it.
|
|
501
|
+
const decryptedSubject =
|
|
502
|
+
conversation.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? messages.map((message) => decryptedMessages[message.uid]?.subject).find(Boolean) : undefined;
|
|
503
|
+
const subject = (decryptedSubject ?? displaySubject(conversation.subject)) || "(no subject)";
|
|
499
504
|
// The newest message that is open carries the Reply / Forward buttons at the foot of its card.
|
|
500
505
|
const footerUid = messages.find((message) => expandedUids.has(message.uid))?.uid;
|
|
501
506
|
|
|
@@ -80,14 +80,16 @@ import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
|
80
80
|
import LabelMenuButton from "./labelMenu.js";
|
|
81
81
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
82
82
|
import EncryptedBody from "./reading/EncryptedBody.js";
|
|
83
|
-
import { displaySubject } from "./reading/EncryptedPreview.js";
|
|
83
|
+
import { ENCRYPTED_SUBJECT_PLACEHOLDER, displaySubject } from "./reading/EncryptedPreview.js";
|
|
84
|
+
import { rememberDecrypted } from "../../mail/decryptedMessages.js";
|
|
84
85
|
import MessageBody, { BodySkeleton } from "./reading/MessageBody.js";
|
|
85
86
|
import { BODY_FONT_STYLE, CardShell, SenderAvatar, SubjectCard } from "./reading/MessageCard.js";
|
|
86
87
|
import MessageMoreMenu, { MessageMenuActions } from "./reading/MessageMoreMenu.js";
|
|
87
88
|
import MessageSourceDialog, { SourceMode, SourceState } from "./reading/MessageSourceDialog.js";
|
|
88
89
|
import { useMessageActions } from "./reading/useMessageActions.js";
|
|
89
90
|
import { saveAsEml } from "./reading/messageExport.js";
|
|
90
|
-
import {
|
|
91
|
+
import { buildConversationPrintDocument, printDocument, type PrintableMessage } from "./reading/printMessage.js";
|
|
92
|
+
import { olderConversationMessages, printHeaders, printableOlderMessage } from "./reading/conversationPrint.js";
|
|
91
93
|
import { type BodyContent, fetchBodyContent } from "./reading/bodyContent.js";
|
|
92
94
|
import { useViewOriginal } from "./reading/viewOriginal.js";
|
|
93
95
|
import { ROW_FOCUS_CLASS, UnreadLabel, dateClass, senderClass } from "./unreadStyle.js";
|
|
@@ -1042,7 +1044,14 @@ function MessageDetailContent({
|
|
|
1042
1044
|
? senderKeyState!.conflict
|
|
1043
1045
|
: undefined;
|
|
1044
1046
|
|
|
1045
|
-
|
|
1047
|
+
// An encrypted message's outer Subject is only RFC 9788's placeholder: whatever decrypting recovered is the subject, signed or not.
|
|
1048
|
+
const decryptedSubject = message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? security?.subject : undefined;
|
|
1049
|
+
const shownSubject = (protectedSubject ?? decryptedSubject ?? displaySubject(message.subject)) || "(no subject)";
|
|
1050
|
+
useEffect(() => {
|
|
1051
|
+
if (decryptedSubject) {
|
|
1052
|
+
rememberDecrypted(message.uid, message.mailboxUid, { subject: decryptedSubject });
|
|
1053
|
+
}
|
|
1054
|
+
}, [decryptedSubject, message.uid, message.mailboxUid]);
|
|
1046
1055
|
// 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
|
|
1047
1056
|
// happens in `MessageBody`, like every body's); anything else is the server's own `/content`. An encrypted message has nothing worth
|
|
1048
1057
|
// fetching until it has been decrypted (the server only has the ciphertext), so it shows the skeleton meanwhile.
|
|
@@ -1104,24 +1113,34 @@ function MessageDetailContent({
|
|
|
1104
1113
|
|
|
1105
1114
|
// ---- The "More actions" menu's own rows (Print, View, Save as, Create rule); the rest are `messageActions`. ----
|
|
1106
1115
|
|
|
1107
|
-
/** Prints this
|
|
1108
|
-
*
|
|
1116
|
+
/** Prints this message and the conversation it belongs to: the message first, then every earlier one, newest to oldest (the messages that
|
|
1117
|
+
* 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
|
|
1118
|
+
* sanitized body - in a frame of its own; see `printMessage.ts`. */
|
|
1109
1119
|
async function handlePrint() {
|
|
1110
1120
|
try {
|
|
1111
1121
|
const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
|
|
1112
|
-
const
|
|
1113
|
-
const
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1122
|
+
const older = await olderConversationMessages(message, client).catch(() => []);
|
|
1123
|
+
const earlier = await Promise.all(
|
|
1124
|
+
older.map((other) =>
|
|
1125
|
+
printableOlderMessage(other).catch(
|
|
1126
|
+
(): PrintableMessage => ({
|
|
1127
|
+
subject: other.subject || "(no subject)",
|
|
1128
|
+
headers: printHeaders(other, formatMailAddress(other.from)),
|
|
1129
|
+
content: { kind: "text", text: "This message could not be loaded, so it is not printed." },
|
|
1130
|
+
}),
|
|
1131
|
+
),
|
|
1132
|
+
),
|
|
1133
|
+
);
|
|
1134
|
+
const printable = buildConversationPrintDocument(shownSubject, [
|
|
1135
|
+
{
|
|
1136
|
+
subject: shownSubject,
|
|
1137
|
+
headers: printHeaders(message, senderLabel),
|
|
1138
|
+
content,
|
|
1139
|
+
attachments,
|
|
1140
|
+
inlineParts: innerAttachments,
|
|
1141
|
+
},
|
|
1142
|
+
...earlier,
|
|
1143
|
+
]);
|
|
1125
1144
|
if (printable === undefined) {
|
|
1126
1145
|
notify({ kind: "warning", title: "This message is too large to print here" });
|
|
1127
1146
|
return;
|
|
@@ -6,7 +6,7 @@ import React, { useRef, useState } from "react";
|
|
|
6
6
|
import { HiOutlineCalendarDays } from "react-icons/hi2";
|
|
7
7
|
import type { Message } from "../../../../../lib/mail/mailApi.js";
|
|
8
8
|
import type { MessageInvite } from "../../../../../lib/calendar/inviteApi.js";
|
|
9
|
-
import {
|
|
9
|
+
import { formatRowStart } from "./inviteFormat.js";
|
|
10
10
|
import { useMessageInvite } from "./inviteStore.js";
|
|
11
11
|
import InviteRsvpPopover from "./InviteRsvpPopover.js";
|
|
12
12
|
|
|
@@ -25,8 +25,8 @@ export function showsInviteChip(message: Pick<Message, "meetingMethod" | "encryp
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* 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
|
|
28
|
-
* zone), under that whether it conflicts with the reader's calendar
|
|
29
|
-
* Accept, Decline and
|
|
28
|
+
* zone), under that whether it conflicts with the reader's calendar, and an RSVP button that opens the day view with
|
|
29
|
+
* Accept, Decline and Maybe (`InviteRsvpPopover`). Once the meeting has been answered - from here or anywhere else - the chip is gone.
|
|
30
30
|
*
|
|
31
31
|
* 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
|
|
32
32
|
* it arrives, or when the lookup fails, the chip says only "Meeting request", with no button.
|
|
@@ -39,11 +39,12 @@ export default function InviteRowChip({ message, onResponded, className = "mx-4"
|
|
|
39
39
|
const { invite, setInvite } = useMessageInvite(message.uid, eligible);
|
|
40
40
|
const [open, setOpen] = useState(false);
|
|
41
41
|
const rsvpRef = useRef<HTMLButtonElement>(null);
|
|
42
|
-
|
|
42
|
+
const answer = invite?.response ?? message.meetingResponse;
|
|
43
|
+
// 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).
|
|
44
|
+
if (!eligible || answer) {
|
|
43
45
|
return null;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
const answer = invite?.response ?? message.meetingResponse;
|
|
47
48
|
const conflicts = invite?.conflicts.length ?? 0;
|
|
48
49
|
const start = invite ? formatRowStart(invite) : undefined;
|
|
49
50
|
|
|
@@ -67,13 +68,8 @@ export default function InviteRowChip({ message, onResponded, className = "mx-4"
|
|
|
67
68
|
<div className="min-w-0 flex-1">
|
|
68
69
|
<div className="truncate text-text">{start ?? "Meeting request"}</div>
|
|
69
70
|
{invite && (
|
|
70
|
-
<div
|
|
71
|
-
|
|
72
|
-
"truncate",
|
|
73
|
-
answer === "accepted" ? "text-success" : answer === "declined" ? "text-danger" : conflicts > 0 ? "text-danger" : "text-text-muted",
|
|
74
|
-
].join(" ")}
|
|
75
|
-
>
|
|
76
|
-
{answer ? ANSWER_LABEL[answer] : conflicts > 0 ? `Conflicts with ${conflicts} ${conflicts === 1 ? "event" : "events"}` : "No conflicts"}
|
|
71
|
+
<div className={["truncate", conflicts > 0 ? "text-danger" : "text-text-muted"].join(" ")}>
|
|
72
|
+
{conflicts > 0 ? `Conflicts with ${conflicts} ${conflicts === 1 ? "event" : "events"}` : "No conflicts"}
|
|
77
73
|
</div>
|
|
78
74
|
)}
|
|
79
75
|
</div>
|
|
@@ -42,7 +42,7 @@ function estimateHeight(invite: MessageInvite, proposing: boolean): number {
|
|
|
42
42
|
const RESPONSES: { response: InviteResponse; label: string; text: string; failure: string }[] = [
|
|
43
43
|
{ response: "accepted", label: "Accept", text: "Accept", failure: "Couldn't accept this meeting" },
|
|
44
44
|
{ response: "declined", label: "Decline", text: "Decline", failure: "Couldn't decline this meeting" },
|
|
45
|
-
{ response: "tentative", label: "Tentative", text: "
|
|
45
|
+
{ response: "tentative", label: "Tentative", text: "Maybe", failure: "Couldn't respond tentatively to this meeting" },
|
|
46
46
|
];
|
|
47
47
|
|
|
48
48
|
interface RsvpPanelProps {
|
|
@@ -236,7 +236,7 @@ export interface InviteRsvpPopoverProps {
|
|
|
236
236
|
|
|
237
237
|
/**
|
|
238
238
|
* 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
|
|
239
|
-
* the invitation highlighted and its conflicts marked, and Accept, Decline, a "
|
|
239
|
+
* the invitation highlighted and its conflicts marked, and Accept, Decline, a "Maybe" for Tentative and a "..." menu (Propose new time, Open in calendar).
|
|
240
240
|
* 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
|
|
241
241
|
* updated invitation to its caller. A failure raises a pop-up and leaves the popover open.
|
|
242
242
|
*
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import type { Attachment } from "../../../../../lib/mail/mailApi.js";
|
|
6
6
|
import type { InviteResponse, MessageInvite } from "../../../../../lib/calendar/inviteApi.js";
|
|
7
|
+
import { describeTimeZone, deviceTimeZone } from "../../../../../lib/util/timeZone.js";
|
|
7
8
|
import { APP_HREFS } from "../../../navigation/appHrefs.js";
|
|
8
9
|
|
|
9
10
|
/** Whether an attachment is a calendar file - what tells the pane a message may carry an invitation worth asking the server about. By type
|
|
@@ -53,15 +54,15 @@ export function formatInviteWhen(invite: Pick<MessageInvite, "startDate" | "endD
|
|
|
53
54
|
}
|
|
54
55
|
const date = new Intl.DateTimeFormat(undefined, DAY_FORMAT);
|
|
55
56
|
const time = new Intl.DateTimeFormat(undefined, CLOCK_FORMAT);
|
|
56
|
-
// The zone is named once, after the last time shown: the times are the reader's own, not the organizer's.
|
|
57
|
-
const
|
|
57
|
+
// The zone is named once, in full, after the last time shown: the times are the reader's own, not the organizer's.
|
|
58
|
+
const zone = describeTimeZone(deviceTimeZone(), end ?? start);
|
|
58
59
|
const startDay = date.format(start);
|
|
59
60
|
if (!end) {
|
|
60
|
-
return `${startDay}, ${
|
|
61
|
+
return `${startDay}, ${time.format(start)} • ${zone}`;
|
|
61
62
|
}
|
|
62
63
|
return startDay === date.format(end)
|
|
63
|
-
? `${startDay}, ${time.format(start)} - ${
|
|
64
|
-
: `${startDay}, ${time.format(start)} - ${date.format(end)}, ${
|
|
64
|
+
? `${startDay}, ${time.format(start)} - ${time.format(end)} • ${zone}`
|
|
65
|
+
: `${startDay}, ${time.format(start)} - ${date.format(end)}, ${time.format(end)} • ${zone}`;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
/** The meeting's start the way a list row writes it - `Thu 9/24/2026 1:00 PM` (dates only for an all-day event), in the reader's zone. */
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { getUnlockedKeys } from "../../../../../lib/crypto/keySession.js";
|
|
6
|
+
import { listConversationMessages } from "../../../../../lib/mail/conversationsApi.js";
|
|
7
|
+
import { formatMailAddress } from "../../../../../lib/mail/mailAddress.js";
|
|
8
|
+
import { getMessageRawContent, listAttachments, type Message, type Recipient } from "../../../../../lib/mail/mailApi.js";
|
|
9
|
+
import type { ApiClient } from "../../../../../lib/util/api.js";
|
|
10
|
+
import { fetchBodyContent } from "./bodyContent.js";
|
|
11
|
+
import { ENCRYPTED_SUBJECT_PLACEHOLDER, displaySubject } from "./EncryptedPreview.js";
|
|
12
|
+
import type { PrintableMessage } from "./printMessage.js";
|
|
13
|
+
|
|
14
|
+
const format = (list: Recipient[]) => list.map((r) => formatMailAddress(r)).join(", ");
|
|
15
|
+
|
|
16
|
+
/** The header lines printed above a message's body. */
|
|
17
|
+
export function printHeaders(message: Message, from: string): PrintableMessage["headers"] {
|
|
18
|
+
return [
|
|
19
|
+
{ name: "From", value: from },
|
|
20
|
+
{ name: "To", value: format(message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")) },
|
|
21
|
+
{ name: "Cc", value: format(message.recipients.filter((r) => r.type === "cc")) },
|
|
22
|
+
{ name: "Date", value: new Date(message.receivedDate).toLocaleString() },
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The messages of `message`'s conversation that came before it, newest first - the order they follow it in a printed conversation. A conversation
|
|
28
|
+
* the server has no id for (or one that can't be read) has none: the message prints alone.
|
|
29
|
+
*/
|
|
30
|
+
export async function olderConversationMessages(message: Message, client?: ApiClient): Promise<Message[]> {
|
|
31
|
+
if (!message.conversationId) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const thread = await listConversationMessages(message.mailboxUid, message.conversationId, { limit: 500 }, client);
|
|
35
|
+
const sent = new Date(message.receivedDate).getTime();
|
|
36
|
+
return thread
|
|
37
|
+
.filter((other) => other.uid !== message.uid && new Date(other.receivedDate).getTime() <= sent)
|
|
38
|
+
.sort((a, b) => new Date(b.receivedDate).getTime() - new Date(a.receivedDate).getTime());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
|
|
43
|
+
* its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
|
|
44
|
+
*/
|
|
45
|
+
export async function printableOlderMessage(message: Message): Promise<PrintableMessage> {
|
|
46
|
+
const attachments = await listAttachments(message.folderUid, message.uid).catch(() => []);
|
|
47
|
+
if (message.encrypted) {
|
|
48
|
+
const unlocked = getUnlockedKeys(message.mailboxUid);
|
|
49
|
+
if (unlocked) {
|
|
50
|
+
const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
|
|
51
|
+
const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid), unlocked);
|
|
52
|
+
const content =
|
|
53
|
+
security.text !== undefined ? ({ kind: "text", text: security.text } as const) : security.html !== undefined ? ({ kind: "html", html: security.html } as const) : undefined;
|
|
54
|
+
if (content) {
|
|
55
|
+
return {
|
|
56
|
+
subject: (message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? security.subject : undefined) || displaySubject(message.subject) || "(no subject)",
|
|
57
|
+
headers: printHeaders(message, formatMailAddress(message.from)),
|
|
58
|
+
content,
|
|
59
|
+
attachments,
|
|
60
|
+
inlineParts: security.attachments,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
subject: displaySubject(message.subject) || "(no subject)",
|
|
66
|
+
headers: printHeaders(message, formatMailAddress(message.from)),
|
|
67
|
+
content: { kind: "text", text: "This message is encrypted and could not be decrypted, so it is not printed." },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
subject: message.subject || "(no subject)",
|
|
72
|
+
headers: printHeaders(message, formatMailAddress(message.from)),
|
|
73
|
+
content: await fetchBodyContent(message.uid, message.version),
|
|
74
|
+
attachments,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -9,7 +9,7 @@ import { prepareBodyHtml } from "./bodyHtml.js";
|
|
|
9
9
|
import { frameCsp } from "./frameDocument.js";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Printing
|
|
12
|
+
* Printing a message, and the conversation it belongs to: a document of its own - each message's header lines, and under them the same sanitized body the reading pane draws -
|
|
13
13
|
* shown in a frame that nobody sees and printed from there, so the print is the message and not the app around it.
|
|
14
14
|
*
|
|
15
15
|
* The frame is as isolated as the reading pane's own (`frameDocument.ts`): the body has been through `prepareBodyHtml()`, the document
|
|
@@ -35,6 +35,8 @@ dl{margin:0 0 12px;display:grid;grid-template-columns:max-content 1fr;gap:2px 12
|
|
|
35
35
|
dt{font-weight:600;color:#444}
|
|
36
36
|
dd{margin:0;overflow-wrap:anywhere}
|
|
37
37
|
hr{border:0;border-top:1px solid #999;margin:0 0 16px}
|
|
38
|
+
h2{font-size:16px;margin:0 0 12px}
|
|
39
|
+
.rr-older{margin-top:28px;padding-top:20px;border-top:2px solid #000;break-inside:auto}
|
|
38
40
|
#rr-body{overflow-wrap:break-word}
|
|
39
41
|
#rr-body img{max-width:100%;height:auto}
|
|
40
42
|
#rr-body pre,pre.rr-text{white-space:pre-wrap;font:inherit;margin:0}
|
|
@@ -44,10 +46,14 @@ export function escapeHtml(text: string): string {
|
|
|
44
46
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
interface PrintSection {
|
|
50
|
+
/** The `<dl>`, rule and body of one message. */
|
|
51
|
+
html: string;
|
|
52
|
+
imageSource?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One message's header lines and body, or `undefined` for a body too large to prepare (the pane does not draw it either). */
|
|
56
|
+
function renderSection(message: PrintableMessage): PrintSection | undefined {
|
|
51
57
|
let body: string;
|
|
52
58
|
let imageSource: string | undefined;
|
|
53
59
|
if (message.content.kind === "html") {
|
|
@@ -65,13 +71,44 @@ export function buildPrintDocument(message: PrintableMessage): string | undefine
|
|
|
65
71
|
.filter((header) => header.value !== "")
|
|
66
72
|
.map((header) => `<dt>${escapeHtml(header.name)}</dt><dd>${escapeHtml(header.value)}</dd>`)
|
|
67
73
|
.join("");
|
|
74
|
+
return { html: `<dl>${lines}</dl><hr>${body}`, imageSource };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The document to print, or `undefined` for a body too large to prepare (the pane does not draw it either).
|
|
79
|
+
*/
|
|
80
|
+
export function buildPrintDocument(message: PrintableMessage): string | undefined {
|
|
81
|
+
return buildConversationPrintDocument(message.subject, [message]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The document to print for a conversation: `subject` as the title, then each of `messages` in the order given (the message that was asked for
|
|
86
|
+
* first, then the ones before it), each under its own header lines and set apart from the last. A message whose subject differs from the title
|
|
87
|
+
* says so under its rule. Only the first message is required to be printable: a later one too large to prepare is replaced by a note saying so.
|
|
88
|
+
*/
|
|
89
|
+
export function buildConversationPrintDocument(subject: string, messages: PrintableMessage[]): string | undefined {
|
|
90
|
+
const sections: string[] = [];
|
|
91
|
+
let imageSource: string | undefined;
|
|
92
|
+
for (const [index, message] of messages.entries()) {
|
|
93
|
+
const section = renderSection(message);
|
|
94
|
+
if (section === undefined && index === 0) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
imageSource ??= section?.imageSource;
|
|
98
|
+
const html = section?.html ?? `<p>${escapeHtml(`"${message.subject}" is too large to print here.`)}</p>`;
|
|
99
|
+
sections.push(
|
|
100
|
+
index === 0
|
|
101
|
+
? html
|
|
102
|
+
: `<div class="rr-older">${message.subject !== subject ? `<h2>${escapeHtml(message.subject)}</h2>` : ""}${html}</div>`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
68
105
|
return [
|
|
69
106
|
"<!doctype html>",
|
|
70
107
|
'<html lang="en"><head><meta charset="utf-8">',
|
|
71
108
|
`<meta http-equiv="Content-Security-Policy" content="${frameCsp(imageSource)}">`,
|
|
72
|
-
`<title>${escapeHtml(
|
|
109
|
+
`<title>${escapeHtml(subject)}</title>`,
|
|
73
110
|
`<style>${PRINT_CSS}</style></head>`,
|
|
74
|
-
`<body><h1>${escapeHtml(
|
|
111
|
+
`<body><h1>${escapeHtml(subject)}</h1>${sections.join("")}</body></html>`,
|
|
75
112
|
].join("");
|
|
76
113
|
}
|
|
77
114
|
|
|
@@ -66,6 +66,7 @@ export const SHORTCUTS = {
|
|
|
66
66
|
replyAll: def({ id: "mail.replyAll", keys: ["mod+shift+r"], label: "Reply all", scope: "mail" }),
|
|
67
67
|
forward: def({ id: "mail.forward", keys: ["mod+shift+f"], label: "Forward", scope: "mail" }),
|
|
68
68
|
delete: def({ id: "mail.delete", keys: ["mod+d", "delete"], label: "Delete", scope: "mail" }),
|
|
69
|
+
permanentDelete: def({ id: "mail.permanentDelete", keys: ["shift+delete"], label: "Delete permanently", scope: "mail" }),
|
|
69
70
|
archive: def({ id: "mail.archive", keys: ["e", "backspace"], label: "Archive", scope: "mail" }),
|
|
70
71
|
move: def({ id: "mail.move", keys: ["mod+shift+v"], label: "Move to folder", scope: "mail" }),
|
|
71
72
|
markRead: def({ id: "mail.markRead", keys: ["ctrl+q"], label: "Mark as read", scope: "mail" }),
|