@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
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
/** Pure helper behind the calendar page's live updates: which push events say "an event changed". */
|
|
6
6
|
|
|
7
7
|
import type { PushEvent } from "../../../lib/mail/pushClient.js";
|
|
8
|
+
import { changedItemOf } from "../live/useFolderLiveUpdates.js";
|
|
8
9
|
|
|
9
10
|
/** The published model name of a calendar event on either database (`CalendarEventMongo`, `CalendarEventSQL`) - and not the reminder's `"CalendarEvent"`,
|
|
10
11
|
* which has its own action. */
|
|
@@ -20,9 +21,5 @@ const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
|
|
|
20
21
|
* changed event is handled that way, an ordinary one (an invitation arriving, an edit on another device) included.
|
|
21
22
|
*/
|
|
22
23
|
export function changedEventOf(event: PushEvent): { uid: string; deleted: boolean } | undefined {
|
|
23
|
-
|
|
24
|
-
return undefined;
|
|
25
|
-
}
|
|
26
|
-
const data = event.data as { uid?: unknown } | null | undefined;
|
|
27
|
-
return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
|
|
24
|
+
return changedItemOf(event, EVENT_MODEL);
|
|
28
25
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { PushEvent } from "../../../lib/mail/pushClient.js";
|
|
9
9
|
|
|
10
|
-
/** How long "Snooze" waits before the
|
|
10
|
+
/** How long "Snooze" waits before the alarm comes back. */
|
|
11
11
|
export const SNOOZE_MS = 5 * 60_000;
|
|
12
12
|
|
|
13
13
|
/** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
|
|
@@ -49,17 +49,26 @@ export function joinMeetingUrl(location: string | null | undefined): string | un
|
|
|
49
49
|
return url.protocol === "http:" || url.protocol === "https:" ? trimmed : undefined;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** 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
|
|
53
|
+
* it is one to join), "Open" for any other web address. */
|
|
54
|
+
export function locationActionLabel(url: string): "Join" | "Open" {
|
|
55
|
+
const { hostname, pathname } = new URL(url);
|
|
56
|
+
const host = hostname.toLowerCase();
|
|
57
|
+
const conferencing = /(^|\.)(zoom\.us|zoom\.com|zoomgov\.com|webex\.com|gotomeeting\.com|goto\.com|whereby\.com|bluejeans\.com|chime\.aws|meet\.jit\.si)$/;
|
|
58
|
+
const known = conferencing.test(host) || host === "meet.google.com" || host === "teams.microsoft.com" || host === "teams.live.com";
|
|
59
|
+
return known || /\/(join|meet|meeting|room|call)(\/|$)/i.test(pathname) || /^(meet|video|call|conference)\./.test(host) ? "Join" : "Open";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Where an alarm's View button leads: the Calendar, with the event (and the occurrence due) open. */
|
|
63
|
+
export function calendarEventHref(eventUid: string, startDate: string): string {
|
|
64
|
+
return `/calendar?event=${encodeURIComponent(eventUid)}&start=${encodeURIComponent(startDate)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
/** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
|
|
53
68
|
export function calendarReminderOf(event: PushEvent): CalendarReminderNotice | undefined {
|
|
54
69
|
return event.type === "CalendarEvent" && event.action === "reminder" && isReminderNotice(event.data) ? event.data : undefined;
|
|
55
70
|
}
|
|
56
71
|
|
|
57
|
-
/** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
|
|
58
|
-
* reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
|
|
59
|
-
export function snoozeDelayMs(startDate: string, now: number = Date.now()): number {
|
|
60
|
-
return Math.max(0, Math.min(SNOOZE_MS, new Date(startDate).getTime() - now));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
72
|
/** The pop-up's message line: when the meeting starts, relative to `now`. */
|
|
64
73
|
export function reminderMessage(startDate: string, now: number = Date.now()): string {
|
|
65
74
|
const time = new Date(startDate).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
@@ -2,14 +2,12 @@
|
|
|
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 { CalendarReminderNotice,
|
|
9
|
-
|
|
10
|
-
/** 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
|
|
11
|
-
* `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
|
|
12
|
-
export const CALENDAR_HREF = "/calendar";
|
|
10
|
+
import { CalendarReminderNotice, SNOOZE_MS, calendarReminderOf, reminderMessage, reminderNotificationId } from "./calendarReminders.js";
|
|
13
11
|
|
|
14
12
|
export interface UseCalendarRemindersOptions {
|
|
15
13
|
/** Nothing is shown without a signed-in user. */
|
|
@@ -18,76 +16,83 @@ export interface UseCalendarRemindersOptions {
|
|
|
18
16
|
enabled: boolean;
|
|
19
17
|
}
|
|
20
18
|
|
|
19
|
+
/** The calendar alarms waiting on the person, as `CalendarAlarmDialog` draws them. */
|
|
20
|
+
export interface CalendarAlarms {
|
|
21
|
+
/** The alarm on screen: the oldest one not yet answered. `undefined` when there is none. */
|
|
22
|
+
current?: CalendarReminderNotice;
|
|
23
|
+
/** How many more are waiting behind `current`. */
|
|
24
|
+
waiting: number;
|
|
25
|
+
/** Closes `current` and brings it back after `SNOOZE_MS` (five minutes). */
|
|
26
|
+
snooze: () => void;
|
|
27
|
+
/** Closes `current` for good. */
|
|
28
|
+
dismiss: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
/**
|
|
22
|
-
*
|
|
23
|
-
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
32
|
+
* Calendar alarms. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate, location }`)
|
|
33
|
+
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open (`getPushClient()`) -
|
|
34
|
+
* every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the event is published to the
|
|
35
|
+
* 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
|
|
36
|
+
* app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so an alarm shows wherever the user is signed in - Mail, Calendar, Contacts, Settings.
|
|
37
|
+
*
|
|
38
|
+
* 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
|
|
39
|
+
* 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 -
|
|
40
|
+
* 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
|
|
41
|
+
* push event is never replayed), it does not survive the tab closing.
|
|
28
42
|
*
|
|
29
|
-
*
|
|
30
|
-
* closes it and shows it again - reusing the same notification id, so it replaces rather than stacks - after `SNOOZE_MS` (five minutes), or
|
|
31
|
-
* sooner if the meeting starts first (`snoozeDelayMs()`). A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the
|
|
32
|
-
* reminder itself (the push event is never replayed), it does not survive the tab closing.
|
|
43
|
+
* With pop-ups turned off (`getNotificationsEnabled()`) no dialog is shown and nothing is heard: the alarm is only recorded in "Recent notifications".
|
|
33
44
|
*/
|
|
34
|
-
export function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions):
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
export function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions): CalendarAlarms {
|
|
46
|
+
const [queue, setQueue] = useState<CalendarReminderNotice[]>([]);
|
|
47
|
+
// 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).
|
|
48
|
+
const queueRef = useRef(queue);
|
|
49
|
+
queueRef.current = queue;
|
|
50
|
+
const timers = useRef(new Set<ReturnType<typeof setTimeout>>());
|
|
51
|
+
|
|
52
|
+
const present = useCallback((notice: CalendarReminderNotice) => {
|
|
53
|
+
if (!getNotificationsEnabled()) {
|
|
54
|
+
// Off: only the history keeps it.
|
|
55
|
+
notify({ id: reminderNotificationId(notice.eventUid, notice.startDate), kind: "calendar", title: notice.title, message: reminderMessage(notice.startDate) });
|
|
37
56
|
return;
|
|
38
57
|
}
|
|
39
|
-
const
|
|
58
|
+
const id = reminderNotificationId(notice.eventUid, notice.startDate);
|
|
59
|
+
setQueue((current) => (current.some((queued) => reminderNotificationId(queued.eventUid, queued.startDate) === id) ? current : [...current, notice]));
|
|
60
|
+
playNotificationSound("calendar");
|
|
61
|
+
}, []);
|
|
40
62
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
id: reminderNotificationId(notice.eventUid, notice.startDate),
|
|
45
|
-
kind: "calendar",
|
|
46
|
-
title: notice.title,
|
|
47
|
-
message: reminderMessage(notice.startDate),
|
|
48
|
-
// Anyone who can invite you can put any address in an event's location: the button says where it goes.
|
|
49
|
-
hint: joinUrl ? `Join Meeting opens ${new URL(joinUrl).host}` : undefined,
|
|
50
|
-
href: CALENDAR_HREF,
|
|
51
|
-
sticky: true,
|
|
52
|
-
actions: [
|
|
53
|
-
...(joinUrl
|
|
54
|
-
? [
|
|
55
|
-
{
|
|
56
|
-
label: "Join Meeting",
|
|
57
|
-
onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"),
|
|
58
|
-
// Joining isn't resolving the reminder - Dismiss/Snooze are still there afterward.
|
|
59
|
-
keepOpen: true,
|
|
60
|
-
},
|
|
61
|
-
]
|
|
62
|
-
: []),
|
|
63
|
-
{ label: "Dismiss" },
|
|
64
|
-
{
|
|
65
|
-
label: "Snooze",
|
|
66
|
-
onClick: () => {
|
|
67
|
-
const timer = setTimeout(() => {
|
|
68
|
-
timers.delete(timer);
|
|
69
|
-
show(notice);
|
|
70
|
-
}, snoozeDelayMs(notice.startDate));
|
|
71
|
-
timers.add(timer);
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
],
|
|
75
|
-
});
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!enabled || !userUid) {
|
|
65
|
+
return;
|
|
76
66
|
}
|
|
77
|
-
|
|
78
67
|
const offEvent = getPushClient().onEvent((event) => {
|
|
79
68
|
const notice = calendarReminderOf(event);
|
|
80
69
|
if (notice) {
|
|
81
|
-
|
|
70
|
+
present(notice);
|
|
82
71
|
}
|
|
83
72
|
});
|
|
84
|
-
|
|
73
|
+
const pending = timers.current;
|
|
85
74
|
return () => {
|
|
86
75
|
offEvent();
|
|
87
|
-
for (const timer of
|
|
76
|
+
for (const timer of pending) {
|
|
88
77
|
clearTimeout(timer);
|
|
89
78
|
}
|
|
90
|
-
|
|
79
|
+
pending.clear();
|
|
80
|
+
setQueue([]);
|
|
91
81
|
};
|
|
92
|
-
}, [enabled, userUid]);
|
|
82
|
+
}, [enabled, userUid, present]);
|
|
83
|
+
|
|
84
|
+
const dismiss = useCallback(() => setQueue((current) => current.slice(1)), []);
|
|
85
|
+
const snooze = useCallback(() => {
|
|
86
|
+
const first = queueRef.current[0];
|
|
87
|
+
if (first) {
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
timers.current.delete(timer);
|
|
90
|
+
present(first);
|
|
91
|
+
}, SNOOZE_MS);
|
|
92
|
+
timers.current.add(timer);
|
|
93
|
+
}
|
|
94
|
+
setQueue((current) => current.slice(1));
|
|
95
|
+
}, [present]);
|
|
96
|
+
|
|
97
|
+
return { current: queue[0], waiting: Math.max(0, queue.length - 1), snooze, dismiss };
|
|
93
98
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import Button from "../../../../lib/components/buttons/Button.js";
|
|
7
|
+
import Modal from "../../../../lib/components/overlays/Modal.js";
|
|
8
|
+
import { calendarEventHref, joinMeetingUrl, locationActionLabel, reminderMessage } from "../../calendar/calendarReminders.js";
|
|
9
|
+
import type { CalendarAlarms } from "../../calendar/useCalendarReminders.js";
|
|
10
|
+
import { useNavigate } from "../../navigation/index.js";
|
|
11
|
+
|
|
12
|
+
export interface CalendarAlarmDialogProps {
|
|
13
|
+
alarms: CalendarAlarms;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The modal an event's alarm raises (see `useCalendarReminders()`): what is starting and when, where, and three actions. **View** opens the event's card
|
|
18
|
+
* 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
|
|
19
|
+
* 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
|
|
20
|
+
* event. **Snooze** closes the dialog and brings it back after five minutes. Closing it with the close button or Escape dismisses the alarm.
|
|
21
|
+
* View and Join/Open also dismiss it: the person has answered it.
|
|
22
|
+
*/
|
|
23
|
+
export default function CalendarAlarmDialog({ alarms }: CalendarAlarmDialogProps) {
|
|
24
|
+
const navigate = useNavigate();
|
|
25
|
+
const { current: alarm, waiting, snooze, dismiss } = alarms;
|
|
26
|
+
if (!alarm) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const url = joinMeetingUrl(alarm.location);
|
|
30
|
+
const location = alarm.location?.trim();
|
|
31
|
+
return (
|
|
32
|
+
<Modal open onClose={dismiss} title={alarm.title}>
|
|
33
|
+
<div className="flex flex-col gap-4">
|
|
34
|
+
<div className="flex flex-col gap-1 text-sm">
|
|
35
|
+
<p className="font-semibold">{reminderMessage(alarm.startDate)}</p>
|
|
36
|
+
{location && <p className="text-text-muted break-words">{location}</p>}
|
|
37
|
+
{waiting > 0 && (
|
|
38
|
+
<p className="text-xs text-text-muted">
|
|
39
|
+
{waiting} more alarm{waiting === 1 ? "" : "s"} after this one
|
|
40
|
+
</p>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
44
|
+
<Button
|
|
45
|
+
type="button"
|
|
46
|
+
variant="secondary"
|
|
47
|
+
className="!w-auto"
|
|
48
|
+
onClick={() => {
|
|
49
|
+
navigate(calendarEventHref(alarm.eventUid, alarm.startDate));
|
|
50
|
+
dismiss();
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
View
|
|
54
|
+
</Button>
|
|
55
|
+
{url && (
|
|
56
|
+
<Button
|
|
57
|
+
type="button"
|
|
58
|
+
className="!w-auto"
|
|
59
|
+
onClick={() => {
|
|
60
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
61
|
+
dismiss();
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
{locationActionLabel(url)}
|
|
65
|
+
</Button>
|
|
66
|
+
)}
|
|
67
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={snooze}>
|
|
68
|
+
Snooze
|
|
69
|
+
</Button>
|
|
70
|
+
</div>
|
|
71
|
+
{url && <p className="text-xs text-text-muted">{`${locationActionLabel(url)} opens ${new URL(url).host}`}</p>}
|
|
72
|
+
</div>
|
|
73
|
+
</Modal>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -12,6 +12,7 @@ import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
|
12
12
|
|
|
13
13
|
export type ContactsView =
|
|
14
14
|
| { type: "all" }
|
|
15
|
+
| { type: "everyone" }
|
|
15
16
|
| { type: "favorites" }
|
|
16
17
|
| { type: "suggested" }
|
|
17
18
|
| { type: "list"; uid: string; name: string }
|
|
@@ -94,7 +95,7 @@ function NavItem({
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
/**
|
|
97
|
-
* Contacts' Outlook-style left sidebar: "Your contacts" (
|
|
98
|
+
* 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
|
|
98
99
|
* lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
|
|
99
100
|
* distinct `categories` values across the loaded contacts — no separate category registry exists
|
|
100
101
|
* server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
|
|
@@ -166,6 +167,9 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
|
|
|
166
167
|
const navContent = (idPrefix: string) => (
|
|
167
168
|
<>
|
|
168
169
|
<div className="flex flex-col gap-0.5">
|
|
170
|
+
{suggestedCount !== undefined && (
|
|
171
|
+
<NavItem label="All contacts" count={contacts.length + suggestedCount} active={active.type === "everyone"} onClick={() => handleSelect({ type: "everyone" })} />
|
|
172
|
+
)}
|
|
169
173
|
<NavItem label="Your contacts" count={contacts.length} active={active.type === "all"} onClick={() => handleSelect({ type: "all" })} />
|
|
170
174
|
<NavItem label="Favorites" count={favoriteCount} active={active.type === "favorites"} onClick={() => handleSelect({ type: "favorites" })} />
|
|
171
175
|
{suggestedCount !== undefined && (
|
|
@@ -104,8 +104,11 @@ export async function loadUpcomingEvents(folders: readonly Folder[], address: st
|
|
|
104
104
|
.slice(0, CARD_LIST_SIZE);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/** A vCard attachment's text. */
|
|
108
|
-
export async function fetchVCardText(attachmentUid: string): Promise<string> {
|
|
107
|
+
/** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
|
|
108
|
+
export async function fetchVCardText(attachmentUid: string, client?: ApiClient): Promise<string> {
|
|
109
|
+
if (client) {
|
|
110
|
+
return (await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachmentUid)}/content`)).text();
|
|
111
|
+
}
|
|
109
112
|
const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
|
|
110
113
|
if (!res.ok) {
|
|
111
114
|
throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
|
|
@@ -130,7 +133,7 @@ export async function loadParticipantCard(participant: Participant, context: Con
|
|
|
130
133
|
const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
|
|
131
134
|
let fallback: ParsedVCardContact | undefined;
|
|
132
135
|
for (const attachment of await vCardAttachments(context, client)) {
|
|
133
|
-
const cards = parseVCards(await fetchVCardText(attachment.uid));
|
|
136
|
+
const cards = parseVCards(await fetchVCardText(attachment.uid, client));
|
|
134
137
|
const match = cardForAddress(cards, participant.address, false);
|
|
135
138
|
if (match) {
|
|
136
139
|
return match;
|
|
@@ -50,6 +50,7 @@ import { MailConnectionContext, useMailConnection } from "../../mail/useMailConn
|
|
|
50
50
|
import { useUnreadTitle } from "../../mail/useUnreadTitle.js";
|
|
51
51
|
import UnlockBridge from "../../mail/outbox/UnlockBridge.js";
|
|
52
52
|
import NotificationCenter from "../../notifications/NotificationCenter.js";
|
|
53
|
+
import CalendarAlarmDialog from "../calendar/CalendarAlarmDialog.js";
|
|
53
54
|
import { useHeaderHeightRef } from "../../notifications/headerOffset.js";
|
|
54
55
|
import NotificationHistoryDialog from "../../notifications/NotificationHistoryDialog.js";
|
|
55
56
|
import { notifySessionExpired, setSignInUrl } from "../../notifications/apiErrors.js";
|
|
@@ -210,8 +211,8 @@ export function AppChrome({
|
|
|
210
211
|
const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
|
|
211
212
|
// A signing certificate the user asked for is announced when it is issued (or fails), on whichever page they are - see the hook.
|
|
212
213
|
useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
|
|
213
|
-
//
|
|
214
|
-
useCalendarReminders({ userUid, enabled: inFrame });
|
|
214
|
+
// An event's alarm raises a modal (drawn by `CalendarAlarmDialog`, below) on whichever page they are - see the hook.
|
|
215
|
+
const calendarAlarms = useCalendarReminders({ userUid, enabled: inFrame });
|
|
215
216
|
|
|
216
217
|
// 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
|
|
217
218
|
// 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.
|
|
@@ -459,6 +460,7 @@ export function AppChrome({
|
|
|
459
460
|
)}
|
|
460
461
|
{/* The one pop-up stack for the whole app: right under the header row, so it never covers the account menu. */}
|
|
461
462
|
<NotificationCenter />
|
|
463
|
+
<CalendarAlarmDialog alarms={calendarAlarms} />
|
|
462
464
|
<div
|
|
463
465
|
id="app-content"
|
|
464
466
|
tabIndex={-1}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
HiOutlineArrowUturnLeft,
|
|
11
11
|
HiOutlineBell,
|
|
12
12
|
HiOutlineBellAlert,
|
|
13
|
+
HiOutlineSpeakerWave,
|
|
13
14
|
HiOutlineClock,
|
|
14
15
|
HiOutlineCog6Tooth,
|
|
15
16
|
HiOutlineCommandLine,
|
|
@@ -34,7 +35,12 @@ import {
|
|
|
34
35
|
requestDesktopPermission,
|
|
35
36
|
setDesktopOfferDismissed,
|
|
36
37
|
} from "../../mail/newMailNotifications.js";
|
|
37
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
getNotificationSoundsEnabled,
|
|
40
|
+
getNotificationsEnabled,
|
|
41
|
+
setNotificationSoundsEnabled,
|
|
42
|
+
setNotificationsEnabled,
|
|
43
|
+
} from "../../notifications/preferences.js";
|
|
38
44
|
import { dismissAll } from "../../notifications/store.js";
|
|
39
45
|
|
|
40
46
|
export interface UserMenuProps {
|
|
@@ -134,6 +140,7 @@ export default function UserMenu({
|
|
|
134
140
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
135
141
|
const [detectedAdmin, setDetectedAdmin] = useState(false);
|
|
136
142
|
const [popups, setPopups] = useState(true);
|
|
143
|
+
const [sounds, setSounds] = useState(true);
|
|
137
144
|
const [permission, setPermission] = useState<DesktopPermission>("unsupported");
|
|
138
145
|
const [profile, setProfile] = useState<Profile | undefined>(undefined);
|
|
139
146
|
// Whether the profile lookup has answered, with a profile or without: the fallback names are only worth asking for after that.
|
|
@@ -230,10 +237,16 @@ export default function UserMenu({
|
|
|
230
237
|
useEffect(() => {
|
|
231
238
|
if (open && showNotificationSettings) {
|
|
232
239
|
setPopups(getNotificationsEnabled());
|
|
240
|
+
setSounds(getNotificationSoundsEnabled());
|
|
233
241
|
setPermission(desktopPermission());
|
|
234
242
|
}
|
|
235
243
|
}, [open, showNotificationSettings]);
|
|
236
244
|
|
|
245
|
+
function toggleSounds() {
|
|
246
|
+
setNotificationSoundsEnabled(!sounds);
|
|
247
|
+
setSounds(!sounds);
|
|
248
|
+
}
|
|
249
|
+
|
|
237
250
|
function togglePopups() {
|
|
238
251
|
setNotificationsEnabled(!popups);
|
|
239
252
|
setPopups(!popups);
|
|
@@ -386,6 +399,21 @@ export default function UserMenu({
|
|
|
386
399
|
<span className="text-xs font-semibold text-text-muted">{popups ? "On" : "Off"}</span>
|
|
387
400
|
</button>
|
|
388
401
|
)}
|
|
402
|
+
{showNotificationSettings && (
|
|
403
|
+
<button
|
|
404
|
+
role="menuitemcheckbox"
|
|
405
|
+
aria-checked={sounds}
|
|
406
|
+
type="button"
|
|
407
|
+
onClick={toggleSounds}
|
|
408
|
+
className="flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
|
|
409
|
+
>
|
|
410
|
+
<span className="flex items-center gap-2">
|
|
411
|
+
<HiOutlineSpeakerWave size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
412
|
+
Notification sounds
|
|
413
|
+
</span>
|
|
414
|
+
<span className="text-xs font-semibold text-text-muted">{sounds ? "On" : "Off"}</span>
|
|
415
|
+
</button>
|
|
416
|
+
)}
|
|
389
417
|
{showNotificationSettings && permission === "default" && (
|
|
390
418
|
<button
|
|
391
419
|
role="menuitem"
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
HiOutlineNoSymbol,
|
|
17
17
|
HiOutlineSun,
|
|
18
18
|
} from "react-icons/hi2";
|
|
19
|
-
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
19
|
+
import { ApiRequestError, type ApiClient } from "../../../../lib/util/api.js";
|
|
20
20
|
import {
|
|
21
21
|
Attachment,
|
|
22
22
|
Folder,
|
|
@@ -200,6 +200,23 @@ function downloadFromUrl(url: string, filename: string): void {
|
|
|
200
200
|
a.remove();
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/** How long an attachment saved through an explicit client keeps its object URL: the download has started well before this. */
|
|
204
|
+
const DOWNLOAD_URL_LIFETIME_MS = 60_000;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Saves the file at `path` through `client` (its origin and token, which a link's `href` cannot carry): its bytes become an object URL handed to
|
|
208
|
+
* `downloadFromUrl()`. The URL stays in `urls` until it is revoked - after `DOWNLOAD_URL_LIFETIME_MS`, or when the pane unmounts.
|
|
209
|
+
*/
|
|
210
|
+
async function downloadThroughClient(client: ApiClient, path: string, filename: string, urls: Set<string>): Promise<void> {
|
|
211
|
+
const url = URL.createObjectURL(await client.fetchBlob(path));
|
|
212
|
+
urls.add(url);
|
|
213
|
+
downloadFromUrl(url, filename);
|
|
214
|
+
setTimeout(() => {
|
|
215
|
+
urls.delete(url);
|
|
216
|
+
URL.revokeObjectURL(url);
|
|
217
|
+
}, DOWNLOAD_URL_LIFETIME_MS);
|
|
218
|
+
}
|
|
219
|
+
|
|
203
220
|
/** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
|
|
204
221
|
* opaque download (`application/octet-stream`), never rendered in this origin. */
|
|
205
222
|
function downloadMimeAttachment(attachment: MimeAttachment): void {
|
|
@@ -529,6 +546,17 @@ function MessageDetailContent({
|
|
|
529
546
|
const { mailboxes, trackMessageChange } = useMailShell();
|
|
530
547
|
const { requestUnlock } = useUnlockPrompt();
|
|
531
548
|
const client = useApiClient();
|
|
549
|
+
// Object URLs of attachments saved through `client`, revoked when they have been left to lapse or when this pane unmounts.
|
|
550
|
+
const downloadUrls = useRef(new Set<string>());
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
const urls = downloadUrls.current;
|
|
553
|
+
return () => {
|
|
554
|
+
for (const url of urls) {
|
|
555
|
+
URL.revokeObjectURL(url);
|
|
556
|
+
}
|
|
557
|
+
urls.clear();
|
|
558
|
+
};
|
|
559
|
+
}, []);
|
|
532
560
|
// Bumped after a successful on-demand unlock to re-run the effect below - it's not a dependency the
|
|
533
561
|
// effect could read reactively otherwise (getUnlockedKeys() is a plain module-level read, not React
|
|
534
562
|
// state; see keySession.ts's own doc comment).
|
|
@@ -783,11 +811,23 @@ function MessageDetailContent({
|
|
|
783
811
|
return mailbox ? [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])] : [];
|
|
784
812
|
}
|
|
785
813
|
|
|
814
|
+
/** Saves a listed attachment: through the explicit client when there is one (failures are reported, the file is an object URL), else as the
|
|
815
|
+
* cookie link always did. */
|
|
816
|
+
function saveAttachment(attachment: Attachment) {
|
|
817
|
+
if (client) {
|
|
818
|
+
downloadThroughClient(client, `/mail/attachments/${encodeURIComponent(attachment.uid)}/content`, attachment.filename, downloadUrls.current).catch(
|
|
819
|
+
(err) => notifyApiError(err, "Couldn't download this attachment"),
|
|
820
|
+
);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename);
|
|
824
|
+
}
|
|
825
|
+
|
|
786
826
|
/** Starts fetching what Reply/Reply All/Forward will need (the compose window's code, the original's body) as the
|
|
787
827
|
* pointer or keyboard reaches one of their buttons, so the click itself finds it already on its way or here. */
|
|
788
828
|
function prefetchReply() {
|
|
789
829
|
prefetchComposeWindow();
|
|
790
|
-
prefetchOriginalMessage(message);
|
|
830
|
+
prefetchOriginalMessage(message, client);
|
|
791
831
|
}
|
|
792
832
|
|
|
793
833
|
/**
|
|
@@ -812,7 +852,7 @@ function MessageDetailContent({
|
|
|
812
852
|
// Never rejects: loadOriginalMessage() and ownAddresses() don't, and the rest is pure.
|
|
813
853
|
const late = (async (): Promise<ComposeLateInput> => {
|
|
814
854
|
const [original, own] = await Promise.all([
|
|
815
|
-
loadOriginalMessage(message, security, { recipients: kind === "replyAll" }),
|
|
855
|
+
loadOriginalMessage(message, security, { recipients: kind === "replyAll" }, client),
|
|
816
856
|
kind === "forward" ? [] : (knownOwn ?? ownAddresses()),
|
|
817
857
|
]);
|
|
818
858
|
if (kind === "forward") {
|
|
@@ -1141,11 +1181,11 @@ function MessageDetailContent({
|
|
|
1141
1181
|
* sanitized body - in a frame of its own; see `printMessage.ts`. */
|
|
1142
1182
|
async function handlePrint() {
|
|
1143
1183
|
try {
|
|
1144
|
-
const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
|
|
1184
|
+
const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version, undefined, client));
|
|
1145
1185
|
const older = await olderConversationMessages(message, client).catch(() => []);
|
|
1146
1186
|
// A few at a time: a long conversation is hundreds of messages, each of them several requests.
|
|
1147
1187
|
const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) =>
|
|
1148
|
-
printableOlderMessage(other).catch(
|
|
1188
|
+
printableOlderMessage(other, client).catch(
|
|
1149
1189
|
(): PrintableMessage => ({
|
|
1150
1190
|
subject: other.subject || "(no subject)",
|
|
1151
1191
|
headers: printHeaders(other, formatMailAddress(other.from)),
|
|
@@ -1643,9 +1683,18 @@ function MessageDetailContent({
|
|
|
1643
1683
|
<VCardAttachmentChip
|
|
1644
1684
|
label={`${attachment.filename} (${formatBytes(attachment.sizeBytes)})`}
|
|
1645
1685
|
message={message}
|
|
1646
|
-
loadText={() => fetchVCardText(attachment.uid)}
|
|
1647
|
-
onDownload={() =>
|
|
1686
|
+
loadText={() => fetchVCardText(attachment.uid, client)}
|
|
1687
|
+
onDownload={() => saveAttachment(attachment)}
|
|
1648
1688
|
/>
|
|
1689
|
+
) : client ? (
|
|
1690
|
+
// Through the explicit client a link cannot carry the token, so the file is fetched and saved from a button.
|
|
1691
|
+
<button
|
|
1692
|
+
type="button"
|
|
1693
|
+
onClick={() => saveAttachment(attachment)}
|
|
1694
|
+
className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark"
|
|
1695
|
+
>
|
|
1696
|
+
{attachment.filename} ({formatBytes(attachment.sizeBytes)})
|
|
1697
|
+
</button>
|
|
1649
1698
|
) : (
|
|
1650
1699
|
<a
|
|
1651
1700
|
href={attachmentContentUrl(attachment.uid)}
|