@rapidmx/web-client 0.32.0 → 0.34.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/calendarReminders.ts +16 -7
- package/apps/shared/calendar/useCalendarReminders.ts +66 -61
- package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +13 -2
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +24 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +3 -3
- package/apps/shared/components/calendar/CalendarAlarmDialog.tsx +75 -0
- package/apps/shared/components/calendar/EventDetails.tsx +10 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +10 -0
- package/apps/shared/components/calendar/MonthView.tsx +10 -4
- package/apps/shared/components/contacts/ContactCard.tsx +4 -2
- package/apps/shared/components/contacts/ContactsSidebar.tsx +8 -2
- package/apps/shared/components/contacts/ContactsToolbar.tsx +6 -0
- package/apps/shared/components/layout/AppShell.tsx +4 -2
- package/apps/shared/components/layout/UserMenu.tsx +29 -1
- package/apps/shared/components/mail/ConversationThreadPane.tsx +34 -2
- package/apps/shared/components/mail/compose/ComposeContext.tsx +19 -1
- package/apps/shared/components/mail/compose/draftResume.ts +54 -0
- 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 +35 -5
- package/apps/www/contacts/index.tsx +75 -3
- package/apps/www/tasks/index.tsx +1 -1
- 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/admin/diagnostics/SettingsTable.js +3 -2
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +6 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -3
- 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/calendar/EventDetails.js +2 -1
- package/dist/apps/shared/components/calendar/EventFormParts.js +2 -1
- package/dist/apps/shared/components/calendar/MonthView.d.ts +1 -1
- package/dist/apps/shared/components/calendar/MonthView.js +4 -4
- package/dist/apps/shared/components/contacts/ContactCard.js +4 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +6 -2
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +3 -3
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -1
- 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/ConversationThreadPane.js +35 -1
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +17 -1
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +10 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +48 -0
- 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 +34 -5
- package/dist/apps/www/contacts/index.js +63 -5
- package/dist/apps/www/tasks/index.js +1 -1
- package/dist/lib/calendar/recurrence.js +1 -1
- package/dist/lib/components/overlays/overlayStack.d.ts +6 -0
- package/dist/lib/components/overlays/overlayStack.js +10 -0
- package/dist/lib/components/pickers/MiniDatePicker.d.ts +1 -1
- package/dist/lib/components/pickers/MiniDatePicker.js +4 -4
- package/dist/lib/contacts/contactsApi.d.ts +12 -0
- package/dist/lib/contacts/contactsApi.js +8 -0
- package/lib/calendar/recurrence.ts +1 -1
- package/lib/components/overlays/overlayStack.ts +11 -0
- package/lib/components/pickers/MiniDatePicker.tsx +4 -4
- package/lib/contacts/contactsApi.ts +16 -0
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React, { useMemo, useState } from "react";
|
|
6
6
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
7
|
+
import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
|
|
7
8
|
import type { DiagnosticsSetting } from "./diagnosticsApi.js";
|
|
8
9
|
|
|
9
10
|
/** How many settings are listed at first, and how many more each "Show more" adds: an environment can be a hundred long. */
|
|
@@ -83,9 +84,19 @@ export default function SettingsTable({ title, description, id, settings }: Sett
|
|
|
83
84
|
<tbody>
|
|
84
85
|
{shown.map((item) => (
|
|
85
86
|
<tr key={item.name}>
|
|
86
|
-
<td className="py-2 px-2.5 border-b border-border font-mono break-all">{item.name}</td>
|
|
87
87
|
<td className="py-2 px-2.5 border-b border-border font-mono break-all">
|
|
88
|
-
{item.
|
|
88
|
+
{item.name}
|
|
89
|
+
<CopyIconButton value={item.name} label={`Copy the name of ${item.name}`} />
|
|
90
|
+
</td>
|
|
91
|
+
<td className="py-2 px-2.5 border-b border-border font-mono break-all">
|
|
92
|
+
{item.redacted ? (
|
|
93
|
+
<span className="text-text-muted">{HIDDEN_VALUE}</span>
|
|
94
|
+
) : (
|
|
95
|
+
<>
|
|
96
|
+
{item.value}
|
|
97
|
+
<CopyIconButton value={item.value ?? ""} label={`Copy the value of ${item.name}`} />
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
89
100
|
</td>
|
|
90
101
|
</tr>
|
|
91
102
|
))}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React from "react";
|
|
6
6
|
import { Mailbox } from "../../../../../lib/mail/mailApi.js";
|
|
7
|
+
import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
|
|
7
8
|
|
|
8
9
|
function formatBytes(bytes: number): string {
|
|
9
10
|
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
|
|
@@ -12,6 +13,16 @@ function formatBytes(bytes: number): string {
|
|
|
12
13
|
return `${bytes} B`;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** A cell's value with the icon button that copies it. */
|
|
17
|
+
function Copyable({ value, label }: { value: string; label: string }) {
|
|
18
|
+
return (
|
|
19
|
+
<span className="inline-flex items-center gap-1">
|
|
20
|
+
{value}
|
|
21
|
+
<CopyIconButton value={value} label={label} />
|
|
22
|
+
</span>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
export interface MailboxTableProps {
|
|
16
27
|
mailboxes: Mailbox[];
|
|
17
28
|
}
|
|
@@ -44,17 +55,26 @@ export default function MailboxTable({ mailboxes }: MailboxTableProps) {
|
|
|
44
55
|
<tbody>
|
|
45
56
|
{mailboxes.map((mailbox) => (
|
|
46
57
|
<tr key={mailbox.uid}>
|
|
47
|
-
<td className="py-2.5 px-2.5 border-b border-border align-middle">{mailbox.primarySmtpAddress}</td>
|
|
48
|
-
<td className="py-2.5 px-2.5 border-b border-border align-middle">{mailbox.displayName}</td>
|
|
49
58
|
<td className="py-2.5 px-2.5 border-b border-border align-middle">
|
|
50
|
-
{mailbox.
|
|
59
|
+
<Copyable value={mailbox.primarySmtpAddress} label={`Copy the address of ${mailbox.primarySmtpAddress}`} />
|
|
60
|
+
</td>
|
|
61
|
+
<td className="py-2.5 px-2.5 border-b border-border align-middle">
|
|
62
|
+
<Copyable value={mailbox.displayName} label={`Copy the display name of ${mailbox.primarySmtpAddress}`} />
|
|
63
|
+
</td>
|
|
64
|
+
<td className="py-2.5 px-2.5 border-b border-border align-middle">
|
|
65
|
+
{mailbox.ownerUserUid ? (
|
|
66
|
+
<Copyable value={mailbox.ownerUserUid} label={`Copy the owner of ${mailbox.primarySmtpAddress}`} />
|
|
67
|
+
) : (
|
|
51
68
|
<span className="inline-block text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">
|
|
52
69
|
Shared
|
|
53
70
|
</span>
|
|
54
71
|
)}
|
|
55
72
|
</td>
|
|
56
73
|
<td className="py-2.5 px-2.5 border-b border-border align-middle">
|
|
57
|
-
|
|
74
|
+
<Copyable
|
|
75
|
+
value={`${formatBytes(mailbox.usedBytes)} / ${formatBytes(mailbox.quotaBytes)}`}
|
|
76
|
+
label={`Copy the quota used of ${mailbox.primarySmtpAddress}`}
|
|
77
|
+
/>
|
|
58
78
|
</td>
|
|
59
79
|
<td className="py-2.5 px-2.5 border-b border-border align-middle text-right">
|
|
60
80
|
<a
|
|
@@ -6,7 +6,7 @@ import React, { useEffect, useState } from "react";
|
|
|
6
6
|
import { DnsRecordCheck, Domain, getDnsSetup, getDomain, verifyDomain } from "../../../../../lib/admin/domainsApi.js";
|
|
7
7
|
import Alert from "../ActionAlert.js";
|
|
8
8
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
9
|
-
import
|
|
9
|
+
import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
|
|
10
10
|
import { actionErrorMessage } from "../elevation.js";
|
|
11
11
|
|
|
12
12
|
const RECORD_TYPE_LABELS: Record<DnsRecordCheck["type"], string> = {
|
|
@@ -51,12 +51,12 @@ function splitSrvValue(value: string): { priority: string; weight: string; port:
|
|
|
51
51
|
return match ? { priority: match[1], weight: match[2], port: match[3], target: match[4] } : null;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a
|
|
54
|
+
/** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a copy icon button beside it. */
|
|
55
55
|
function CopyableValue({ value, copyLabel }: { value: string; copyLabel: string }) {
|
|
56
56
|
return (
|
|
57
57
|
<div className="flex items-start gap-2">
|
|
58
58
|
<code className="flex-1 min-w-0 text-xs break-all">{value}</code>
|
|
59
|
-
<
|
|
59
|
+
<CopyIconButton value={value} label={copyLabel} className="shrink-0" />
|
|
60
60
|
</div>
|
|
61
61
|
);
|
|
62
62
|
}
|
|
@@ -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
|
+
}
|
|
@@ -25,6 +25,7 @@ import { getVideoMeeting } from "../../../../lib/videoconf/videoMeetingsApi.js";
|
|
|
25
25
|
import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.js";
|
|
26
26
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
27
27
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
28
|
+
import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
|
|
28
29
|
import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
|
|
29
30
|
import ParticipantLink from "../contacts/ParticipantLink.js";
|
|
30
31
|
import EventDescriptionView from "./EventDescriptionView.js";
|
|
@@ -273,6 +274,15 @@ export default function EventDetails({
|
|
|
273
274
|
</span>
|
|
274
275
|
)}
|
|
275
276
|
</div>
|
|
277
|
+
{joinUrl && (
|
|
278
|
+
// The link itself, for when the button is not enough (a blocked pop-up, a link to open in another browser or share).
|
|
279
|
+
<div className="mt-1.5 flex items-center gap-1 min-w-0 text-xs text-text-muted">
|
|
280
|
+
<a href={joinUrl} target="_blank" rel="noopener noreferrer" className="break-all underline hover:text-text">
|
|
281
|
+
{joinUrl}
|
|
282
|
+
</a>
|
|
283
|
+
<CopyIconButton value={joinUrl} label="Copy the video call link" className="shrink-0" />
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
276
286
|
</DetailRow>
|
|
277
287
|
)}
|
|
278
288
|
|
|
@@ -6,6 +6,7 @@ import React, { ReactNode, useRef, useState } from "react";
|
|
|
6
6
|
import { HiOutlineBars3BottomLeft, HiOutlineBell, HiOutlineMapPin, HiOutlineUserGroup, HiOutlineVideoCamera, HiOutlineXMark } from "react-icons/hi2";
|
|
7
7
|
import { AttendeeRole, EventVisibility, RecurrenceRule, WeekdayCode } from "../../../../lib/calendar/calendarApi.js";
|
|
8
8
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
9
|
+
import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
|
|
9
10
|
import GuestInput from "./GuestInput.js";
|
|
10
11
|
import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
|
|
11
12
|
import RecurrenceEditor from "./RecurrenceEditor.js";
|
|
@@ -103,6 +104,15 @@ export function VideoConferencingRow({ c }: { c: EventFormController }) {
|
|
|
103
104
|
)}
|
|
104
105
|
</div>
|
|
105
106
|
)}
|
|
107
|
+
{c.videoMeetingUid && c.joinUrl && (
|
|
108
|
+
// The link itself, for when the button is not enough (a blocked pop-up, a link to open in another browser or share).
|
|
109
|
+
<div className="mb-1 flex items-center gap-1 min-w-0 text-xs text-text-muted">
|
|
110
|
+
<a href={c.joinUrl} target="_blank" rel="noopener noreferrer" className="break-all underline hover:text-text">
|
|
111
|
+
{c.joinUrl}
|
|
112
|
+
</a>
|
|
113
|
+
<CopyIconButton value={c.joinUrl} label="Copy the video call link" className="shrink-0" />
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
106
116
|
</IconRow>
|
|
107
117
|
);
|
|
108
118
|
}
|
|
@@ -27,15 +27,21 @@ export interface MonthViewProps {
|
|
|
27
27
|
onSelectSlot?: (start: Date, end: Date, anchor: EventAnchor) => void;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** A real 6-week month grid (
|
|
30
|
+
/** A real 6-week month grid (Sunday-start), matching Outlook/Gmail's month view. Multi-day and all-day
|
|
31
31
|
* events are shown on every day they cover (see `allDay.ts`'s `occursOnDay`). */
|
|
32
32
|
export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }: MonthViewProps) {
|
|
33
|
-
const gridStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn:
|
|
34
|
-
const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn:
|
|
33
|
+
const gridStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 0 });
|
|
34
|
+
const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 0 });
|
|
35
35
|
const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
|
|
36
36
|
|
|
37
37
|
return (
|
|
38
|
-
<div className="flex-1 grid grid-cols-7 grid-rows-6 min-h-0" role="grid" aria-label="Month">
|
|
38
|
+
<div className="flex-1 grid grid-cols-7 grid-rows-[auto_repeat(6,minmax(0,1fr))] min-h-0" role="grid" aria-label="Month">
|
|
39
|
+
{days.slice(0, 7).map((day) => (
|
|
40
|
+
<div key={day.getDay()} role="columnheader" className="px-2 py-1 text-xs font-semibold text-text-muted border-b border-border">
|
|
41
|
+
<span className="sm:hidden">{format(day, "EEEEE")}</span>
|
|
42
|
+
<span className="hidden sm:inline">{format(day, "EEEE")}</span>
|
|
43
|
+
</div>
|
|
44
|
+
))}
|
|
39
45
|
{days.map((day) => (
|
|
40
46
|
<DayCell
|
|
41
47
|
key={day.toISOString()}
|
|
@@ -22,6 +22,7 @@ import { useMailboxUpdateAccess } from "../../mail/useMailboxUpdateAccess.js";
|
|
|
22
22
|
import { notify } from "../../notifications/store.js";
|
|
23
23
|
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
24
24
|
import { APP_HREFS } from "../../navigation/appHrefs.js";
|
|
25
|
+
import { closeAllOverlays } from "../../../../lib/components/overlays/overlayStack.js";
|
|
25
26
|
import { useCompose } from "../mail/compose/ComposeContext.js";
|
|
26
27
|
import { formatInviteWhen } from "../mail/invite/inviteFormat.js";
|
|
27
28
|
import {
|
|
@@ -234,6 +235,8 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
|
|
|
234
235
|
}
|
|
235
236
|
|
|
236
237
|
function handleEmail() {
|
|
238
|
+
// Every card open, not just this one: it may have been reached from another (a guest of an event), which would stay on screen behind the new window.
|
|
239
|
+
closeAllOverlays();
|
|
237
240
|
openCompose({ to: formatMailAddress(participant) });
|
|
238
241
|
onClose();
|
|
239
242
|
}
|
|
@@ -261,9 +264,8 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
|
|
|
261
264
|
{stored && <FavoriteStarButton favorite={!!stored.favorite} disabled={photoBusy} onToggle={() => void handleToggleFavorite(stored)} className="shrink-0 self-start" />}
|
|
262
265
|
</div>
|
|
263
266
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
264
|
-
<Button type="button" variant="secondary" className={`${ACTION_CLASS} inline-flex items-center
|
|
267
|
+
<Button type="button" variant="secondary" aria-label="Email" title="Email" className={`${ACTION_CLASS} inline-flex items-center`} onClick={handleEmail}>
|
|
265
268
|
<HiOutlineEnvelope aria-hidden="true" className="size-4" />
|
|
266
|
-
Email
|
|
267
269
|
</Button>
|
|
268
270
|
{stored ? (
|
|
269
271
|
<a
|
|
@@ -13,6 +13,7 @@ import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
|
13
13
|
export type ContactsView =
|
|
14
14
|
| { type: "all" }
|
|
15
15
|
| { type: "favorites" }
|
|
16
|
+
| { type: "suggested" }
|
|
16
17
|
| { type: "list"; uid: string; name: string }
|
|
17
18
|
| { type: "deleted" }
|
|
18
19
|
| { type: "category"; name: string };
|
|
@@ -49,6 +50,8 @@ export interface ContactsSidebarProps {
|
|
|
49
50
|
contacts: Contact[];
|
|
50
51
|
active: ContactsView;
|
|
51
52
|
onSelect: (view: ContactsView) => void;
|
|
53
|
+
/** How many contacts the mailbox's Suggested Contacts folder holds; `undefined` when it has none to show (the folder isn't known), which hides the view. */
|
|
54
|
+
suggestedCount?: number;
|
|
52
55
|
/** Whether to offer the "Deleted" view - restapi only honors `deleted=true` for a caller with delete and
|
|
53
56
|
* update rights on the mailbox (anyone else silently gets the live contacts), so the parent hides it
|
|
54
57
|
* otherwise. Defaults to `true`. */
|
|
@@ -91,13 +94,13 @@ function NavItem({
|
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
/**
|
|
94
|
-
* Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Your contact
|
|
97
|
+
* Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
|
|
95
98
|
* lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
|
|
96
99
|
* distinct `categories` values across the loaded contacts — no separate category registry exists
|
|
97
100
|
* server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
|
|
98
101
|
* `ContactsShellContext` itself minimal (mailbox/folder resolution only).
|
|
99
102
|
*/
|
|
100
|
-
export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted = true }: ContactsSidebarProps) {
|
|
103
|
+
export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted = true, suggestedCount }: ContactsSidebarProps) {
|
|
101
104
|
const client = useApiClient();
|
|
102
105
|
const [lists, setLists] = useState<ContactList[]>([]);
|
|
103
106
|
const [listsError, setListsError] = useState<string | null>(null);
|
|
@@ -165,6 +168,9 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
|
|
|
165
168
|
<div className="flex flex-col gap-0.5">
|
|
166
169
|
<NavItem label="Your contacts" count={contacts.length} active={active.type === "all"} onClick={() => handleSelect({ type: "all" })} />
|
|
167
170
|
<NavItem label="Favorites" count={favoriteCount} active={active.type === "favorites"} onClick={() => handleSelect({ type: "favorites" })} />
|
|
171
|
+
{suggestedCount !== undefined && (
|
|
172
|
+
<NavItem label="Suggested contacts" count={suggestedCount} active={active.type === "suggested"} onClick={() => handleSelect({ type: "suggested" })} />
|
|
173
|
+
)}
|
|
168
174
|
{showDeleted && <NavItem label="Deleted" active={active.type === "deleted"} onClick={() => handleSelect({ type: "deleted" })} />}
|
|
169
175
|
</div>
|
|
170
176
|
|
|
@@ -30,6 +30,8 @@ export interface ContactsToolbarProps {
|
|
|
30
30
|
onToggleFavorite: () => void;
|
|
31
31
|
onAddCategory: () => void;
|
|
32
32
|
onExportVCard: () => void;
|
|
33
|
+
/** Turns the selected suggested contacts into contacts of the mailbox. Given only in the Suggested contacts view, which is also the only place the button is offered. */
|
|
34
|
+
onAddToContacts?: () => void;
|
|
33
35
|
/** A `.vcf` file the user picked via the toolbar's own hidden file input — parsing/import is the
|
|
34
36
|
* page's responsibility (it has the mailbox/folder context and the actual `createContact` calls). */
|
|
35
37
|
onImportFile: (file: File) => void;
|
|
@@ -58,6 +60,7 @@ export default function ContactsToolbar({
|
|
|
58
60
|
onToggleFavorite,
|
|
59
61
|
onAddCategory,
|
|
60
62
|
onExportVCard,
|
|
63
|
+
onAddToContacts,
|
|
61
64
|
onImportFile,
|
|
62
65
|
shortcuts,
|
|
63
66
|
hideNew,
|
|
@@ -91,6 +94,9 @@ export default function ContactsToolbar({
|
|
|
91
94
|
rank: 3,
|
|
92
95
|
},
|
|
93
96
|
{ id: "category", label: "Add category", icon: HiOutlineTag, onClick: onAddCategory, disabled: !hasSelection, group: 2, rank: 2 },
|
|
97
|
+
...(onAddToContacts
|
|
98
|
+
? [{ id: "addToContacts", label: "Add to contacts", icon: HiOutlineUserPlus, onClick: onAddToContacts, disabled: !hasSelection, group: 1, rank: 7 }]
|
|
99
|
+
: []),
|
|
94
100
|
{ id: "export", label: "Export", icon: HiOutlineArrowUpTray, onClick: onExportVCard, disabled: !hasSelection, group: 3, rank: 1 },
|
|
95
101
|
{ id: "import", label: "Import", icon: HiOutlineArrowDownTray, onClick: () => importInputRef.current?.click(), group: 3, rank: 0 },
|
|
96
102
|
];
|
|
@@ -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}
|