@rapidmx/web-client 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/admin/domains/[uid].tsx +73 -2
- package/apps/admin/domains/index.tsx +2 -1
- package/apps/admin/domains/new/index.tsx +28 -3
- package/apps/shared/calendar/calendarReminders.ts +76 -0
- package/apps/shared/calendar/useCalendarReminders.ts +91 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +8 -0
- package/apps/shared/components/calendar/EventModal.tsx +203 -11
- package/apps/shared/components/layout/AppShell.tsx +3 -0
- package/apps/shared/notifications/NotificationCenter.tsx +2 -0
- package/apps/shared/notifications/NotificationHistoryDialog.tsx +1 -0
- package/apps/shared/notifications/store.ts +3 -1
- package/dist/apps/admin/domains/[uid].js +38 -2
- package/dist/apps/admin/domains/index.js +1 -1
- package/dist/apps/admin/domains/new/index.js +13 -4
- package/dist/apps/shared/calendar/calendarReminders.d.ts +28 -0
- package/dist/apps/shared/calendar/calendarReminders.js +55 -0
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +23 -0
- package/dist/apps/shared/calendar/useCalendarReminders.js +79 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +2 -2
- package/dist/apps/shared/components/calendar/EventModal.d.ts +10 -0
- package/dist/apps/shared/components/calendar/EventModal.js +156 -12
- package/dist/apps/shared/components/layout/AppShell.js +3 -0
- package/dist/apps/shared/notifications/NotificationCenter.js +2 -1
- package/dist/apps/shared/notifications/NotificationHistoryDialog.js +1 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -1
- package/dist/apps/shared/notifications/store.js +2 -0
- package/package.json +2 -2
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { FormEvent, useRef, useState } from "react";
|
|
5
|
+
import React, { FormEvent, useEffect, useRef, useState } from "react";
|
|
6
6
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
7
|
import {
|
|
8
8
|
Attendee,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
AttendeeResponseStatus,
|
|
11
11
|
AttendeeRole,
|
|
12
12
|
BusyStatus,
|
|
13
|
+
CalendarEvent,
|
|
13
14
|
CalendarEventInput,
|
|
14
15
|
RecurrenceRule,
|
|
15
16
|
createCalendarEvent,
|
|
@@ -17,6 +18,12 @@ import {
|
|
|
17
18
|
respondToEvent,
|
|
18
19
|
updateCalendarEvent,
|
|
19
20
|
} from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
21
|
+
import {
|
|
22
|
+
VideoMeetingInvitee,
|
|
23
|
+
createVideoMeeting,
|
|
24
|
+
getVideoMeeting,
|
|
25
|
+
updateVideoMeeting,
|
|
26
|
+
} from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
|
|
20
27
|
import { deleteEventOccurrence, deleteEventSeries, detachOccurrence, saveEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
|
|
21
28
|
import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
|
|
22
29
|
import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
@@ -44,6 +51,21 @@ const RESPONSE_STATUS_LABEL: Record<AttendeeResponseStatus, string> = {
|
|
|
44
51
|
tentative: "Tentative",
|
|
45
52
|
};
|
|
46
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The Location text this modal writes when it mints a video meeting for an event, and the one value it will
|
|
56
|
+
* clear again when video conferencing is turned back off (see `applyVideoConferencing()`).
|
|
57
|
+
*
|
|
58
|
+
* Deliberately generic and identical for everyone: each attendee's own personal join link is substituted into
|
|
59
|
+
* their own copy of the invitation server-side (`@rapidmx/restapi`'s `MeetingSchedulingJob`), so no link -
|
|
60
|
+
* personal or otherwise - may be stored on the shared event itself, where every attendee would read the same
|
|
61
|
+
* one.
|
|
62
|
+
*/
|
|
63
|
+
export const VIDEO_LOCATION_PLACEHOLDER = "Video call — link in this invitation";
|
|
64
|
+
|
|
65
|
+
/** The plugin's own title bound (`BaseVideoMeetingRoute`'s `MAX_TITLE_LENGTH`) - a longer title is a 400, so
|
|
66
|
+
* an over-long event title is trimmed to fit rather than failing the whole meeting. */
|
|
67
|
+
const MAX_MEETING_TITLE_LENGTH = 200;
|
|
68
|
+
|
|
47
69
|
export interface EventModalProps {
|
|
48
70
|
open: boolean;
|
|
49
71
|
onClose: () => void;
|
|
@@ -211,6 +233,17 @@ export default function EventModal({
|
|
|
211
233
|
const [autoReplyEnabled, setAutoReplyEnabled] = useState(occurrence?.autoReplyEnabled ?? false);
|
|
212
234
|
const [autoReplyMessage, setAutoReplyMessage] = useState(occurrence?.autoReplyMessage ?? "");
|
|
213
235
|
const [editScope, setEditScope] = useState<EditScope>("occurrence");
|
|
236
|
+
// Video conferencing: the toggle, the meeting this event is linked to (if any), the organizer's own join
|
|
237
|
+
// link for it, and whatever went wrong with the last attempt to mint/cancel one.
|
|
238
|
+
const [videoEnabled, setVideoEnabled] = useState(!!occurrence?.videoMeetingUid);
|
|
239
|
+
const [videoMeetingUid, setVideoMeetingUid] = useState<string | undefined>(occurrence?.videoMeetingUid);
|
|
240
|
+
// `undefined` while the link is still being fetched, `null` once it is known there is none to open.
|
|
241
|
+
const [organizerJoinUrl, setOrganizerJoinUrl] = useState<string | null | undefined>(undefined);
|
|
242
|
+
const [videoError, setVideoError] = useState<string | null>(null);
|
|
243
|
+
// The event as this modal last persisted it. Set only when a save got the event stored but could not
|
|
244
|
+
// finish the video-conferencing step after it: the modal then stays open on that error, and the retry
|
|
245
|
+
// must update *that* record (its uid and its new version), not re-create it or reuse the stale prop.
|
|
246
|
+
const [savedEvent, setSavedEvent] = useState<CalendarEvent | null>(null);
|
|
214
247
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
215
248
|
const [error, setError] = useState<string | null>(null);
|
|
216
249
|
const [saving, setSaving] = useState(false);
|
|
@@ -220,6 +253,31 @@ export default function EventModal({
|
|
|
220
253
|
const [resourcePickerOpen, setResourcePickerOpen] = useState(false);
|
|
221
254
|
const addResourceButtonRef = useRef<HTMLButtonElement>(null);
|
|
222
255
|
|
|
256
|
+
// An event that already carried a meeting when it was opened (e.g. after a page reload) knows only that
|
|
257
|
+
// meeting's uid - the organizer's own join link lives on the meeting, so it is fetched once here. A
|
|
258
|
+
// meeting minted in this session already has its link from the create call (see
|
|
259
|
+
// `applyVideoConferencing()`), which is why this deliberately reads the *prop*, not the state: it must
|
|
260
|
+
// not re-fetch what was just handed to us. A failure and a meeting with no link at all are the same
|
|
261
|
+
// thing to this UI - there is nothing to open either way - so both settle on `null`.
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
const uid = occurrence?.videoMeetingUid;
|
|
264
|
+
if (!uid) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
let cancelled = false;
|
|
268
|
+
void getVideoMeeting(uid)
|
|
269
|
+
.then((meeting) => meeting.organizerJoinUrl ?? null)
|
|
270
|
+
.catch(() => null)
|
|
271
|
+
.then((url) => {
|
|
272
|
+
if (!cancelled) {
|
|
273
|
+
setOrganizerJoinUrl(url);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
return () => {
|
|
277
|
+
cancelled = true;
|
|
278
|
+
};
|
|
279
|
+
}, [occurrence?.videoMeetingUid]);
|
|
280
|
+
|
|
223
281
|
// The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize —
|
|
224
282
|
// `organizerAddress` (plus the aliases) are this mailbox's own addresses (see `EventModalProps`), so
|
|
225
283
|
// finding one among `attendees` (and not as that attendee's own organizer flag) identifies exactly
|
|
@@ -245,9 +303,86 @@ export default function EventModal({
|
|
|
245
303
|
setResourcePickerOpen(false);
|
|
246
304
|
}
|
|
247
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Brings the event's video meeting in line with the toggle, once the event itself is saved (so the
|
|
308
|
+
* attendee list a new meeting's invitees are built from is the one that was just stored).
|
|
309
|
+
*
|
|
310
|
+
* Turning it **on** mints a meeting for the saved event's attendees - every one of them except the
|
|
311
|
+
* organizer, who reaches their own meeting through ownership (`organizerJoinUrl`), not as a guest - and
|
|
312
|
+
* then patches the event with the meeting's uid and the generic `VIDEO_LOCATION_PLACEHOLDER` location.
|
|
313
|
+
* No join link is written onto the event: each attendee's own link is substituted into their own copy of
|
|
314
|
+
* the invitation server-side.
|
|
315
|
+
*
|
|
316
|
+
* Turning it **off** cancels the meeting and clears the link, restoring Location to `""` only when it
|
|
317
|
+
* still holds exactly the placeholder this modal wrote - anything the user has typed there since is
|
|
318
|
+
* theirs and is preserved.
|
|
319
|
+
*
|
|
320
|
+
* **Known, deliberate limitation:** an event that already has a meeting is left completely alone, even
|
|
321
|
+
* when its attendee list has just changed. `@rapidmx/videoconf-plugin`'s `PUT /mail/video-meetings/:id`
|
|
322
|
+
* is deliberately minimal (title and cancellation only) and cannot add or remove invitees, and a private
|
|
323
|
+
* meeting's personal join links are minted once at creation - so there is no way to reconcile an invitee
|
|
324
|
+
* list, and silently pretending otherwise would leave a new attendee with no link and a removed one with
|
|
325
|
+
* a working one. The helper text under the checkbox says so, and turning the toggle off and on again
|
|
326
|
+
* reissues links for the current attendee list.
|
|
327
|
+
*
|
|
328
|
+
* Never throws: the event is already stored by the time this runs, so a failure here is reported inline
|
|
329
|
+
* next to the checkbox and left retryable, with `videoMeetingUid` still whatever it was before.
|
|
330
|
+
*/
|
|
331
|
+
async function applyVideoConferencing(saved: CalendarEvent): Promise<{ event: CalendarEvent; failed: boolean }> {
|
|
332
|
+
// Off and never on, or on and already linked (see the limitation above) - nothing to do either way.
|
|
333
|
+
if (videoEnabled === !!videoMeetingUid) {
|
|
334
|
+
return { event: saved, failed: false };
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
if (videoEnabled) {
|
|
338
|
+
const organizerAddressLower = saved.organizer.address.toLowerCase();
|
|
339
|
+
const invitees: VideoMeetingInvitee[] = attendees
|
|
340
|
+
.filter((a) => !a.isOrganizer && !!a.address.trim() && a.address.trim().toLowerCase() !== organizerAddressLower)
|
|
341
|
+
.map((a) => ({ email: a.address.trim(), displayName: a.displayName }));
|
|
342
|
+
if (invitees.length === 0) {
|
|
343
|
+
setVideoError("Add at least one attendee other than yourself to add video conferencing.");
|
|
344
|
+
return { event: saved, failed: true };
|
|
345
|
+
}
|
|
346
|
+
const result = await createVideoMeeting({
|
|
347
|
+
mailboxUid: saved.mailboxUid,
|
|
348
|
+
title: saved.title.slice(0, MAX_MEETING_TITLE_LENGTH),
|
|
349
|
+
visibility: "private",
|
|
350
|
+
calendarEventUid: saved.uid,
|
|
351
|
+
startTime: saved.startDate,
|
|
352
|
+
endTime: saved.endDate,
|
|
353
|
+
invitees,
|
|
354
|
+
});
|
|
355
|
+
const patched = await updateCalendarEvent({
|
|
356
|
+
uid: saved.uid,
|
|
357
|
+
version: saved.version,
|
|
358
|
+
location: VIDEO_LOCATION_PLACEHOLDER,
|
|
359
|
+
videoMeetingUid: result.meeting.uid,
|
|
360
|
+
});
|
|
361
|
+
setVideoMeetingUid(result.meeting.uid);
|
|
362
|
+
setOrganizerJoinUrl(result.organizerJoinUrl ?? null);
|
|
363
|
+
setLocation(VIDEO_LOCATION_PLACEHOLDER);
|
|
364
|
+
return { event: patched, failed: false };
|
|
365
|
+
}
|
|
366
|
+
await updateVideoMeeting(videoMeetingUid!, { status: "cancelled" });
|
|
367
|
+
const restoredLocation = location.trim() === VIDEO_LOCATION_PLACEHOLDER ? "" : location.trim();
|
|
368
|
+
// A stored event clears a field with an explicit `null` (see `EventFields`) - an omitted one would
|
|
369
|
+
// leave the placeholder (and the link to the now-cancelled meeting) in place.
|
|
370
|
+
const patch: EventFields = { location: restoredLocation || null, videoMeetingUid: null };
|
|
371
|
+
const patched = await updateCalendarEvent({ uid: saved.uid, version: saved.version, ...(patch as Partial<CalendarEventInput>) });
|
|
372
|
+
setVideoMeetingUid(undefined);
|
|
373
|
+
setOrganizerJoinUrl(undefined);
|
|
374
|
+
setLocation(restoredLocation);
|
|
375
|
+
return { event: patched, failed: false };
|
|
376
|
+
} catch (err) {
|
|
377
|
+
setVideoError(err instanceof ApiRequestError ? err.message : "Could not update this event's video conferencing.");
|
|
378
|
+
return { event: saved, failed: true };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
248
382
|
async function handleSubmit(e: FormEvent) {
|
|
249
383
|
e.preventDefault();
|
|
250
384
|
setError(null);
|
|
385
|
+
setVideoError(null);
|
|
251
386
|
|
|
252
387
|
if (!title.trim()) {
|
|
253
388
|
setError("A title is required.");
|
|
@@ -294,25 +429,42 @@ export default function EventModal({
|
|
|
294
429
|
|
|
295
430
|
setSaving(true);
|
|
296
431
|
try {
|
|
297
|
-
|
|
432
|
+
let saved: CalendarEvent;
|
|
433
|
+
let detachedSyncFailed = false;
|
|
434
|
+
if (savedEvent) {
|
|
435
|
+
// A retry after the event was stored but its video meeting couldn't be (see `savedEvent`):
|
|
436
|
+
// update exactly the record the first attempt left behind, whichever branch below created it.
|
|
437
|
+
// A series' own exception/detached-occurrence re-pointing already ran on that attempt and has
|
|
438
|
+
// nothing left to shift - the dates being sent again are the ones it moved everything to.
|
|
439
|
+
saved = await updateCalendarEvent({ uid: savedEvent.uid, version: savedEvent.version, ...(fields as Partial<CalendarEventInput>) });
|
|
440
|
+
} else if (!occurrence) {
|
|
298
441
|
// The organizer is only ever set on create - an edit keeps the event's own organizer.
|
|
299
|
-
await createCalendarEvent({
|
|
442
|
+
saved = await createCalendarEvent({
|
|
300
443
|
mailboxUid: targetMailboxUid,
|
|
301
444
|
folderUid: targetFolderUid,
|
|
302
445
|
...fields,
|
|
303
446
|
organizer: { address: effectiveOrganizerAddress, type: "to" },
|
|
304
447
|
} as CalendarEventInput);
|
|
305
448
|
} else if (editingSingleOccurrence) {
|
|
306
|
-
await detachOccurrence(occurrence, fields as Partial<CalendarEventInput>);
|
|
449
|
+
saved = await detachOccurrence(occurrence, fields as Partial<CalendarEventInput>);
|
|
307
450
|
} else if (occurrence.isRecurringOccurrence) {
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
setSyncWarning(true);
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
451
|
+
const series = await saveEventSeries(occurrence, (await toSeriesFields(occurrence, fields)) as Partial<CalendarEventInput>);
|
|
452
|
+
saved = series;
|
|
453
|
+
detachedSyncFailed = !!series.detachedOccurrenceSyncFailed;
|
|
314
454
|
} else {
|
|
315
|
-
await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...(fields as Partial<CalendarEventInput>) });
|
|
455
|
+
saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...(fields as Partial<CalendarEventInput>) });
|
|
456
|
+
}
|
|
457
|
+
const video = await applyVideoConferencing(saved);
|
|
458
|
+
if (video.failed) {
|
|
459
|
+
// The event itself is saved - keep the modal open on the inline error so the video meeting
|
|
460
|
+
// can be retried (or the toggle put back) rather than closing over it.
|
|
461
|
+
setSavedEvent(video.event);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (detachedSyncFailed) {
|
|
465
|
+
// The series itself was saved - say so, and leave closing to the user once they've read it.
|
|
466
|
+
setSyncWarning(true);
|
|
467
|
+
return;
|
|
316
468
|
}
|
|
317
469
|
onSaved();
|
|
318
470
|
} catch (err) {
|
|
@@ -433,6 +585,28 @@ export default function EventModal({
|
|
|
433
585
|
</p>
|
|
434
586
|
)}
|
|
435
587
|
|
|
588
|
+
{/* Outside the `disabled` fieldset below: joining a call is not editing the event, so it stays
|
|
589
|
+
available on an invitation's read-only copy too (where the link, like the meeting itself,
|
|
590
|
+
is only reachable by whoever holds access to its mailbox). */}
|
|
591
|
+
{videoMeetingUid && (
|
|
592
|
+
<div className="flex items-center gap-3 mb-3">
|
|
593
|
+
<Button
|
|
594
|
+
type="button"
|
|
595
|
+
variant="secondary"
|
|
596
|
+
className="!w-auto"
|
|
597
|
+
disabled={!organizerJoinUrl}
|
|
598
|
+
onClick={() => window.open(organizerJoinUrl!, "_blank", "noopener,noreferrer")}
|
|
599
|
+
>
|
|
600
|
+
Join video call
|
|
601
|
+
</Button>
|
|
602
|
+
{!organizerJoinUrl && (
|
|
603
|
+
<span className="text-xs text-text-muted">
|
|
604
|
+
{organizerJoinUrl === null ? "This meeting’s join link isn’t available." : "Loading the join link…"}
|
|
605
|
+
</span>
|
|
606
|
+
)}
|
|
607
|
+
</div>
|
|
608
|
+
)}
|
|
609
|
+
|
|
436
610
|
<fieldset disabled={isInvited} className="flex flex-col gap-1 min-w-0">
|
|
437
611
|
|
|
438
612
|
{!occurrence && mailboxOptions && mailboxOptions.length > 1 && (
|
|
@@ -478,6 +652,24 @@ export default function EventModal({
|
|
|
478
652
|
<input id="event-location" type="text" className={INPUT_CLASS} value={location} onChange={(e) => setLocation(e.target.value)} />
|
|
479
653
|
</FormField>
|
|
480
654
|
|
|
655
|
+
<div className="mb-3">
|
|
656
|
+
<label className="flex items-center gap-2 text-sm font-medium">
|
|
657
|
+
<input type="checkbox" checked={videoEnabled} onChange={(e) => setVideoEnabled(e.target.checked)} />
|
|
658
|
+
Add video conferencing
|
|
659
|
+
</label>
|
|
660
|
+
{videoEnabled && (
|
|
661
|
+
<p className="text-xs text-text-muted mt-1">
|
|
662
|
+
Changing attendees after enabling video conferencing won’t update meeting invitees —
|
|
663
|
+
turn this off and back on to reissue links to the current attendee list.
|
|
664
|
+
</p>
|
|
665
|
+
)}
|
|
666
|
+
{videoError && (
|
|
667
|
+
<p role="alert" className="text-xs text-danger mt-1">
|
|
668
|
+
{videoError}
|
|
669
|
+
</p>
|
|
670
|
+
)}
|
|
671
|
+
</div>
|
|
672
|
+
|
|
481
673
|
<label className="flex items-center gap-2 text-sm font-medium mb-3">
|
|
482
674
|
<input type="checkbox" checked={allDay} onChange={(e) => handleAllDayChange(e.target.checked)} />
|
|
483
675
|
All day
|
|
@@ -48,6 +48,7 @@ import NotificationHistoryDialog from "../../notifications/NotificationHistoryDi
|
|
|
48
48
|
import { notifySessionExpired, setSignInUrl } from "../../notifications/apiErrors.js";
|
|
49
49
|
import { useUnseenErrorCount } from "../../notifications/useNotifications.js";
|
|
50
50
|
import { useSigningEnrollmentWatcher } from "../../signing/useSigningEnrollmentWatcher.js";
|
|
51
|
+
import { useCalendarReminders } from "../../calendar/useCalendarReminders.js";
|
|
51
52
|
|
|
52
53
|
/** How long sign-out waits for auth-server's logout before navigating anyway. */
|
|
53
54
|
export const LOGOUT_TIMEOUT_MS = 3_000;
|
|
@@ -200,6 +201,8 @@ export function AppChrome({
|
|
|
200
201
|
const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
|
|
201
202
|
// A signing certificate the user asked for is announced when it is issued (or fails), on whichever page they are - see the hook.
|
|
202
203
|
useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
|
|
204
|
+
// A meeting reminder pops up on whichever page they are - see the hook.
|
|
205
|
+
useCalendarReminders({ userUid, enabled: inFrame });
|
|
203
206
|
|
|
204
207
|
useRedirectIfUnauthenticated(userUid, authServerUrl);
|
|
205
208
|
// Any request this app makes that the server answers with a 401 - the session ended - raises one "Your session expired" pop-up with a
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import React, { useEffect } from "react";
|
|
6
6
|
import type { IconType } from "react-icons";
|
|
7
7
|
import {
|
|
8
|
+
HiOutlineBell,
|
|
8
9
|
HiOutlineCheckCircle,
|
|
9
10
|
HiOutlineEnvelope,
|
|
10
11
|
HiOutlineExclamationCircle,
|
|
@@ -19,6 +20,7 @@ import { useSystemErrorNotifications } from "./systemErrors.js";
|
|
|
19
20
|
|
|
20
21
|
const KIND_STYLE: Record<NotificationKind, { border: string; icon: string; Icon: IconType; label: string }> = {
|
|
21
22
|
mail: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineEnvelope, label: "New message" },
|
|
23
|
+
calendar: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineBell, label: "Reminder" },
|
|
22
24
|
info: { border: "border-l-primary", icon: "text-primary-dark", Icon: HiOutlineInformationCircle, label: "Information" },
|
|
23
25
|
success: { border: "border-l-success", icon: "text-success", Icon: HiOutlineCheckCircle, label: "Success" },
|
|
24
26
|
warning: { border: "border-l-warning", icon: "text-warning", Icon: HiOutlineExclamationTriangle, label: "Warning" },
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* `notify()` only hands back an id.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
export type NotificationKind = "mail" | "info" | "success" | "warning" | "error";
|
|
29
|
+
export type NotificationKind = "mail" | "calendar" | "info" | "success" | "warning" | "error";
|
|
30
30
|
|
|
31
31
|
export interface NotificationAction {
|
|
32
32
|
label: string;
|
|
@@ -119,6 +119,8 @@ export const MAX_DETAIL_LINE_LENGTH = 500;
|
|
|
119
119
|
/** How long a non-sticky notification stays, by kind. Errors are sticky, so theirs is only for an explicit `sticky: false`. */
|
|
120
120
|
export const DEFAULT_TIMEOUT_MS: Record<NotificationKind, number> = {
|
|
121
121
|
mail: 8_000,
|
|
122
|
+
// Irrelevant in practice - a reminder always carries actions, which makes it sticky (see `applyInput()`) regardless of this value.
|
|
123
|
+
calendar: 8_000,
|
|
122
124
|
info: 6_000,
|
|
123
125
|
success: 5_000,
|
|
124
126
|
warning: 10_000,
|
|
@@ -5,12 +5,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
-
import { deleteDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
8
|
+
import { deleteDomain, updateDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
9
9
|
import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
|
|
10
10
|
import DomainDnsSetup from "../../shared/components/admin/settings/DomainDnsSetup.js";
|
|
11
11
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
12
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
13
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
13
14
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
15
|
+
const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
14
16
|
export default function DomainDetailPage(props) {
|
|
15
17
|
return (_jsx(AdminShell, { ...props, active: "domains", children: _jsx(DomainDetailContent, { uid: props.params.uid }) }));
|
|
16
18
|
}
|
|
@@ -19,6 +21,15 @@ function DomainDetailContent({ uid }) {
|
|
|
19
21
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
20
22
|
const [deleting, setDeleting] = useState(false);
|
|
21
23
|
const [deleteError, setDeleteError] = useState(null);
|
|
24
|
+
// Not initialized from `domain.aliasOf` until it first loads (`null` means "not yet") - once set, kept as
|
|
25
|
+
// the user's own edit rather than fighting later unrelated reloads (e.g. after "Verify now").
|
|
26
|
+
const [aliasInput, setAliasInput] = useState(null);
|
|
27
|
+
const [savingAlias, setSavingAlias] = useState(false);
|
|
28
|
+
const [aliasError, setAliasError] = useState(null);
|
|
29
|
+
const [aliasSaved, setAliasSaved] = useState(false);
|
|
30
|
+
// Bumped after a successful aliasOf save to force `DomainDnsSetup` (which owns its own `domain` state,
|
|
31
|
+
// fetched only on mount/uid change) to reload and reflect the new value in its own status panel.
|
|
32
|
+
const [refreshCount, setRefreshCount] = useState(0);
|
|
22
33
|
function closeDeleteModal() {
|
|
23
34
|
setConfirmingDelete(false);
|
|
24
35
|
}
|
|
@@ -35,5 +46,30 @@ function DomainDetailContent({ uid }) {
|
|
|
35
46
|
setDeleting(false);
|
|
36
47
|
}
|
|
37
48
|
}
|
|
38
|
-
|
|
49
|
+
function handleLoaded(d) {
|
|
50
|
+
setDomain(d);
|
|
51
|
+
setAliasInput((current) => (current === null ? d?.aliasOf ?? "" : current));
|
|
52
|
+
}
|
|
53
|
+
async function handleSaveAlias() {
|
|
54
|
+
setSavingAlias(true);
|
|
55
|
+
setAliasError(null);
|
|
56
|
+
setAliasSaved(false);
|
|
57
|
+
try {
|
|
58
|
+
const updated = await updateDomain({ uid: domain.uid, version: domain.version, aliasOf: aliasInput.trim() || undefined });
|
|
59
|
+
setDomain(updated);
|
|
60
|
+
setAliasInput(updated.aliasOf ?? "");
|
|
61
|
+
setAliasSaved(true);
|
|
62
|
+
setRefreshCount((c) => c + 1);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
setAliasError(err instanceof ApiRequestError ? err.message : "Could not update this domain.");
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
setSavingAlias(false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [domain && (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] })), _jsx(DomainDnsSetup, { uid: uid, onLoaded: handleLoaded }, refreshCount), domain && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "Alias" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Make this domain a pure alias of another one - it will still receive (and may send as) mail for the same addresses, but have no mailboxes of its own. Leave blank for a regular domain." }), aliasError && _jsx(Alert, { children: aliasError }), _jsx(FormField, { label: "Alias of", htmlFor: "aliasOf", children: _jsx("input", { id: "aliasOf", type: "text", className: INPUT_CLASS, value: aliasInput ?? "", onChange: (e) => {
|
|
72
|
+
setAliasInput(e.target.value);
|
|
73
|
+
setAliasSaved(false);
|
|
74
|
+
}, placeholder: "e.g. powerlevel.gg" }) }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: savingAlias, disabled: savingAlias || (aliasInput ?? "") === (domain.aliasOf ?? ""), onClick: handleSaveAlias, children: "Save" }), aliasSaved && _jsx("span", { className: "text-sm text-text-muted", children: "Saved." })] })] })), domain && (_jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete domain", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: domain.name }), "? This cannot be undone."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] }))] }));
|
|
39
75
|
}
|
|
@@ -27,5 +27,5 @@ function DomainsListContent() {
|
|
|
27
27
|
.finally(() => setLoading(false));
|
|
28
28
|
}, [page]);
|
|
29
29
|
const hasNextPage = domains.length === PAGE_SIZE;
|
|
30
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Domains" }), _jsx("a", { href: "/admin/domains/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New domain" }) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : domains.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No domains configured yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Enabled", "Verified", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: domains.map((domain) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.enabled ? "Yes" : "No" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/domains/${encodeURIComponent(domain.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, domain.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
|
|
30
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Domains" }), _jsx("a", { href: "/admin/domains/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New domain" }) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : domains.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No domains configured yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Enabled", "Verified", "Alias of", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: domains.map((domain) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.enabled ? "Yes" : "No" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: domain.aliasOf || "—" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/domains/${encodeURIComponent(domain.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, domain.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
|
|
31
31
|
}
|
|
@@ -3,9 +3,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { useState } from "react";
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
-
import { createDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
8
|
+
import { createDomain, listDomains } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
9
9
|
import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
|
|
10
10
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
11
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
@@ -16,8 +16,17 @@ export default function NewDomainPage(props) {
|
|
|
16
16
|
}
|
|
17
17
|
function NewDomainForm() {
|
|
18
18
|
const [name, setName] = useState("");
|
|
19
|
+
const [aliasOf, setAliasOf] = useState("");
|
|
20
|
+
// Only an existing, non-alias domain can be aliased (no chains) - see `Domain.aliasOf`'s own doc comment
|
|
21
|
+
// on the `@rapidmx/restapi` side.
|
|
22
|
+
const [primaryDomains, setPrimaryDomains] = useState([]);
|
|
19
23
|
const [error, setError] = useState(null);
|
|
20
24
|
const [saving, setSaving] = useState(false);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
listDomains({ limit: 200 })
|
|
27
|
+
.then((domains) => setPrimaryDomains(domains.filter((d) => !d.aliasOf)))
|
|
28
|
+
.catch(() => setPrimaryDomains([]));
|
|
29
|
+
}, []);
|
|
21
30
|
async function handleSubmit(e) {
|
|
22
31
|
e.preventDefault();
|
|
23
32
|
setError(null);
|
|
@@ -27,7 +36,7 @@ function NewDomainForm() {
|
|
|
27
36
|
}
|
|
28
37
|
setSaving(true);
|
|
29
38
|
try {
|
|
30
|
-
const domain = await createDomain({ name: name.trim() });
|
|
39
|
+
const domain = await createDomain({ name: name.trim(), aliasOf: aliasOf || undefined });
|
|
31
40
|
window.location.href = `/admin/domains/${encodeURIComponent(domain.uid)}`;
|
|
32
41
|
}
|
|
33
42
|
catch (err) {
|
|
@@ -37,5 +46,5 @@ function NewDomainForm() {
|
|
|
37
46
|
setSaving(false);
|
|
38
47
|
}
|
|
39
48
|
}
|
|
40
|
-
return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New domain" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "A new domain starts unverified. Once created, its detail page shows the DNS TXT record to publish to prove ownership before mail can be accepted on it." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Domain name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create domain" }), _jsx("a", { href: "/admin/domains", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
|
|
49
|
+
return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New domain" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "A new domain starts unverified. Once created, its detail page shows the DNS TXT record to publish to prove ownership before mail can be accepted on it." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Domain name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsxs(FormField, { label: "Alias of", htmlFor: "aliasOf", children: [_jsxs("select", { id: "aliasOf", className: INPUT_CLASS, value: aliasOf, onChange: (e) => setAliasOf(e.target.value), children: [_jsx("option", { value: "", children: "None - a regular domain with mailboxes of its own" }), primaryDomains.map((domain) => (_jsx("option", { value: domain.name, children: domain.name }, domain.uid)))] }), _jsx("p", { className: "text-xs text-text-muted mt-1.5", children: "A pure alias receives (and may send as) mail for the same addresses as the domain it aliases, but has no mailboxes of its own - it still needs its own DNS setup below." })] }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create domain" }), _jsx("a", { href: "/admin/domains", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
|
|
41
50
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
|
|
2
|
+
* arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
|
|
3
|
+
import type { PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
|
|
4
|
+
/** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
|
|
5
|
+
export declare const SNOOZE_MS: number;
|
|
6
|
+
/** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
|
|
7
|
+
export interface CalendarReminderNotice {
|
|
8
|
+
eventUid: string;
|
|
9
|
+
title: string;
|
|
10
|
+
/** The due occurrence's own start (an ISO string) - the series' `startDate` for a non-recurring event, one occurrence's for a recurring one. */
|
|
11
|
+
startDate: string;
|
|
12
|
+
/** The event's own `location`, verbatim - `undefined`/`null` when the event has none. Not necessarily a URL;
|
|
13
|
+
* see `joinMeetingUrl()` for when it's shown as a join link. */
|
|
14
|
+
location?: string | null;
|
|
15
|
+
}
|
|
16
|
+
/** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
|
|
17
|
+
* else (a room name, an address, free-text instructions are all real, common `location` values this must not
|
|
18
|
+
* treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
|
|
19
|
+
export declare function joinMeetingUrl(location: string | null | undefined): string | undefined;
|
|
20
|
+
/** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
|
|
21
|
+
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
|
+
/** The pop-up's message line: when the meeting starts, relative to `now`. */
|
|
26
|
+
export declare function reminderMessage(startDate: string, now?: number): string;
|
|
27
|
+
/** 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. */
|
|
28
|
+
export declare function reminderNotificationId(eventUid: string, startDate: string): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
|
|
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 reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
|
|
8
|
+
export const SNOOZE_MS = 5 * 60000;
|
|
9
|
+
function isReminderNotice(data) {
|
|
10
|
+
const notice = data;
|
|
11
|
+
return (!!notice &&
|
|
12
|
+
typeof notice.eventUid === "string" &&
|
|
13
|
+
typeof notice.title === "string" &&
|
|
14
|
+
typeof notice.startDate === "string" &&
|
|
15
|
+
(notice.location === undefined || notice.location === null || typeof notice.location === "string"));
|
|
16
|
+
}
|
|
17
|
+
/** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
|
|
18
|
+
* else (a room name, an address, free-text instructions are all real, common `location` values this must not
|
|
19
|
+
* treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
|
|
20
|
+
export function joinMeetingUrl(location) {
|
|
21
|
+
const trimmed = location?.trim();
|
|
22
|
+
if (!trimmed) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
let url;
|
|
26
|
+
try {
|
|
27
|
+
url = new URL(trimmed);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return url.protocol === "http:" || url.protocol === "https:" ? trimmed : undefined;
|
|
33
|
+
}
|
|
34
|
+
/** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
|
|
35
|
+
export function calendarReminderOf(event) {
|
|
36
|
+
return event.type === "CalendarEvent" && event.action === "reminder" && isReminderNotice(event.data) ? event.data : undefined;
|
|
37
|
+
}
|
|
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
|
+
/** The pop-up's message line: when the meeting starts, relative to `now`. */
|
|
44
|
+
export function reminderMessage(startDate, now = Date.now()) {
|
|
45
|
+
const time = new Date(startDate).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
46
|
+
const diffMinutes = Math.round((new Date(startDate).getTime() - now) / 60000);
|
|
47
|
+
if (diffMinutes <= 0) {
|
|
48
|
+
return `Starting now - ${time}`;
|
|
49
|
+
}
|
|
50
|
+
return `Starting in ${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} - ${time}`;
|
|
51
|
+
}
|
|
52
|
+
/** 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. */
|
|
53
|
+
export function reminderNotificationId(eventUid, startDate) {
|
|
54
|
+
return `calendar-reminder:${eventUid}:${startDate}`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** 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
|
|
2
|
+
* `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
|
|
3
|
+
export declare const CALENDAR_HREF = "/calendar";
|
|
4
|
+
export interface UseCalendarRemindersOptions {
|
|
5
|
+
/** Nothing is shown without a signed-in user. */
|
|
6
|
+
userUid?: string;
|
|
7
|
+
/** Runs only while true - the persistent app frame turns it on, like `useMailConnection`'s identical option. */
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Pop-up meeting reminders. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate }`)
|
|
12
|
+
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
|
|
13
|
+
* (`getPushClient()`) - every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the
|
|
14
|
+
* event is published to the mailbox channel as well as the folder's own), so this hook only has to listen, never to manage channels of its
|
|
15
|
+
* own. Lives in the persistent app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so a reminder shows wherever the user is signed in -
|
|
16
|
+
* Mail, Calendar, Contacts, Settings.
|
|
17
|
+
*
|
|
18
|
+
* Each reminder is a sticky pop-up (`notify()`, kind `"calendar"`) with two actions: **Dismiss** closes it and does nothing else. **Snooze**
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
export declare function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions): void;
|