@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describeTimeZone, deviceTimeZone } from "../../../../../lib/util/timeZone.js";
|
|
1
2
|
import { APP_HREFS } from "../../../navigation/appHrefs.js";
|
|
2
3
|
/** Whether an attachment is a calendar file - what tells the pane a message may carry an invitation worth asking the server about. By type
|
|
3
4
|
* or by name, since mail clients disagree about which one they set (`text/calendar`, `application/ics`, or a bare `.ics` as octet-stream). */
|
|
@@ -43,15 +44,15 @@ export function formatInviteWhen(invite) {
|
|
|
43
44
|
}
|
|
44
45
|
const date = new Intl.DateTimeFormat(undefined, DAY_FORMAT);
|
|
45
46
|
const time = new Intl.DateTimeFormat(undefined, CLOCK_FORMAT);
|
|
46
|
-
// The zone is named once, after the last time shown: the times are the reader's own, not the organizer's.
|
|
47
|
-
const
|
|
47
|
+
// The zone is named once, in full, after the last time shown: the times are the reader's own, not the organizer's.
|
|
48
|
+
const zone = describeTimeZone(deviceTimeZone(), end ?? start);
|
|
48
49
|
const startDay = date.format(start);
|
|
49
50
|
if (!end) {
|
|
50
|
-
return `${startDay}, ${
|
|
51
|
+
return `${startDay}, ${time.format(start)} • ${zone}`;
|
|
51
52
|
}
|
|
52
53
|
return startDay === date.format(end)
|
|
53
|
-
? `${startDay}, ${time.format(start)} - ${
|
|
54
|
-
: `${startDay}, ${time.format(start)} - ${date.format(end)}, ${
|
|
54
|
+
? `${startDay}, ${time.format(start)} - ${time.format(end)} • ${zone}`
|
|
55
|
+
: `${startDay}, ${time.format(start)} - ${date.format(end)}, ${time.format(end)} • ${zone}`;
|
|
55
56
|
}
|
|
56
57
|
/** 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. */
|
|
57
58
|
export function formatRowStart(invite) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Message } from "../../../../../lib/mail/mailApi.js";
|
|
2
|
+
import type { ApiClient } from "../../../../../lib/util/api.js";
|
|
3
|
+
import type { PrintableMessage } from "./printMessage.js";
|
|
4
|
+
/** The header lines printed above a message's body. */
|
|
5
|
+
export declare function printHeaders(message: Message, from: string): PrintableMessage["headers"];
|
|
6
|
+
/**
|
|
7
|
+
* The messages of `message`'s conversation that came before it, newest first - the order they follow it in a printed conversation. A conversation
|
|
8
|
+
* the server has no id for (or one that can't be read) has none: the message prints alone.
|
|
9
|
+
*/
|
|
10
|
+
export declare function olderConversationMessages(message: Message, client?: ApiClient): Promise<Message[]>;
|
|
11
|
+
/**
|
|
12
|
+
* 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
|
|
13
|
+
* its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
|
|
14
|
+
*/
|
|
15
|
+
export declare function printableOlderMessage(message: Message): Promise<PrintableMessage>;
|
|
@@ -0,0 +1,69 @@
|
|
|
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 } from "../../../../../lib/mail/mailApi.js";
|
|
9
|
+
import { fetchBodyContent } from "./bodyContent.js";
|
|
10
|
+
import { ENCRYPTED_SUBJECT_PLACEHOLDER, displaySubject } from "./EncryptedPreview.js";
|
|
11
|
+
const format = (list) => list.map((r) => formatMailAddress(r)).join(", ");
|
|
12
|
+
/** The header lines printed above a message's body. */
|
|
13
|
+
export function printHeaders(message, from) {
|
|
14
|
+
return [
|
|
15
|
+
{ name: "From", value: from },
|
|
16
|
+
{ name: "To", value: format(message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc")) },
|
|
17
|
+
{ name: "Cc", value: format(message.recipients.filter((r) => r.type === "cc")) },
|
|
18
|
+
{ name: "Date", value: new Date(message.receivedDate).toLocaleString() },
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The messages of `message`'s conversation that came before it, newest first - the order they follow it in a printed conversation. A conversation
|
|
23
|
+
* the server has no id for (or one that can't be read) has none: the message prints alone.
|
|
24
|
+
*/
|
|
25
|
+
export async function olderConversationMessages(message, client) {
|
|
26
|
+
if (!message.conversationId) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const thread = await listConversationMessages(message.mailboxUid, message.conversationId, { limit: 500 }, client);
|
|
30
|
+
const sent = new Date(message.receivedDate).getTime();
|
|
31
|
+
return thread
|
|
32
|
+
.filter((other) => other.uid !== message.uid && new Date(other.receivedDate).getTime() <= sent)
|
|
33
|
+
.sort((a, b) => new Date(b.receivedDate).getTime() - new Date(a.receivedDate).getTime());
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 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
|
|
37
|
+
* its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
|
|
38
|
+
*/
|
|
39
|
+
export async function printableOlderMessage(message) {
|
|
40
|
+
const attachments = await listAttachments(message.folderUid, message.uid).catch(() => []);
|
|
41
|
+
if (message.encrypted) {
|
|
42
|
+
const unlocked = getUnlockedKeys(message.mailboxUid);
|
|
43
|
+
if (unlocked) {
|
|
44
|
+
const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
|
|
45
|
+
const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid), unlocked);
|
|
46
|
+
const content = security.text !== undefined ? { kind: "text", text: security.text } : security.html !== undefined ? { kind: "html", html: security.html } : undefined;
|
|
47
|
+
if (content) {
|
|
48
|
+
return {
|
|
49
|
+
subject: (message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? security.subject : undefined) || displaySubject(message.subject) || "(no subject)",
|
|
50
|
+
headers: printHeaders(message, formatMailAddress(message.from)),
|
|
51
|
+
content,
|
|
52
|
+
attachments,
|
|
53
|
+
inlineParts: security.attachments,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
subject: displaySubject(message.subject) || "(no subject)",
|
|
59
|
+
headers: printHeaders(message, formatMailAddress(message.from)),
|
|
60
|
+
content: { kind: "text", text: "This message is encrypted and could not be decrypted, so it is not printed." },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
subject: message.subject || "(no subject)",
|
|
65
|
+
headers: printHeaders(message, formatMailAddress(message.from)),
|
|
66
|
+
content: await fetchBodyContent(message.uid, message.version),
|
|
67
|
+
attachments,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { Attachment } from "../../../../../lib/mail/mailApi.js";
|
|
|
2
2
|
import type { MimeAttachment } from "../../../../../lib/crypto/mime.js";
|
|
3
3
|
import { BodyContent } from "./bodyContent.js";
|
|
4
4
|
/**
|
|
5
|
-
* Printing
|
|
5
|
+
* 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 -
|
|
6
6
|
* shown in a frame that nobody sees and printed from there, so the print is the message and not the app around it.
|
|
7
7
|
*
|
|
8
8
|
* The frame is as isolated as the reading pane's own (`frameDocument.ts`): the body has been through `prepareBodyHtml()`, the document
|
|
@@ -26,5 +26,11 @@ export declare function escapeHtml(text: string): string;
|
|
|
26
26
|
* The document to print, or `undefined` for a body too large to prepare (the pane does not draw it either).
|
|
27
27
|
*/
|
|
28
28
|
export declare function buildPrintDocument(message: PrintableMessage): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* 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
|
|
31
|
+
* 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
|
|
32
|
+
* 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.
|
|
33
|
+
*/
|
|
34
|
+
export declare function buildConversationPrintDocument(subject: string, messages: PrintableMessage[]): string | undefined;
|
|
29
35
|
/** Prints `html` (from `buildPrintDocument()`) from a frame nobody sees, and removes the frame when the print is over. */
|
|
30
36
|
export declare function printDocument(html: string): void;
|
|
@@ -9,6 +9,8 @@ dl{margin:0 0 12px;display:grid;grid-template-columns:max-content 1fr;gap:2px 12
|
|
|
9
9
|
dt{font-weight:600;color:#444}
|
|
10
10
|
dd{margin:0;overflow-wrap:anywhere}
|
|
11
11
|
hr{border:0;border-top:1px solid #999;margin:0 0 16px}
|
|
12
|
+
h2{font-size:16px;margin:0 0 12px}
|
|
13
|
+
.rr-older{margin-top:28px;padding-top:20px;border-top:2px solid #000;break-inside:auto}
|
|
12
14
|
#rr-body{overflow-wrap:break-word}
|
|
13
15
|
#rr-body img{max-width:100%;height:auto}
|
|
14
16
|
#rr-body pre,pre.rr-text{white-space:pre-wrap;font:inherit;margin:0}
|
|
@@ -16,10 +18,8 @@ hr{border:0;border-top:1px solid #999;margin:0 0 16px}
|
|
|
16
18
|
export function escapeHtml(text) {
|
|
17
19
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
18
20
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
*/
|
|
22
|
-
export function buildPrintDocument(message) {
|
|
21
|
+
/** One message's header lines and body, or `undefined` for a body too large to prepare (the pane does not draw it either). */
|
|
22
|
+
function renderSection(message) {
|
|
23
23
|
let body;
|
|
24
24
|
let imageSource;
|
|
25
25
|
if (message.content.kind === "html") {
|
|
@@ -38,13 +38,40 @@ export function buildPrintDocument(message) {
|
|
|
38
38
|
.filter((header) => header.value !== "")
|
|
39
39
|
.map((header) => `<dt>${escapeHtml(header.name)}</dt><dd>${escapeHtml(header.value)}</dd>`)
|
|
40
40
|
.join("");
|
|
41
|
+
return { html: `<dl>${lines}</dl><hr>${body}`, imageSource };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The document to print, or `undefined` for a body too large to prepare (the pane does not draw it either).
|
|
45
|
+
*/
|
|
46
|
+
export function buildPrintDocument(message) {
|
|
47
|
+
return buildConversationPrintDocument(message.subject, [message]);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 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
|
|
51
|
+
* 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
|
|
52
|
+
* 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.
|
|
53
|
+
*/
|
|
54
|
+
export function buildConversationPrintDocument(subject, messages) {
|
|
55
|
+
const sections = [];
|
|
56
|
+
let imageSource;
|
|
57
|
+
for (const [index, message] of messages.entries()) {
|
|
58
|
+
const section = renderSection(message);
|
|
59
|
+
if (section === undefined && index === 0) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
imageSource ?? (imageSource = section?.imageSource);
|
|
63
|
+
const html = section?.html ?? `<p>${escapeHtml(`"${message.subject}" is too large to print here.`)}</p>`;
|
|
64
|
+
sections.push(index === 0
|
|
65
|
+
? html
|
|
66
|
+
: `<div class="rr-older">${message.subject !== subject ? `<h2>${escapeHtml(message.subject)}</h2>` : ""}${html}</div>`);
|
|
67
|
+
}
|
|
41
68
|
return [
|
|
42
69
|
"<!doctype html>",
|
|
43
70
|
'<html lang="en"><head><meta charset="utf-8">',
|
|
44
71
|
`<meta http-equiv="Content-Security-Policy" content="${frameCsp(imageSource)}">`,
|
|
45
|
-
`<title>${escapeHtml(
|
|
72
|
+
`<title>${escapeHtml(subject)}</title>`,
|
|
46
73
|
`<style>${PRINT_CSS}</style></head>`,
|
|
47
|
-
`<body><h1>${escapeHtml(
|
|
74
|
+
`<body><h1>${escapeHtml(subject)}</h1>${sections.join("")}</body></html>`,
|
|
48
75
|
].join("");
|
|
49
76
|
}
|
|
50
77
|
/** How long a print frame is kept when the browser never says it has finished with it (`afterprint`). */
|
|
@@ -107,6 +107,12 @@ export declare const SHORTCUTS: {
|
|
|
107
107
|
label: string;
|
|
108
108
|
scope: "mail";
|
|
109
109
|
};
|
|
110
|
+
readonly permanentDelete: {
|
|
111
|
+
id: string;
|
|
112
|
+
keys: string[];
|
|
113
|
+
label: string;
|
|
114
|
+
scope: "mail";
|
|
115
|
+
};
|
|
110
116
|
readonly archive: {
|
|
111
117
|
id: string;
|
|
112
118
|
keys: string[];
|
|
@@ -38,6 +38,7 @@ export const SHORTCUTS = {
|
|
|
38
38
|
replyAll: def({ id: "mail.replyAll", keys: ["mod+shift+r"], label: "Reply all", scope: "mail" }),
|
|
39
39
|
forward: def({ id: "mail.forward", keys: ["mod+shift+f"], label: "Forward", scope: "mail" }),
|
|
40
40
|
delete: def({ id: "mail.delete", keys: ["mod+d", "delete"], label: "Delete", scope: "mail" }),
|
|
41
|
+
permanentDelete: def({ id: "mail.permanentDelete", keys: ["shift+delete"], label: "Delete permanently", scope: "mail" }),
|
|
41
42
|
archive: def({ id: "mail.archive", keys: ["e", "backspace"], label: "Archive", scope: "mail" }),
|
|
42
43
|
move: def({ id: "mail.move", keys: ["mod+shift+v"], label: "Move to folder", scope: "mail" }),
|
|
43
44
|
markRead: def({ id: "mail.markRead", keys: ["ctrl+q"], label: "Mark as read", scope: "mail" }),
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this device has recovered of encrypted messages by decrypting them: the real subject (the server only ever holds RFC 9788's `[...]`
|
|
3
|
+
* placeholder) and a short preview. Shared by every view that draws a message - the message list, the conversation list and the reading pane -
|
|
4
|
+
* so a message decrypted in one shows its subject in all of them. Held in memory only, keyed by message uid, and dropped for a mailbox the moment
|
|
5
|
+
* its keys are locked, exactly like the decrypted content it came from.
|
|
6
|
+
*/
|
|
7
|
+
export interface DecryptedMessage {
|
|
8
|
+
mailboxUid: string;
|
|
9
|
+
subject?: string;
|
|
10
|
+
preview?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Plain text of decrypted HTML for a one-line preview. Only ever feeds text display, never markup. */
|
|
13
|
+
export declare function previewOf(html: string): string;
|
|
14
|
+
/** Remembers what was recovered of one message. Ignored when there is nothing new to show. */
|
|
15
|
+
export declare function rememberDecrypted(uid: string, mailboxUid: string, found: {
|
|
16
|
+
subject?: string;
|
|
17
|
+
preview?: string;
|
|
18
|
+
}): void;
|
|
19
|
+
/** Every message decrypted so far, by uid; a new object whenever any changes. */
|
|
20
|
+
export declare function useDecryptedMessages(): Record<string, DecryptedMessage>;
|
|
21
|
+
/** A number that changes whenever a mailbox is unlocked - a dependency for effects that decrypt what they can. */
|
|
22
|
+
export declare function useUnlockEpoch(): number;
|
|
23
|
+
/**
|
|
24
|
+
* Decrypts one message to recover its subject and preview, when its mailbox's keys are unlocked and it isn't already known. Best effort: a
|
|
25
|
+
* message that can't be fetched or opened is simply left as it was. Never prompts.
|
|
26
|
+
*/
|
|
27
|
+
export declare function decryptForDisplay(uid: string, mailboxUid: string): Promise<void>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useSyncExternalStore } from "react";
|
|
6
|
+
import { getUnlockedKeys, subscribeKeySession } from "../../../lib/crypto/keySession.js";
|
|
7
|
+
import { getMessageRawContent } from "../../../lib/mail/mailApi.js";
|
|
8
|
+
const PREVIEW_MAX_LENGTH = 160;
|
|
9
|
+
let entries = {};
|
|
10
|
+
/** Bumped whenever a mailbox's keys are unlocked, so a view can retry decrypting what it couldn't before. */
|
|
11
|
+
let unlockEpoch = 0;
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
const inFlight = new Set();
|
|
14
|
+
function emit() {
|
|
15
|
+
for (const listener of [...listeners]) {
|
|
16
|
+
listener();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
subscribeKeySession(({ mailboxUid, state }) => {
|
|
20
|
+
if (state === "locked") {
|
|
21
|
+
const kept = Object.fromEntries(Object.entries(entries).filter(([, entry]) => entry.mailboxUid !== mailboxUid));
|
|
22
|
+
if (Object.keys(kept).length !== Object.keys(entries).length) {
|
|
23
|
+
entries = kept;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
unlockEpoch++;
|
|
28
|
+
}
|
|
29
|
+
emit();
|
|
30
|
+
});
|
|
31
|
+
function subscribe(listener) {
|
|
32
|
+
listeners.add(listener);
|
|
33
|
+
return () => {
|
|
34
|
+
listeners.delete(listener);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Plain text of decrypted HTML for a one-line preview. Only ever feeds text display, never markup. */
|
|
38
|
+
export function previewOf(html) {
|
|
39
|
+
return html
|
|
40
|
+
.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
|
41
|
+
.replace(/<[^>]+>/g, " ")
|
|
42
|
+
.replace(/ /gi, " ")
|
|
43
|
+
.replace(/&/gi, "&")
|
|
44
|
+
.replace(/</gi, "<")
|
|
45
|
+
.replace(/>/gi, ">")
|
|
46
|
+
.replace(/"/gi, '"')
|
|
47
|
+
.replace(/�*39;/gi, "'")
|
|
48
|
+
.replace(/\s+/g, " ")
|
|
49
|
+
.trim()
|
|
50
|
+
.slice(0, PREVIEW_MAX_LENGTH);
|
|
51
|
+
}
|
|
52
|
+
/** Remembers what was recovered of one message. Ignored when there is nothing new to show. */
|
|
53
|
+
export function rememberDecrypted(uid, mailboxUid, found) {
|
|
54
|
+
if (!found.subject && !found.preview) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const current = entries[uid];
|
|
58
|
+
if (current && current.subject === found.subject && current.preview === found.preview) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
entries = { ...entries, [uid]: { mailboxUid, subject: found.subject, preview: found.preview } };
|
|
62
|
+
emit();
|
|
63
|
+
}
|
|
64
|
+
/** Every message decrypted so far, by uid; a new object whenever any changes. */
|
|
65
|
+
export function useDecryptedMessages() {
|
|
66
|
+
return useSyncExternalStore(subscribe, () => entries);
|
|
67
|
+
}
|
|
68
|
+
/** A number that changes whenever a mailbox is unlocked - a dependency for effects that decrypt what they can. */
|
|
69
|
+
export function useUnlockEpoch() {
|
|
70
|
+
return useSyncExternalStore(subscribe, () => unlockEpoch);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Decrypts one message to recover its subject and preview, when its mailbox's keys are unlocked and it isn't already known. Best effort: a
|
|
74
|
+
* message that can't be fetched or opened is simply left as it was. Never prompts.
|
|
75
|
+
*/
|
|
76
|
+
export async function decryptForDisplay(uid, mailboxUid) {
|
|
77
|
+
const unlocked = getUnlockedKeys(mailboxUid);
|
|
78
|
+
if (!unlocked || entries[uid] || inFlight.has(uid)) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
inFlight.add(uid);
|
|
82
|
+
try {
|
|
83
|
+
const rawMime = await getMessageRawContent(uid);
|
|
84
|
+
const { evaluateMessageSecurity } = await import("../../../lib/crypto/messageSecurity.js");
|
|
85
|
+
const security = await evaluateMessageSecurity(rawMime, unlocked);
|
|
86
|
+
// A lock while this ran: the result must not go back on screen.
|
|
87
|
+
if (getUnlockedKeys(mailboxUid) === unlocked) {
|
|
88
|
+
rememberDecrypted(uid, mailboxUid, { subject: security.subject, preview: security.html ? previewOf(security.html) : security.text?.slice(0, PREVIEW_MAX_LENGTH) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Left as the placeholder.
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
inFlight.delete(uid);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -31,6 +31,7 @@ import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
|
|
|
31
31
|
import Alert from "../../../lib/components/feedback/Alert.js";
|
|
32
32
|
import Button from "../../../lib/components/buttons/Button.js";
|
|
33
33
|
import { SWIPE_PERIOD_SHIFT } from "../../shared/components/calendar/swipeNavigation.js";
|
|
34
|
+
import { nextHalfHour } from "../../shared/components/calendar/timePicker.js";
|
|
34
35
|
import { useSwipeSlide } from "../../shared/gestures/useSwipeSlide.js";
|
|
35
36
|
import { useEnterSlide } from "../../shared/gestures/useEnterSlide.js";
|
|
36
37
|
import { useWheelPaging } from "../../shared/gestures/useWheelPaging.js";
|
|
@@ -275,10 +276,12 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
275
276
|
if (!mailboxUid || !folderUid) {
|
|
276
277
|
return;
|
|
277
278
|
}
|
|
279
|
+
// With nothing clicked, the next half hour, for an hour.
|
|
280
|
+
const from = start ?? nextHalfHour(new Date());
|
|
278
281
|
setModal({
|
|
279
282
|
occurrence: null,
|
|
280
|
-
initialStart:
|
|
281
|
-
initialEnd: end ?? new Date(
|
|
283
|
+
initialStart: from,
|
|
284
|
+
initialEnd: end ?? new Date(from.getTime() + 60 * 60000),
|
|
282
285
|
targetFolderUid,
|
|
283
286
|
anchor,
|
|
284
287
|
initialAllDay,
|
package/dist/apps/www/index.js
CHANGED
|
@@ -32,6 +32,7 @@ import { setReadStateMany } from "../shared/mail/messageReadState.js";
|
|
|
32
32
|
import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
|
|
33
33
|
import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
|
|
34
34
|
import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
|
|
35
|
+
import { rememberDecrypted } from "../shared/mail/decryptedMessages.js";
|
|
35
36
|
import ConversationList, { conversationRowKey } from "../shared/components/mail/ConversationList.js";
|
|
36
37
|
import SwipeRow from "../shared/components/mail/SwipeRow.js";
|
|
37
38
|
import InviteRowChip from "../shared/components/mail/invite/InviteRowChip.js";
|
|
@@ -101,6 +102,7 @@ async function decryptEncryptedRows(messages, unlocked) {
|
|
|
101
102
|
return null;
|
|
102
103
|
}
|
|
103
104
|
const preview = security.html ? stripHtmlToText(security.html).slice(0, LIST_PREVIEW_MAX_LENGTH) : undefined;
|
|
105
|
+
rememberDecrypted(message.uid, message.mailboxUid, { subject: security.subject, preview });
|
|
104
106
|
return [message.uid, { subject: security.subject, preview }];
|
|
105
107
|
}
|
|
106
108
|
catch {
|
|
@@ -2076,11 +2078,19 @@ function InboxContent({ userUid }) {
|
|
|
2076
2078
|
}
|
|
2077
2079
|
await actOnKeyboardTargets(targets.filter((message) => !inDeletedItems(message)), (chosen) => moveToFolderOfType(chosen, "deleted_items", "Deleted Items"), true);
|
|
2078
2080
|
}
|
|
2081
|
+
/** Shift+Delete, from the keyboard: permanently deletes the targets wherever they are, after the same confirmation as Delete in Deleted Items. */
|
|
2082
|
+
async function permanentDeleteFromKeyboard() {
|
|
2083
|
+
const targets = await keyboardTargets();
|
|
2084
|
+
if (targets.length > 0 && (await purgeChosen(targets)) && threadPane && !selectMode) {
|
|
2085
|
+
closeOpenThread();
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2079
2088
|
useShortcut(SHORTCUTS.mail.next, () => selectNeighbour(1), { enabled: canMoveSelection });
|
|
2080
2089
|
useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
|
|
2081
2090
|
useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
|
|
2082
2091
|
useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
|
|
2083
2092
|
useShortcut(SHORTCUTS.mail.delete, () => void deleteFromKeyboard(), { enabled: keyboardActions && keyboardTargetExists });
|
|
2093
|
+
useShortcut(SHORTCUTS.mail.permanentDelete, () => void permanentDeleteFromKeyboard(), { enabled: keyboardActions && keyboardTargetExists });
|
|
2084
2094
|
useShortcut(SHORTCUTS.mail.markRead, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, true, { patch: patchListedMessage, track: trackMessageChange }), false, isUnread), { enabled: keyboardActions && keyboardTargetExists });
|
|
2085
2095
|
useShortcut(SHORTCUTS.mail.markUnread, () => void runOnKeyboardTargets((chosen) => setReadStateMany(chosen, false, { patch: patchListedMessage, track: trackMessageChange }), false, (message) => !isUnread(message)), { enabled: keyboardActions && keyboardTargetExists });
|
|
2086
2096
|
useShortcut(SHORTCUTS.mail.flag, () =>
|
|
@@ -6,7 +6,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import { pageTitle } from "../../../shared/navigation/pageTitle.js";
|
|
7
7
|
import { useMemo, useState } from "react";
|
|
8
8
|
import { freeBusyVisibilityOf, isSharedWithMe, updateMailbox } from "../../../../lib/mail/mailApi.js";
|
|
9
|
-
import
|
|
9
|
+
import TimeZonePicker from "../../../../lib/components/pickers/TimeZonePicker.js";
|
|
10
|
+
import { DEFAULT_TIME_ZONE, describeTimeZone, deviceTimeZone, timeZoneOptions } from "../../../../lib/util/timeZone.js";
|
|
10
11
|
import SettingsShell, { useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
11
12
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
12
13
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
@@ -112,13 +113,13 @@ function ProfileContent() {
|
|
|
112
113
|
setDisplayName(e.target.value);
|
|
113
114
|
setNameError(null);
|
|
114
115
|
setSaved(false);
|
|
115
|
-
}, "aria-invalid": nameError ? true : undefined, "aria-describedby": nameError ? "profile-display-name-error" : "profile-display-name-help" }), nameError && (_jsx("p", { id: "profile-display-name-error", role: "alert", className: "mt-1 text-xs text-danger", children: nameError })), _jsx("p", { id: "profile-display-name-help", className: "mt-1 text-xs text-text-muted", children: "The sender name on mail you send, shown to recipients beside your address (for example \u201CJane Doe\u201D). It can\u2019t contain an \u201C@\u201D." })] }), _jsxs(FormField, { label: "Time zone", htmlFor: "profile-time-zone", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(
|
|
116
|
-
setTimezone(
|
|
116
|
+
}, "aria-invalid": nameError ? true : undefined, "aria-describedby": nameError ? "profile-display-name-error" : "profile-display-name-help" }), nameError && (_jsx("p", { id: "profile-display-name-error", role: "alert", className: "mt-1 text-xs text-danger", children: nameError })), _jsx("p", { id: "profile-display-name-help", className: "mt-1 text-xs text-text-muted", children: "The sender name on mail you send, shown to recipients beside your address (for example \u201CJane Doe\u201D). It can\u2019t contain an \u201C@\u201D." })] }), _jsxs(FormField, { label: "Time zone", htmlFor: "profile-time-zone", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(TimeZonePicker, { id: "profile-time-zone", className: SELECT_CLASS, value: timezone, zones: zones, onChange: (zone) => {
|
|
117
|
+
setTimezone(zone);
|
|
117
118
|
setSaved(false);
|
|
118
|
-
}, "aria-describedby": "profile-time-zone-help"
|
|
119
|
+
}, "aria-describedby": "profile-time-zone-help" }), timezone !== deviceZone && (_jsxs(Button, { type: "button", variant: "text", className: "!w-auto", onClick: () => {
|
|
119
120
|
setTimezone(deviceZone);
|
|
120
121
|
setSaved(false);
|
|
121
|
-
}, children: ["Use this device\u2019s time zone (", deviceZone, ")"] }))] }), zoneWasPreselected && (_jsx("p", { className: "mt-1 text-xs text-text-muted", children: "Your mailbox has no time zone chosen yet; this device\u2019s is preselected. Save to keep it." })), _jsx("p", { id: "profile-time-zone-help", className: "mt-1 text-xs text-text-muted", children: "How calendar times and reminders are shown and scheduled for this mailbox, such as when an event or a reminder falls." })] }), _jsxs(FormField, { label: "Free/busy visibility", htmlFor: "profile-free-busy", children: [_jsx("select", { id: "profile-free-busy", className: SELECT_CLASS, value: freeBusy, disabled: !canChangeFreeBusy, onChange: (e) => {
|
|
122
|
+
}, children: ["Use this device\u2019s time zone (", describeTimeZone(deviceZone), ")"] }))] }), zoneWasPreselected && (_jsx("p", { className: "mt-1 text-xs text-text-muted", children: "Your mailbox has no time zone chosen yet; this device\u2019s is preselected. Save to keep it." })), _jsx("p", { id: "profile-time-zone-help", className: "mt-1 text-xs text-text-muted", children: "How calendar times and reminders are shown and scheduled for this mailbox, such as when an event or a reminder falls." })] }), _jsxs(FormField, { label: "Free/busy visibility", htmlFor: "profile-free-busy", children: [_jsx("select", { id: "profile-free-busy", className: SELECT_CLASS, value: freeBusy, disabled: !canChangeFreeBusy, onChange: (e) => {
|
|
122
123
|
setFreeBusy(e.target.value);
|
|
123
124
|
setSaved(false);
|
|
124
125
|
}, "aria-describedby": "profile-free-busy-help", children: FREE_BUSY_CHOICES.map((choice) => (_jsx("option", { value: choice.value, children: choice.label }, choice.value))) }), _jsx("p", { id: "profile-free-busy-help", className: "mt-1 text-xs text-text-muted", children: FREE_BUSY_CHOICES.find((choice) => choice.value === freeBusy).help }), _jsx("p", { className: "mt-1 text-xs text-text-muted", children: "Who can see the hours you are busy when they look for a time to meet - never the title, place or guests of an event." }), !canChangeFreeBusy && (_jsx("p", { className: "mt-1 text-xs text-text-muted", children: "This mailbox is shared with you: only its owner can change who sees its free/busy times." }))] }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }) }));
|
|
@@ -17,6 +17,18 @@ export declare const SESSION_RELOAD_GUARD_MS: number;
|
|
|
17
17
|
* @returns `true` when a refresh was made, `false` when one was not needed.
|
|
18
18
|
*/
|
|
19
19
|
export declare function refreshSession(authServerUrl: string, force?: boolean): Promise<boolean>;
|
|
20
|
+
/** How long the outcome of a recovery is reused for: requests that fail together share one refresh, and a session that will not come back is not asked again at once. */
|
|
21
|
+
export declare const SESSION_RECOVERY_COOLDOWN_MS: number;
|
|
22
|
+
/**
|
|
23
|
+
* Renews the session because a request was refused for lack of one, whatever this browser's record of the last refresh says. Every request
|
|
24
|
+
* refused at about the same time shares one refresh, and its outcome is reused for `SESSION_RECOVERY_COOLDOWN_MS`. A refusal also clears the record of
|
|
25
|
+
* the last refresh, so the next regular check asks again (and sends the browser to sign-in if the refresh token is truly gone).
|
|
26
|
+
*
|
|
27
|
+
* @returns Whether the session was renewed.
|
|
28
|
+
*/
|
|
29
|
+
export declare function recoverSession(authServerUrl: string): Promise<boolean>;
|
|
30
|
+
/** Forgets what recoveries have done (for tests). */
|
|
31
|
+
export declare function resetSessionRecovery(): void;
|
|
20
32
|
/**
|
|
21
33
|
* Redirects the browser to auth-server's sign-in page when `userUid` is absent, carrying the current URL as
|
|
22
34
|
* `return_to` so auth-server can send the browser back here afterward. A no-op once `userUid` is present.
|
|
@@ -51,7 +63,8 @@ export interface SessionRefreshOptions {
|
|
|
51
63
|
* lifetime, or reopened later): attempts one silent refresh and reloads so the server renders the page with the new
|
|
52
64
|
* cookie. If the refresh is refused (the refresh token expired or was revoked, or there was none), or a reload just
|
|
53
65
|
* made still found no session, redirects to sign-in as `useRedirectIfUnauthenticated()` does.
|
|
54
|
-
* - `userUid` set:
|
|
66
|
+
* - `userUid` set: also renews the session when a request is refused for lack of one (`apiFetch()` asks `recoverSession()` and sends the request
|
|
67
|
+
* again), so a token that ran out while timers were stopped costs nothing. Refreshes once the token is `SESSION_REFRESH_AFTER_MS` old (and right away when this browser has no
|
|
55
68
|
* record of a recent refresh, since the page's token age is unknown), checked every `SESSION_REFRESH_CHECK_MS` and
|
|
56
69
|
* whenever the tab becomes visible or the network returns, because a sleeping laptop stops timers. A failure that is
|
|
57
70
|
* not a rejection is simply retried at the next check. A rejection (`401`/`403`: the refresh token expired or was
|
package/dist/lib/auth/session.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* sign-out already has.
|
|
16
16
|
*/
|
|
17
17
|
import { useEffect, useRef } from "react";
|
|
18
|
-
import { ApiRequestError, authApiFetch } from "../util/api.js";
|
|
18
|
+
import { ApiRequestError, authApiFetch, setApiSessionRecovery } from "../util/api.js";
|
|
19
19
|
import { useApiClient } from "../util/apiClientContext.js";
|
|
20
20
|
/** An access token's age at which it is refreshed. The token lives an hour; the margin leaves time to retry a failed refresh. */
|
|
21
21
|
export const SESSION_REFRESH_AFTER_MS = 45 * 60 * 1000;
|
|
@@ -71,6 +71,38 @@ export async function refreshSession(authServerUrl, force = false) {
|
|
|
71
71
|
const locks = typeof navigator === "undefined" ? undefined : navigator.locks;
|
|
72
72
|
return locks ? locks.request(LOCK_NAME, run) : run();
|
|
73
73
|
}
|
|
74
|
+
/** How long the outcome of a recovery is reused for: requests that fail together share one refresh, and a session that will not come back is not asked again at once. */
|
|
75
|
+
export const SESSION_RECOVERY_COOLDOWN_MS = 10 * 1000;
|
|
76
|
+
let recovery;
|
|
77
|
+
let lastRecovery;
|
|
78
|
+
/**
|
|
79
|
+
* Renews the session because a request was refused for lack of one, whatever this browser's record of the last refresh says. Every request
|
|
80
|
+
* refused at about the same time shares one refresh, and its outcome is reused for `SESSION_RECOVERY_COOLDOWN_MS`. A refusal also clears the record of
|
|
81
|
+
* the last refresh, so the next regular check asks again (and sends the browser to sign-in if the refresh token is truly gone).
|
|
82
|
+
*
|
|
83
|
+
* @returns Whether the session was renewed.
|
|
84
|
+
*/
|
|
85
|
+
export function recoverSession(authServerUrl) {
|
|
86
|
+
if (lastRecovery && Date.now() - lastRecovery.at < SESSION_RECOVERY_COOLDOWN_MS) {
|
|
87
|
+
return Promise.resolve(lastRecovery.renewed);
|
|
88
|
+
}
|
|
89
|
+
recovery ?? (recovery = refreshSession(authServerUrl, true)
|
|
90
|
+
.then(() => true, () => {
|
|
91
|
+
writeTimestamp(() => localStorage, REFRESHED_AT_KEY, 0);
|
|
92
|
+
return false;
|
|
93
|
+
})
|
|
94
|
+
.then((renewed) => {
|
|
95
|
+
lastRecovery = { at: Date.now(), renewed };
|
|
96
|
+
recovery = undefined;
|
|
97
|
+
return renewed;
|
|
98
|
+
}));
|
|
99
|
+
return recovery;
|
|
100
|
+
}
|
|
101
|
+
/** Forgets what recoveries have done (for tests). */
|
|
102
|
+
export function resetSessionRecovery() {
|
|
103
|
+
recovery = undefined;
|
|
104
|
+
lastRecovery = undefined;
|
|
105
|
+
}
|
|
74
106
|
/** Whether `err` says auth-server rejected the refresh token itself, as opposed to a failure worth retrying. */
|
|
75
107
|
function isAuthRejection(err) {
|
|
76
108
|
return err instanceof ApiRequestError && (err.status === 401 || err.status === 403);
|
|
@@ -106,7 +138,8 @@ export function useRedirectIfUnauthenticated(userUid, authServerUrl) {
|
|
|
106
138
|
* lifetime, or reopened later): attempts one silent refresh and reloads so the server renders the page with the new
|
|
107
139
|
* cookie. If the refresh is refused (the refresh token expired or was revoked, or there was none), or a reload just
|
|
108
140
|
* made still found no session, redirects to sign-in as `useRedirectIfUnauthenticated()` does.
|
|
109
|
-
* - `userUid` set:
|
|
141
|
+
* - `userUid` set: also renews the session when a request is refused for lack of one (`apiFetch()` asks `recoverSession()` and sends the request
|
|
142
|
+
* again), so a token that ran out while timers were stopped costs nothing. Refreshes once the token is `SESSION_REFRESH_AFTER_MS` old (and right away when this browser has no
|
|
110
143
|
* record of a recent refresh, since the page's token age is unknown), checked every `SESSION_REFRESH_CHECK_MS` and
|
|
111
144
|
* whenever the tab becomes visible or the network returns, because a sleeping laptop stops timers. A failure that is
|
|
112
145
|
* not a rejection is simply retried at the next check. A rejection (`401`/`403`: the refresh token expired or was
|
|
@@ -143,7 +176,8 @@ export function useSessionRefresh(userUid, authServerUrl, options = {}) {
|
|
|
143
176
|
window.location.href = signInUrl(authServerUrl);
|
|
144
177
|
return;
|
|
145
178
|
}
|
|
146
|
-
|
|
179
|
+
// Forced: the page has no session, so what this browser remembers of the last refresh says nothing about whether one is due.
|
|
180
|
+
refreshSession(authServerUrl, true)
|
|
147
181
|
.then(() => {
|
|
148
182
|
if (!cancelled) {
|
|
149
183
|
writeTimestamp(() => sessionStorage, RELOADED_AT_KEY, Date.now());
|
|
@@ -162,6 +196,8 @@ export function useSessionRefresh(userUid, authServerUrl, options = {}) {
|
|
|
162
196
|
if (paused) {
|
|
163
197
|
return;
|
|
164
198
|
}
|
|
199
|
+
// A request the server refuses for lack of a session renews it (see `apiFetch()`), instead of waiting for the next check.
|
|
200
|
+
setApiSessionRecovery(() => recoverSession(authServerUrl));
|
|
165
201
|
let leaving = false;
|
|
166
202
|
let inFlight = false;
|
|
167
203
|
const leave = async () => {
|
|
@@ -202,6 +238,7 @@ export function useSessionRefresh(userUid, authServerUrl, options = {}) {
|
|
|
202
238
|
window.addEventListener("online", check);
|
|
203
239
|
return () => {
|
|
204
240
|
cancelled = true;
|
|
241
|
+
setApiSessionRecovery(undefined);
|
|
205
242
|
window.clearInterval(interval);
|
|
206
243
|
document.removeEventListener("visibilitychange", onVisible);
|
|
207
244
|
window.removeEventListener("online", check);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface TimeZonePickerProps {
|
|
3
|
+
id?: string;
|
|
4
|
+
"aria-label"?: string;
|
|
5
|
+
"aria-describedby"?: string;
|
|
6
|
+
/** The chosen zone (an IANA id). */
|
|
7
|
+
value: string;
|
|
8
|
+
/** The zones to choose between; the list orders them itself. */
|
|
9
|
+
zones: string[];
|
|
10
|
+
onChange: (zone: string) => void;
|
|
11
|
+
/** The date each zone's offset is read on (daylight saving); now unless a date matters. */
|
|
12
|
+
at?: Date;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A time zone chooser that lists zones as "Los Angeles, America (GMT-07:00)", west to east and then by city, and has a search box in the list: typing narrows it to the zones whose
|
|
17
|
+
* name or offset contains every word typed ("berlin", "gmt+9", "america indiana"). A button showing the chosen zone opens it; the arrow keys move
|
|
18
|
+
* through the list, Enter chooses, and Escape (or a click elsewhere) closes it without changing anything.
|
|
19
|
+
*/
|
|
20
|
+
export default function TimeZonePicker({ id, "aria-label": label, "aria-describedby": describedBy, value, zones, onChange, at, className }: TimeZonePickerProps): React.JSX.Element;
|