@rapidmx/web-client 0.33.0 → 0.35.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/shared/calendar/calendarLiveUpdates.ts +2 -5
- package/apps/shared/calendar/calendarReminders.ts +16 -7
- package/apps/shared/calendar/useCalendarReminders.ts +66 -61
- package/apps/shared/components/calendar/CalendarAlarmDialog.tsx +75 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +6 -3
- package/apps/shared/components/layout/AppShell.tsx +4 -2
- package/apps/shared/components/layout/UserMenu.tsx +29 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +56 -7
- package/apps/shared/components/mail/compose/quotedBody.ts +27 -12
- package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
- package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
- package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
- package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
- package/apps/shared/notifications/preferences.ts +27 -0
- package/apps/shared/notifications/sounds.ts +135 -0
- package/apps/shared/notifications/store.ts +9 -0
- package/apps/www/calendar/index.tsx +64 -32
- package/apps/www/contacts/index.tsx +58 -2
- package/apps/www/tasks/index.tsx +42 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
- package/dist/apps/shared/calendar/calendarReminders.d.ts +6 -4
- package/dist/apps/shared/calendar/calendarReminders.js +14 -6
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +24 -14
- package/dist/apps/shared/calendar/useCalendarReminders.js +49 -56
- package/dist/apps/shared/components/calendar/CalendarAlarmDialog.d.ts +13 -0
- package/dist/apps/shared/components/calendar/CalendarAlarmDialog.js +28 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
- package/dist/apps/shared/components/layout/AppShell.js +4 -3
- package/dist/apps/shared/components/layout/UserMenu.js +9 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.js +40 -5
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -2
- package/dist/apps/shared/components/mail/compose/quotedBody.js +24 -12
- package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
- package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
- package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
- package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
- package/dist/apps/shared/notifications/preferences.d.ts +8 -0
- package/dist/apps/shared/notifications/preferences.js +27 -0
- package/dist/apps/shared/notifications/sounds.d.ts +15 -0
- package/dist/apps/shared/notifications/sounds.js +101 -0
- package/dist/apps/shared/notifications/store.js +11 -0
- package/dist/apps/www/calendar/index.js +37 -8
- package/dist/apps/www/contacts/index.js +49 -3
- package/dist/apps/www/tasks/index.js +29 -1
- package/dist/lib/mail/pushClient.d.ts +10 -3
- package/dist/lib/mail/pushClient.js +18 -4
- package/dist/lib/tasks/tasksApi.d.ts +1 -0
- package/dist/lib/tasks/tasksApi.js +3 -0
- package/lib/mail/pushClient.ts +18 -4
- package/lib/tasks/tasksApi.ts +4 -0
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
Contact,
|
|
11
11
|
createContact,
|
|
12
12
|
deleteContact,
|
|
13
|
+
getContact,
|
|
13
14
|
listContacts,
|
|
14
15
|
ensureSuggestedContacts,
|
|
15
16
|
listContactsInFolders,
|
|
@@ -42,6 +43,18 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
|
|
|
42
43
|
import { useShortcut } from "../../shared/keyboard/useShortcut.js";
|
|
43
44
|
import { notifyApiError } from "../../shared/notifications/apiErrors.js";
|
|
44
45
|
import { notify } from "../../shared/notifications/store.js";
|
|
46
|
+
import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
|
|
47
|
+
|
|
48
|
+
/** The published model class name of a contact on either database (`ContactMongo`, `ContactSQL`) - not a `ContactList`'s. */
|
|
49
|
+
const CONTACT_MODEL = /^Contact(Mongo|SQL)$/;
|
|
50
|
+
|
|
51
|
+
/** The push client channel group (`PushClient.setChannels()`) the page's contacts folders are subscribed under. */
|
|
52
|
+
const CONTACTS_CHANNEL_GROUP = "contacts";
|
|
53
|
+
|
|
54
|
+
/** `list` with `contact` in place of the one with its uid, or added when it has none. */
|
|
55
|
+
function upsertContact(list: Contact[], contact: Contact): Contact[] {
|
|
56
|
+
return list.some((existing) => existing.uid === contact.uid) ? list.map((existing) => (existing.uid === contact.uid ? contact : existing)) : [...list, contact];
|
|
57
|
+
}
|
|
45
58
|
|
|
46
59
|
const INPUT_CLASS =
|
|
47
60
|
"w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
@@ -166,6 +179,45 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
166
179
|
};
|
|
167
180
|
}, [mailboxUid]);
|
|
168
181
|
|
|
182
|
+
// A contact created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by
|
|
183
|
+
// its uid and filed in the list of the folder it is in now (the mailbox's contacts, or its suggested contacts), and taken out of the other; one that is
|
|
184
|
+
// gone (a delete, or a 404) leaves both. A failed fetch changes nothing: the next load shows it as it is.
|
|
185
|
+
useFolderLiveUpdates(
|
|
186
|
+
CONTACTS_CHANNEL_GROUP,
|
|
187
|
+
[folderUid, suggestedFolderUid].filter((uid): uid is string => !!uid),
|
|
188
|
+
(event) => changedItemOf(event, CONTACT_MODEL),
|
|
189
|
+
(change) => {
|
|
190
|
+
const remove = (uid: string) => {
|
|
191
|
+
setContacts((previous) => previous.filter((existing) => existing.uid !== uid));
|
|
192
|
+
setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== uid));
|
|
193
|
+
};
|
|
194
|
+
if (change.deleted) {
|
|
195
|
+
remove(change.uid);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
void getContact(change.uid, client).then(
|
|
199
|
+
(fresh) => {
|
|
200
|
+
if (fresh.deleted === true) {
|
|
201
|
+
remove(fresh.uid);
|
|
202
|
+
} else if (fresh.folderUid === folderUid) {
|
|
203
|
+
setContacts((previous) => upsertContact(previous, fresh));
|
|
204
|
+
setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
|
|
205
|
+
} else if (fresh.folderUid === suggestedFolderUid) {
|
|
206
|
+
setSuggestedContacts((previous) => upsertContact(previous, fresh));
|
|
207
|
+
setContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
|
|
208
|
+
} else {
|
|
209
|
+
remove(fresh.uid);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
(err: unknown) => {
|
|
213
|
+
if (err instanceof ApiRequestError && err.status === 404) {
|
|
214
|
+
remove(change.uid);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
169
221
|
// restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
|
|
170
222
|
// (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
|
|
171
223
|
// contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
|
|
@@ -228,6 +280,9 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
228
280
|
return contacts;
|
|
229
281
|
case "favorites":
|
|
230
282
|
return contacts.filter((c) => c.favorite);
|
|
283
|
+
case "everyone":
|
|
284
|
+
// The mailbox's own contacts and its suggested ones in one list (a person is in only one of them: a suggestion that is accepted is moved).
|
|
285
|
+
return [...contacts, ...suggestedContacts];
|
|
231
286
|
case "suggested":
|
|
232
287
|
return suggestedContacts;
|
|
233
288
|
case "list":
|
|
@@ -428,7 +483,8 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
428
483
|
|
|
429
484
|
// A suggested contact becomes one of the mailbox's own: a copy in its contacts folder, and the suggestion removed (the person is not suggested again).
|
|
430
485
|
const handleAddToContacts = singleFlight(async () => {
|
|
431
|
-
|
|
486
|
+
// In the All contacts view the selection can hold contacts that already are the mailbox's own: only the suggested ones are added.
|
|
487
|
+
for (const contact of checkedContacts.filter((checked) => checked.folderUid === suggestedFolderUid)) {
|
|
432
488
|
try {
|
|
433
489
|
await createContact(
|
|
434
490
|
{
|
|
@@ -540,7 +596,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
|
|
|
540
596
|
onToggleFavorite={handleToggleFavorite}
|
|
541
597
|
onAddCategory={handleAddCategory}
|
|
542
598
|
onExportVCard={handleExportVCard}
|
|
543
|
-
onAddToContacts={view.type === "suggested" ? handleAddToContacts : undefined}
|
|
599
|
+
onAddToContacts={view.type === "suggested" || view.type === "everyone" ? handleAddToContacts : undefined}
|
|
544
600
|
onImportFile={handleImportFile}
|
|
545
601
|
shortcuts
|
|
546
602
|
hideNew={isMobile}
|
package/apps/www/tasks/index.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
TaskPriority,
|
|
12
12
|
createTask,
|
|
13
13
|
deleteTask,
|
|
14
|
+
getTask,
|
|
14
15
|
listTasks,
|
|
15
16
|
setTaskCompleted,
|
|
16
17
|
setTaskMyDay,
|
|
@@ -34,6 +35,13 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
|
|
|
34
35
|
import { useShortcut } from "../../shared/keyboard/useShortcut.js";
|
|
35
36
|
import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
|
|
36
37
|
import { notifyApiError } from "../../shared/notifications/apiErrors.js";
|
|
38
|
+
import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
|
|
39
|
+
|
|
40
|
+
/** The published model class name of a task on either database (`TaskMongo`, `TaskSQL`) - not a `TaskList`'s. */
|
|
41
|
+
const TASK_MODEL = /^Task(Mongo|SQL)$/;
|
|
42
|
+
|
|
43
|
+
/** The push client channel group (`PushClient.setChannels()`) the page's tasks folder is subscribed under. */
|
|
44
|
+
const TASKS_CHANNEL_GROUP = "tasks";
|
|
37
45
|
|
|
38
46
|
const INPUT_CLASS =
|
|
39
47
|
"text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
@@ -146,6 +154,40 @@ function TasksContent() {
|
|
|
146
154
|
void reload();
|
|
147
155
|
}, [folderUid]);
|
|
148
156
|
|
|
157
|
+
// A task created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by its
|
|
158
|
+
// uid and replaces (or joins) the list while it is still in this folder; one that is gone (a delete, a 404, or moved to another folder) leaves it. A
|
|
159
|
+
// failed fetch changes nothing: the next load shows it as it is.
|
|
160
|
+
useFolderLiveUpdates(
|
|
161
|
+
TASKS_CHANNEL_GROUP,
|
|
162
|
+
folderUid ? [folderUid] : [],
|
|
163
|
+
(event) => changedItemOf(event, TASK_MODEL),
|
|
164
|
+
(change) => {
|
|
165
|
+
const remove = () => setTasks((previous) => previous.filter((existing) => existing.uid !== change.uid));
|
|
166
|
+
if (change.deleted) {
|
|
167
|
+
remove();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
void getTask(change.uid, client).then(
|
|
171
|
+
(fresh) => {
|
|
172
|
+
if (fresh.folderUid !== folderUid) {
|
|
173
|
+
remove();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
setTasks((previous) =>
|
|
177
|
+
previous.some((existing) => existing.uid === fresh.uid)
|
|
178
|
+
? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
|
|
179
|
+
: [...previous, fresh],
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
(err: unknown) => {
|
|
183
|
+
if (err instanceof ApiRequestError && err.status === 404) {
|
|
184
|
+
remove();
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
149
191
|
useEffect(() => {
|
|
150
192
|
setTargetMailboxUid(undefined);
|
|
151
193
|
setAddedElsewhere(null);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
/** Pure helper behind the calendar page's live updates: which push events say "an event changed". */
|
|
6
|
+
import { changedItemOf } from "../live/useFolderLiveUpdates.js";
|
|
6
7
|
/** The published model name of a calendar event on either database (`CalendarEventMongo`, `CalendarEventSQL`) - and not the reminder's `"CalendarEvent"`,
|
|
7
8
|
* which has its own action. */
|
|
8
9
|
const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
|
|
@@ -16,9 +17,5 @@ const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
|
|
|
16
17
|
* changed event is handled that way, an ordinary one (an invitation arriving, an edit on another device) included.
|
|
17
18
|
*/
|
|
18
19
|
export function changedEventOf(event) {
|
|
19
|
-
|
|
20
|
-
return undefined;
|
|
21
|
-
}
|
|
22
|
-
const data = event.data;
|
|
23
|
-
return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
|
|
20
|
+
return changedItemOf(event, EVENT_MODEL);
|
|
24
21
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
|
|
2
2
|
* arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
|
|
3
3
|
import type { PushEvent } from "../../../lib/mail/pushClient.js";
|
|
4
|
-
/** How long "Snooze" waits before the
|
|
4
|
+
/** How long "Snooze" waits before the alarm comes back. */
|
|
5
5
|
export declare const SNOOZE_MS: number;
|
|
6
6
|
/** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
|
|
7
7
|
export interface CalendarReminderNotice {
|
|
@@ -17,11 +17,13 @@ export interface CalendarReminderNotice {
|
|
|
17
17
|
* else (a room name, an address, free-text instructions are all real, common `location` values this must not
|
|
18
18
|
* treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
|
|
19
19
|
export declare function joinMeetingUrl(location: string | null | undefined): string | undefined;
|
|
20
|
+
/** What the alarm's button that opens an event's location URL is called: "Join" for a video meeting (a conferencing service's link, or any link that says
|
|
21
|
+
* it is one to join), "Open" for any other web address. */
|
|
22
|
+
export declare function locationActionLabel(url: string): "Join" | "Open";
|
|
23
|
+
/** Where an alarm's View button leads: the Calendar, with the event (and the occurrence due) open. */
|
|
24
|
+
export declare function calendarEventHref(eventUid: string, startDate: string): string;
|
|
20
25
|
/** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
|
|
21
26
|
export declare function calendarReminderOf(event: PushEvent): CalendarReminderNotice | undefined;
|
|
22
|
-
/** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
|
|
23
|
-
* reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
|
|
24
|
-
export declare function snoozeDelayMs(startDate: string, now?: number): number;
|
|
25
27
|
/** The pop-up's message line: when the meeting starts, relative to `now`. */
|
|
26
28
|
export declare function reminderMessage(startDate: string, now?: number): string;
|
|
27
29
|
/** The one notification id a reminder, and every "Snooze" of it, reuses - so snoozing replaces the same pop-up rather than stacking a new one. */
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
/** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
|
|
6
6
|
* arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
|
|
7
|
-
/** How long "Snooze" waits before the
|
|
7
|
+
/** How long "Snooze" waits before the alarm comes back. */
|
|
8
8
|
export const SNOOZE_MS = 5 * 60000;
|
|
9
9
|
function isReminderNotice(data) {
|
|
10
10
|
const notice = data;
|
|
@@ -31,15 +31,23 @@ export function joinMeetingUrl(location) {
|
|
|
31
31
|
}
|
|
32
32
|
return url.protocol === "http:" || url.protocol === "https:" ? trimmed : undefined;
|
|
33
33
|
}
|
|
34
|
+
/** What the alarm's button that opens an event's location URL is called: "Join" for a video meeting (a conferencing service's link, or any link that says
|
|
35
|
+
* it is one to join), "Open" for any other web address. */
|
|
36
|
+
export function locationActionLabel(url) {
|
|
37
|
+
const { hostname, pathname } = new URL(url);
|
|
38
|
+
const host = hostname.toLowerCase();
|
|
39
|
+
const conferencing = /(^|\.)(zoom\.us|zoom\.com|zoomgov\.com|webex\.com|gotomeeting\.com|goto\.com|whereby\.com|bluejeans\.com|chime\.aws|meet\.jit\.si)$/;
|
|
40
|
+
const known = conferencing.test(host) || host === "meet.google.com" || host === "teams.microsoft.com" || host === "teams.live.com";
|
|
41
|
+
return known || /\/(join|meet|meeting|room|call)(\/|$)/i.test(pathname) || /^(meet|video|call|conference)\./.test(host) ? "Join" : "Open";
|
|
42
|
+
}
|
|
43
|
+
/** Where an alarm's View button leads: the Calendar, with the event (and the occurrence due) open. */
|
|
44
|
+
export function calendarEventHref(eventUid, startDate) {
|
|
45
|
+
return `/calendar?event=${encodeURIComponent(eventUid)}&start=${encodeURIComponent(startDate)}`;
|
|
46
|
+
}
|
|
34
47
|
/** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
|
|
35
48
|
export function calendarReminderOf(event) {
|
|
36
49
|
return event.type === "CalendarEvent" && event.action === "reminder" && isReminderNotice(event.data) ? event.data : undefined;
|
|
37
50
|
}
|
|
38
|
-
/** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
|
|
39
|
-
* reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
|
|
40
|
-
export function snoozeDelayMs(startDate, now = Date.now()) {
|
|
41
|
-
return Math.max(0, Math.min(SNOOZE_MS, new Date(startDate).getTime() - now));
|
|
42
|
-
}
|
|
43
51
|
/** The pop-up's message line: when the meeting starts, relative to `now`. */
|
|
44
52
|
export function reminderMessage(startDate, now = Date.now()) {
|
|
45
53
|
const time = new Date(startDate).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
@@ -1,23 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
* `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
|
|
3
|
-
export declare const CALENDAR_HREF = "/calendar";
|
|
1
|
+
import { CalendarReminderNotice } from "./calendarReminders.js";
|
|
4
2
|
export interface UseCalendarRemindersOptions {
|
|
5
3
|
/** Nothing is shown without a signed-in user. */
|
|
6
4
|
userUid?: string;
|
|
7
5
|
/** Runs only while true - the persistent app frame turns it on, like `useMailConnection`'s identical option. */
|
|
8
6
|
enabled: boolean;
|
|
9
7
|
}
|
|
8
|
+
/** The calendar alarms waiting on the person, as `CalendarAlarmDialog` draws them. */
|
|
9
|
+
export interface CalendarAlarms {
|
|
10
|
+
/** The alarm on screen: the oldest one not yet answered. `undefined` when there is none. */
|
|
11
|
+
current?: CalendarReminderNotice;
|
|
12
|
+
/** How many more are waiting behind `current`. */
|
|
13
|
+
waiting: number;
|
|
14
|
+
/** Closes `current` and brings it back after `SNOOZE_MS` (five minutes). */
|
|
15
|
+
snooze: () => void;
|
|
16
|
+
/** Closes `current` for good. */
|
|
17
|
+
dismiss: () => void;
|
|
18
|
+
}
|
|
10
19
|
/**
|
|
11
|
-
*
|
|
12
|
-
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
20
|
+
* Calendar alarms. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate, location }`)
|
|
21
|
+
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open (`getPushClient()`) -
|
|
22
|
+
* every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the event is published to the
|
|
23
|
+
* mailbox channel as well as the folder's own), so this hook only has to listen, never to manage channels of its own. Lives in the persistent
|
|
24
|
+
* app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so an alarm shows wherever the user is signed in - Mail, Calendar, Contacts, Settings.
|
|
25
|
+
*
|
|
26
|
+
* An alarm is a modal (`CalendarAlarmDialog`), with a bell: not a pop-up that goes by itself, since a meeting missed to a toast that timed out
|
|
27
|
+
* is the failure an alarm exists to prevent. Alarms that arrive together queue, one dialog at a time. **Snooze** closes it and brings it back -
|
|
28
|
+
* with the bell again - after exactly `SNOOZE_MS`. A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the alarm itself (the
|
|
29
|
+
* push event is never replayed), it does not survive the tab closing.
|
|
17
30
|
*
|
|
18
|
-
*
|
|
19
|
-
* closes it and shows it again - reusing the same notification id, so it replaces rather than stacks - after `SNOOZE_MS` (five minutes), or
|
|
20
|
-
* sooner if the meeting starts first (`snoozeDelayMs()`). A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the
|
|
21
|
-
* reminder itself (the push event is never replayed), it does not survive the tab closing.
|
|
31
|
+
* With pop-ups turned off (`getNotificationsEnabled()`) no dialog is shown and nothing is heard: the alarm is only recorded in "Recent notifications".
|
|
22
32
|
*/
|
|
23
|
-
export declare function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions):
|
|
33
|
+
export declare function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions): CalendarAlarms;
|
|
@@ -2,80 +2,73 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import { useEffect } from "react";
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
6
|
import { getPushClient } from "../../../lib/mail/pushClient.js";
|
|
7
|
+
import { getNotificationsEnabled } from "../notifications/preferences.js";
|
|
8
|
+
import { playNotificationSound } from "../notifications/sounds.js";
|
|
7
9
|
import { notify } from "../notifications/store.js";
|
|
8
|
-
import {
|
|
9
|
-
/** Where a reminder's title leads. There is no per-event deep link yet (the calendar opens an event by local state, not a URL - see
|
|
10
|
-
* `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
|
|
11
|
-
export const CALENDAR_HREF = "/calendar";
|
|
10
|
+
import { SNOOZE_MS, calendarReminderOf, reminderMessage, reminderNotificationId } from "./calendarReminders.js";
|
|
12
11
|
/**
|
|
13
|
-
*
|
|
14
|
-
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* Mail, Calendar, Contacts, Settings.
|
|
12
|
+
* Calendar alarms. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate, location }`)
|
|
13
|
+
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open (`getPushClient()`) -
|
|
14
|
+
* every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the event is published to the
|
|
15
|
+
* mailbox channel as well as the folder's own), so this hook only has to listen, never to manage channels of its own. Lives in the persistent
|
|
16
|
+
* app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so an alarm shows wherever the user is signed in - Mail, Calendar, Contacts, Settings.
|
|
19
17
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* An alarm is a modal (`CalendarAlarmDialog`), with a bell: not a pop-up that goes by itself, since a meeting missed to a toast that timed out
|
|
19
|
+
* is the failure an alarm exists to prevent. Alarms that arrive together queue, one dialog at a time. **Snooze** closes it and brings it back -
|
|
20
|
+
* with the bell again - after exactly `SNOOZE_MS`. A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the alarm itself (the
|
|
21
|
+
* push event is never replayed), it does not survive the tab closing.
|
|
22
|
+
*
|
|
23
|
+
* With pop-ups turned off (`getNotificationsEnabled()`) no dialog is shown and nothing is heard: the alarm is only recorded in "Recent notifications".
|
|
24
24
|
*/
|
|
25
25
|
export function useCalendarReminders({ userUid, enabled }) {
|
|
26
|
+
const [queue, setQueue] = useState([]);
|
|
27
|
+
// The queue as of the last render, for the callbacks below to read (a state updater must not start a timer: React may run it twice).
|
|
28
|
+
const queueRef = useRef(queue);
|
|
29
|
+
queueRef.current = queue;
|
|
30
|
+
const timers = useRef(new Set());
|
|
31
|
+
const present = useCallback((notice) => {
|
|
32
|
+
if (!getNotificationsEnabled()) {
|
|
33
|
+
// Off: only the history keeps it.
|
|
34
|
+
notify({ id: reminderNotificationId(notice.eventUid, notice.startDate), kind: "calendar", title: notice.title, message: reminderMessage(notice.startDate) });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const id = reminderNotificationId(notice.eventUid, notice.startDate);
|
|
38
|
+
setQueue((current) => (current.some((queued) => reminderNotificationId(queued.eventUid, queued.startDate) === id) ? current : [...current, notice]));
|
|
39
|
+
playNotificationSound("calendar");
|
|
40
|
+
}, []);
|
|
26
41
|
useEffect(() => {
|
|
27
42
|
if (!enabled || !userUid) {
|
|
28
43
|
return;
|
|
29
44
|
}
|
|
30
|
-
const timers = new Set();
|
|
31
|
-
function show(notice) {
|
|
32
|
-
const joinUrl = joinMeetingUrl(notice.location);
|
|
33
|
-
notify({
|
|
34
|
-
id: reminderNotificationId(notice.eventUid, notice.startDate),
|
|
35
|
-
kind: "calendar",
|
|
36
|
-
title: notice.title,
|
|
37
|
-
message: reminderMessage(notice.startDate),
|
|
38
|
-
// Anyone who can invite you can put any address in an event's location: the button says where it goes.
|
|
39
|
-
hint: joinUrl ? `Join Meeting opens ${new URL(joinUrl).host}` : undefined,
|
|
40
|
-
href: CALENDAR_HREF,
|
|
41
|
-
sticky: true,
|
|
42
|
-
actions: [
|
|
43
|
-
...(joinUrl
|
|
44
|
-
? [
|
|
45
|
-
{
|
|
46
|
-
label: "Join Meeting",
|
|
47
|
-
onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"),
|
|
48
|
-
// Joining isn't resolving the reminder - Dismiss/Snooze are still there afterward.
|
|
49
|
-
keepOpen: true,
|
|
50
|
-
},
|
|
51
|
-
]
|
|
52
|
-
: []),
|
|
53
|
-
{ label: "Dismiss" },
|
|
54
|
-
{
|
|
55
|
-
label: "Snooze",
|
|
56
|
-
onClick: () => {
|
|
57
|
-
const timer = setTimeout(() => {
|
|
58
|
-
timers.delete(timer);
|
|
59
|
-
show(notice);
|
|
60
|
-
}, snoozeDelayMs(notice.startDate));
|
|
61
|
-
timers.add(timer);
|
|
62
|
-
},
|
|
63
|
-
},
|
|
64
|
-
],
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
45
|
const offEvent = getPushClient().onEvent((event) => {
|
|
68
46
|
const notice = calendarReminderOf(event);
|
|
69
47
|
if (notice) {
|
|
70
|
-
|
|
48
|
+
present(notice);
|
|
71
49
|
}
|
|
72
50
|
});
|
|
51
|
+
const pending = timers.current;
|
|
73
52
|
return () => {
|
|
74
53
|
offEvent();
|
|
75
|
-
for (const timer of
|
|
54
|
+
for (const timer of pending) {
|
|
76
55
|
clearTimeout(timer);
|
|
77
56
|
}
|
|
78
|
-
|
|
57
|
+
pending.clear();
|
|
58
|
+
setQueue([]);
|
|
79
59
|
};
|
|
80
|
-
}, [enabled, userUid]);
|
|
60
|
+
}, [enabled, userUid, present]);
|
|
61
|
+
const dismiss = useCallback(() => setQueue((current) => current.slice(1)), []);
|
|
62
|
+
const snooze = useCallback(() => {
|
|
63
|
+
const first = queueRef.current[0];
|
|
64
|
+
if (first) {
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
timers.current.delete(timer);
|
|
67
|
+
present(first);
|
|
68
|
+
}, SNOOZE_MS);
|
|
69
|
+
timers.current.add(timer);
|
|
70
|
+
}
|
|
71
|
+
setQueue((current) => current.slice(1));
|
|
72
|
+
}, [present]);
|
|
73
|
+
return { current: queue[0], waiting: Math.max(0, queue.length - 1), snooze, dismiss };
|
|
81
74
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { CalendarAlarms } from "../../calendar/useCalendarReminders.js";
|
|
3
|
+
export interface CalendarAlarmDialogProps {
|
|
4
|
+
alarms: CalendarAlarms;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The modal an event's alarm raises (see `useCalendarReminders()`): what is starting and when, where, and three actions. **View** opens the event's card
|
|
8
|
+
* in the Calendar. **Join** or **Open** - named for what the event's location is, a video meeting or any other web address - opens that address in a new
|
|
9
|
+
* tab; it is only there when the location is a web address, and says which site it goes to, since anyone who can invite you can put any address in an
|
|
10
|
+
* event. **Snooze** closes the dialog and brings it back after five minutes. Closing it with the close button or Escape dismisses the alarm.
|
|
11
|
+
* View and Join/Open also dismiss it: the person has answered it.
|
|
12
|
+
*/
|
|
13
|
+
export default function CalendarAlarmDialog({ alarms }: CalendarAlarmDialogProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Button from "../../../../lib/components/buttons/Button.js";
|
|
3
|
+
import Modal from "../../../../lib/components/overlays/Modal.js";
|
|
4
|
+
import { calendarEventHref, joinMeetingUrl, locationActionLabel, reminderMessage } from "../../calendar/calendarReminders.js";
|
|
5
|
+
import { useNavigate } from "../../navigation/index.js";
|
|
6
|
+
/**
|
|
7
|
+
* The modal an event's alarm raises (see `useCalendarReminders()`): what is starting and when, where, and three actions. **View** opens the event's card
|
|
8
|
+
* in the Calendar. **Join** or **Open** - named for what the event's location is, a video meeting or any other web address - opens that address in a new
|
|
9
|
+
* tab; it is only there when the location is a web address, and says which site it goes to, since anyone who can invite you can put any address in an
|
|
10
|
+
* event. **Snooze** closes the dialog and brings it back after five minutes. Closing it with the close button or Escape dismisses the alarm.
|
|
11
|
+
* View and Join/Open also dismiss it: the person has answered it.
|
|
12
|
+
*/
|
|
13
|
+
export default function CalendarAlarmDialog({ alarms }) {
|
|
14
|
+
const navigate = useNavigate();
|
|
15
|
+
const { current: alarm, waiting, snooze, dismiss } = alarms;
|
|
16
|
+
if (!alarm) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const url = joinMeetingUrl(alarm.location);
|
|
20
|
+
const location = alarm.location?.trim();
|
|
21
|
+
return (_jsx(Modal, { open: true, onClose: dismiss, title: alarm.title, children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs("div", { className: "flex flex-col gap-1 text-sm", children: [_jsx("p", { className: "font-semibold", children: reminderMessage(alarm.startDate) }), location && _jsx("p", { className: "text-text-muted break-words", children: location }), waiting > 0 && (_jsxs("p", { className: "text-xs text-text-muted", children: [waiting, " more alarm", waiting === 1 ? "" : "s", " after this one"] }))] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => {
|
|
22
|
+
navigate(calendarEventHref(alarm.eventUid, alarm.startDate));
|
|
23
|
+
dismiss();
|
|
24
|
+
}, children: "View" }), url && (_jsx(Button, { type: "button", className: "!w-auto", onClick: () => {
|
|
25
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
26
|
+
dismiss();
|
|
27
|
+
}, children: locationActionLabel(url) })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: snooze, children: "Snooze" })] }), url && _jsx("p", { className: "text-xs text-text-muted", children: `${locationActionLabel(url)} opens ${new URL(url).host}` })] }) }));
|
|
28
|
+
}
|
|
@@ -2,6 +2,8 @@ import React from "react";
|
|
|
2
2
|
import { Contact } from "../../../../lib/contacts/contactsApi.js";
|
|
3
3
|
export type ContactsView = {
|
|
4
4
|
type: "all";
|
|
5
|
+
} | {
|
|
6
|
+
type: "everyone";
|
|
5
7
|
} | {
|
|
6
8
|
type: "favorites";
|
|
7
9
|
} | {
|
|
@@ -35,7 +37,7 @@ export interface ContactsSidebarProps {
|
|
|
35
37
|
refreshToken?: number;
|
|
36
38
|
}
|
|
37
39
|
/**
|
|
38
|
-
* Contacts' Outlook-style left sidebar: "Your contacts" (
|
|
40
|
+
* Contacts' Outlook-style left sidebar: "All contacts" (the mailbox's own and its suggested ones together, when there are suggested ones), "Your contacts" (the mailbox's own, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
|
|
39
41
|
* lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
|
|
40
42
|
* distinct `categories` values across the loaded contacts — no separate category registry exists
|
|
41
43
|
* server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
|
|
@@ -39,7 +39,7 @@ function NavItem({ label, count, active, onClick, swatch, }) {
|
|
|
39
39
|
].join(" "), children: [_jsxs("span", { className: "flex items-center gap-2 min-w-0", children: [swatch && _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: swatch } }), _jsx("span", { className: "truncate", children: label })] }), count !== undefined && _jsx("span", { className: "text-xs text-text-muted shrink-0", children: count })] }));
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
|
-
* Contacts' Outlook-style left sidebar: "Your contacts" (
|
|
42
|
+
* Contacts' Outlook-style left sidebar: "All contacts" (the mailbox's own and its suggested ones together, when there are suggested ones), "Your contacts" (the mailbox's own, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
|
|
43
43
|
* lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
|
|
44
44
|
* distinct `categories` values across the loaded contacts — no separate category registry exists
|
|
45
45
|
* server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
|
|
@@ -104,6 +104,6 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
|
|
|
104
104
|
// A function, not a plain JSX constant — rendered both in the desktop `<nav>` and the mobile
|
|
105
105
|
// `Drawer` (see MailShell/ContactsShell for the identical pattern and the duplicate-id reasoning
|
|
106
106
|
// behind the `idPrefix`).
|
|
107
|
-
const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), suggestedCount !== undefined && (_jsx(NavItem, { label: "Suggested contacts", count: suggestedCount, active: active.type === "suggested", onClick: () => handleSelect({ type: "suggested" }) })), showDeleted && _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
|
|
107
|
+
const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [suggestedCount !== undefined && (_jsx(NavItem, { label: "All contacts", count: contacts.length + suggestedCount, active: active.type === "everyone", onClick: () => handleSelect({ type: "everyone" }) })), _jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), suggestedCount !== undefined && (_jsx(NavItem, { label: "Suggested contacts", count: suggestedCount, active: active.type === "suggested", onClick: () => handleSelect({ type: "suggested" }) })), showDeleted && _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
|
|
108
108
|
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "lg:hidden m-3 mb-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open contacts menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("nav", { "aria-label": "Contacts", className: "hidden lg:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto", children: navContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Contacts", fullScreen: true, children: _jsx("div", { className: "flex flex-col gap-4", children: navContent("mobile") }) })] }));
|
|
109
109
|
}
|
|
@@ -26,8 +26,8 @@ export declare function findContact(folders: readonly Folder[], address: string,
|
|
|
26
26
|
export declare function loadRecentMessages(mailboxes: readonly Mailbox[], address: string, client?: ApiClient): Promise<Message[]>;
|
|
27
27
|
/** The next `CARD_LIST_SIZE` events within `UPCOMING_DAYS` from `now` that `address` organizes or is invited to, soonest first. */
|
|
28
28
|
export declare function loadUpcomingEvents(folders: readonly Folder[], address: string, now: Date, client?: ApiClient): Promise<CalendarOccurrence[]>;
|
|
29
|
-
/** A vCard attachment's text. */
|
|
30
|
-
export declare function fetchVCardText(attachmentUid: string): Promise<string>;
|
|
29
|
+
/** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
|
|
30
|
+
export declare function fetchVCardText(attachmentUid: string, client?: ApiClient): Promise<string>;
|
|
31
31
|
/**
|
|
32
32
|
* The vCard of the context message that describes `participant`: the first attachment holding a card with their address, else - only for the
|
|
33
33
|
* message's sender, whose own vCard it presumably is - the first card there is. `undefined` when there is none, and for any failure to read one:
|
|
@@ -63,8 +63,11 @@ export async function loadUpcomingEvents(folders, address, now, client) {
|
|
|
63
63
|
.sort((a, b) => Date.parse(a.startDate) - Date.parse(b.startDate))
|
|
64
64
|
.slice(0, CARD_LIST_SIZE);
|
|
65
65
|
}
|
|
66
|
-
/** A vCard attachment's text. */
|
|
67
|
-
export async function fetchVCardText(attachmentUid) {
|
|
66
|
+
/** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
|
|
67
|
+
export async function fetchVCardText(attachmentUid, client) {
|
|
68
|
+
if (client) {
|
|
69
|
+
return (await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachmentUid)}/content`)).text();
|
|
70
|
+
}
|
|
68
71
|
const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
|
|
69
72
|
if (!res.ok) {
|
|
70
73
|
throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
|
|
@@ -87,7 +90,7 @@ export async function loadParticipantCard(participant, context, client) {
|
|
|
87
90
|
const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
|
|
88
91
|
let fallback;
|
|
89
92
|
for (const attachment of await vCardAttachments(context, client)) {
|
|
90
|
-
const cards = parseVCards(await fetchVCardText(attachment.uid));
|
|
93
|
+
const cards = parseVCards(await fetchVCardText(attachment.uid, client));
|
|
91
94
|
const match = cardForAddress(cards, participant.address, false);
|
|
92
95
|
if (match) {
|
|
93
96
|
return match;
|
|
@@ -44,6 +44,7 @@ import { MailConnectionContext, useMailConnection } from "../../mail/useMailConn
|
|
|
44
44
|
import { useUnreadTitle } from "../../mail/useUnreadTitle.js";
|
|
45
45
|
import UnlockBridge from "../../mail/outbox/UnlockBridge.js";
|
|
46
46
|
import NotificationCenter from "../../notifications/NotificationCenter.js";
|
|
47
|
+
import CalendarAlarmDialog from "../calendar/CalendarAlarmDialog.js";
|
|
47
48
|
import { useHeaderHeightRef } from "../../notifications/headerOffset.js";
|
|
48
49
|
import NotificationHistoryDialog from "../../notifications/NotificationHistoryDialog.js";
|
|
49
50
|
import { notifySessionExpired, setSignInUrl } from "../../notifications/apiErrors.js";
|
|
@@ -128,8 +129,8 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
|
|
|
128
129
|
const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
|
|
129
130
|
// A signing certificate the user asked for is announced when it is issued (or fails), on whichever page they are - see the hook.
|
|
130
131
|
useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
|
|
131
|
-
//
|
|
132
|
-
useCalendarReminders({ userUid, enabled: inFrame });
|
|
132
|
+
// An event's alarm raises a modal (drawn by `CalendarAlarmDialog`, below) on whichever page they are - see the hook.
|
|
133
|
+
const calendarAlarms = useCalendarReminders({ userUid, enabled: inFrame });
|
|
133
134
|
// A refused refresh means the sign-in has ended: open compose windows save what they hold (the access token still works for a while) and the
|
|
134
135
|
// browser goes to sign-in. Not while viewing as another user - the refresh cookie is the admin's own, and refreshing would end the impersonation.
|
|
135
136
|
useSessionRefresh(userUid, authServerUrl, { paused: impersonating, beforeRedirect: () => flushComposeDrafts(LOGOUT_TIMEOUT_MS) });
|
|
@@ -277,7 +278,7 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
|
|
|
277
278
|
id === active
|
|
278
279
|
? "bg-primary/10 text-primary-dark"
|
|
279
280
|
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
280
|
-
].join(" "), children: _jsxs("span", { className: "relative inline-flex", children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), _jsx(NavBadge, { count: badge })] }) }, id)))] })), !hideChrome && _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [!hideChrome && !customHeader && (_jsxs("header", { ref: headerRef, className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), renderUserMenu("down")] })), _jsx(NotificationCenter, {}), _jsx("div", { id: "app-content", tabIndex: -1, "aria-busy": busy || undefined, className: ["flex-1 flex min-h-0 outline-none", hideChrome ? "" : "pb-14 md:pb-0"].join(" "), children: children })] })] }), !hideChrome && _jsx(FrameBrandingFooter, { parsed: footer, userMenu: menuInFooter ? renderUserMenu("up") : undefined, appTitle: activeLabel })] }) }) })] }), _jsx(NotificationHistoryDialog, { open: historyOpen, onClose: () => setHistoryOpen(false) })] }) }) }));
|
|
281
|
+
].join(" "), children: _jsxs("span", { className: "relative inline-flex", children: [_jsx(Icon, { size: 20, "aria-hidden": "true" }), _jsx(NavBadge, { count: badge })] }) }, id)))] })), !hideChrome && _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [!hideChrome && !customHeader && (_jsxs("header", { ref: headerRef, className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), renderUserMenu("down")] })), _jsx(NotificationCenter, {}), _jsx(CalendarAlarmDialog, { alarms: calendarAlarms }), _jsx("div", { id: "app-content", tabIndex: -1, "aria-busy": busy || undefined, className: ["flex-1 flex min-h-0 outline-none", hideChrome ? "" : "pb-14 md:pb-0"].join(" "), children: children })] })] }), !hideChrome && _jsx(FrameBrandingFooter, { parsed: footer, userMenu: menuInFooter ? renderUserMenu("up") : undefined, appTitle: activeLabel })] }) }) })] }), _jsx(NotificationHistoryDialog, { open: historyOpen, onClose: () => setHistoryOpen(false) })] }) }) }));
|
|
281
282
|
}
|
|
282
283
|
/**
|
|
283
284
|
* What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
|