@rapidmx/web-client 0.14.0 → 0.15.1
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 +3 -2
- package/apps/shared/appearance/AppearanceForm.tsx +9 -1
- package/apps/shared/calendar/bookingPlugin.ts +100 -0
- package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
- package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
- package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
- package/apps/shared/components/calendar/EventDetails.tsx +401 -0
- package/apps/shared/components/calendar/EventEditor.tsx +640 -0
- package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
- package/apps/shared/components/calendar/EventModal.tsx +126 -862
- package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
- package/apps/shared/components/calendar/EventShell.tsx +202 -0
- package/apps/shared/components/calendar/FindATime.tsx +379 -0
- package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
- package/apps/shared/components/calendar/MonthView.tsx +15 -3
- package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
- package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
- package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
- package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
- package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
- package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
- package/apps/shared/components/calendar/eventForm.ts +108 -0
- package/apps/shared/components/calendar/eventFormat.ts +183 -0
- package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
- package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
- package/apps/shared/components/layout/UserMenu.tsx +80 -28
- package/apps/shared/components/mail/ConversationList.tsx +100 -48
- package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
- package/apps/shared/components/mail/InviteCard.tsx +387 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
- package/apps/shared/components/mail/SwipeRow.tsx +120 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
- package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
- package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
- package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
- package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
- package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/gestures/useEnterSlide.ts +54 -0
- package/apps/shared/gestures/useSwipe.ts +114 -0
- package/apps/shared/gestures/useSwipeSlide.ts +113 -0
- package/apps/shared/gestures/useWheelPaging.ts +107 -0
- package/apps/shared/mail/newMailNotifications.ts +171 -183
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/notifications/preferences.ts +36 -0
- package/apps/shared/notifications/store.ts +560 -551
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +139 -37
- package/apps/www/index.tsx +273 -126
- package/apps/www/messages/[uid].tsx +31 -4
- package/apps/www/settings/profile/index.tsx +248 -0
- package/dist/apps/admin/domains/[uid].js +4 -2
- package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
- package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
- package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
- package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
- package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
- package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
- package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
- package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
- package/dist/apps/shared/components/calendar/EventModal.js +57 -455
- package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
- package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
- package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
- package/dist/apps/shared/components/calendar/EventShell.js +134 -0
- package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
- package/dist/apps/shared/components/calendar/FindATime.js +190 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
- package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
- package/dist/apps/shared/components/calendar/MonthView.js +11 -5
- package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
- package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
- package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
- package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
- package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
- package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
- package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
- package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
- package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
- package/dist/apps/shared/components/calendar/eventForm.js +1 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
- package/dist/apps/shared/components/layout/UserMenu.js +70 -23
- package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
- package/dist/apps/shared/components/mail/ConversationList.js +21 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
- package/dist/apps/shared/components/mail/InviteCard.js +150 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
- package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
- package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
- package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
- package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
- package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
- package/dist/apps/shared/gestures/useSwipe.js +90 -0
- package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
- package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
- package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
- package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
- package/dist/apps/shared/mail/newMailNotifications.js +3 -12
- package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
- package/dist/apps/shared/notifications/preferences.d.ts +13 -0
- package/dist/apps/shared/notifications/preferences.js +36 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -0
- package/dist/apps/shared/notifications/store.js +7 -23
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +76 -6
- package/dist/apps/www/index.js +140 -39
- package/dist/apps/www/messages/[uid].js +13 -5
- package/dist/apps/www/settings/profile/index.d.ts +4 -0
- package/dist/apps/www/settings/profile/index.js +124 -0
- package/package.json +3 -3
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useId, useState } from "react";
|
|
6
|
+
import type { MessageInvite, ProposedTime } from "@rapidmx/react-shared/calendar/inviteApi.js";
|
|
7
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
8
|
+
import { isoDay, validDate } from "./inviteFormat.js";
|
|
9
|
+
|
|
10
|
+
const INPUT_CLASS = "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface text-text";
|
|
11
|
+
|
|
12
|
+
/** `HH:MM` of an instant in the reader's zone, the value of a time input. */
|
|
13
|
+
function clock(date: Date): string {
|
|
14
|
+
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ProposeTimeFormProps {
|
|
18
|
+
/** The invitation being answered: its own time (in the reader's zone) is what the fields start out as. */
|
|
19
|
+
invite: Pick<MessageInvite, "startDate" | "endDate">;
|
|
20
|
+
/** A proposal is on its way: the form is off. */
|
|
21
|
+
busy?: boolean;
|
|
22
|
+
/** Called with the proposed instants once the fields are valid. The caller reports a failure and closes the form on success. */
|
|
23
|
+
onSubmit: (proposal: ProposedTime) => void;
|
|
24
|
+
onCancel: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The small form behind "Propose new time": a date and a start and end time - in the reader's own time zone, prefilled with the invitation's own
|
|
29
|
+
* time - and an optional note to the organizer. It validates what it can (all three fields, and an end after the start) before anything is sent.
|
|
30
|
+
* The end is on the start's day: a proposal spans one day, as Outlook's does from a list.
|
|
31
|
+
*/
|
|
32
|
+
export default function ProposeTimeForm({ invite, busy = false, onSubmit, onCancel }: ProposeTimeFormProps) {
|
|
33
|
+
const start = validDate(invite.startDate);
|
|
34
|
+
const end = validDate(invite.endDate);
|
|
35
|
+
const [date, setDate] = useState(start ? isoDay(start) : "");
|
|
36
|
+
const [startTime, setStartTime] = useState(start ? clock(start) : "");
|
|
37
|
+
const [endTime, setEndTime] = useState(end ? clock(end) : "");
|
|
38
|
+
const [comment, setComment] = useState("");
|
|
39
|
+
const [error, setError] = useState<string | null>(null);
|
|
40
|
+
const id = useId();
|
|
41
|
+
|
|
42
|
+
function submit(event: React.FormEvent) {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
if (!date || !startTime || !endTime) {
|
|
45
|
+
setError("Choose a date and a start and end time.");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const proposedStart = new Date(`${date}T${startTime}:00`);
|
|
49
|
+
const proposedEnd = new Date(`${date}T${endTime}:00`);
|
|
50
|
+
if (proposedEnd <= proposedStart) {
|
|
51
|
+
setError("The end time must be after the start time.");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
setError(null);
|
|
55
|
+
const note = comment.trim();
|
|
56
|
+
onSubmit({ startDate: proposedStart.toISOString(), endDate: proposedEnd.toISOString(), ...(note ? { comment: note } : {}) });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<form aria-label="Propose a new time" onSubmit={submit} noValidate className="flex flex-col gap-2 text-sm">
|
|
61
|
+
<div className="flex flex-wrap gap-2">
|
|
62
|
+
<div className="flex-1 min-w-[9rem]">
|
|
63
|
+
<label htmlFor={`${id}-date`} className="block text-xs text-text-muted mb-0.5">
|
|
64
|
+
Date
|
|
65
|
+
</label>
|
|
66
|
+
<input id={`${id}-date`} type="date" value={date} disabled={busy} onChange={(e) => setDate(e.target.value)} className={INPUT_CLASS} />
|
|
67
|
+
</div>
|
|
68
|
+
<div className="flex-1 min-w-[6rem]">
|
|
69
|
+
<label htmlFor={`${id}-start`} className="block text-xs text-text-muted mb-0.5">
|
|
70
|
+
Start
|
|
71
|
+
</label>
|
|
72
|
+
<input id={`${id}-start`} type="time" value={startTime} disabled={busy} onChange={(e) => setStartTime(e.target.value)} className={INPUT_CLASS} />
|
|
73
|
+
</div>
|
|
74
|
+
<div className="flex-1 min-w-[6rem]">
|
|
75
|
+
<label htmlFor={`${id}-end`} className="block text-xs text-text-muted mb-0.5">
|
|
76
|
+
End
|
|
77
|
+
</label>
|
|
78
|
+
<input id={`${id}-end`} type="time" value={endTime} disabled={busy} onChange={(e) => setEndTime(e.target.value)} className={INPUT_CLASS} />
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<div>
|
|
82
|
+
<label htmlFor={`${id}-comment`} className="block text-xs text-text-muted mb-0.5">
|
|
83
|
+
Comment (optional)
|
|
84
|
+
</label>
|
|
85
|
+
<textarea
|
|
86
|
+
id={`${id}-comment`}
|
|
87
|
+
rows={2}
|
|
88
|
+
value={comment}
|
|
89
|
+
disabled={busy}
|
|
90
|
+
onChange={(e) => setComment(e.target.value)}
|
|
91
|
+
className={`${INPUT_CLASS} resize-y`}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
{error && (
|
|
95
|
+
<p role="alert" className="text-danger">
|
|
96
|
+
{error}
|
|
97
|
+
</p>
|
|
98
|
+
)}
|
|
99
|
+
<div className="flex flex-wrap gap-2">
|
|
100
|
+
<Button type="submit" className="!w-auto" loading={busy} disabled={busy} aria-busy={busy || undefined}>
|
|
101
|
+
Send proposal
|
|
102
|
+
</Button>
|
|
103
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={onCancel}>
|
|
104
|
+
Cancel
|
|
105
|
+
</Button>
|
|
106
|
+
</div>
|
|
107
|
+
</form>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import type { Attachment } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
6
|
+
import type { InviteResponse, MessageInvite } from "@rapidmx/react-shared/calendar/inviteApi.js";
|
|
7
|
+
import { APP_HREFS } from "../../../navigation/appHrefs.js";
|
|
8
|
+
|
|
9
|
+
/** Whether an attachment is a calendar file - what tells the pane a message may carry an invitation worth asking the server about. By type
|
|
10
|
+
* or by name, since mail clients disagree about which one they set (`text/calendar`, `application/ics`, or a bare `.ics` as octet-stream). */
|
|
11
|
+
export function isCalendarAttachment(attachment: Pick<Attachment, "filename" | "mimeType">): boolean {
|
|
12
|
+
const type = (attachment.mimeType ?? "").split(";")[0].trim().toLowerCase();
|
|
13
|
+
return type === "text/calendar" || type === "application/ics" || (attachment.filename ?? "").trim().toLowerCase().endsWith(".ics");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DAY_FORMAT: Intl.DateTimeFormatOptions = { weekday: "short", year: "numeric", month: "short", day: "numeric" };
|
|
17
|
+
const CLOCK_FORMAT: Intl.DateTimeFormatOptions = { hour: "numeric", minute: "2-digit" };
|
|
18
|
+
|
|
19
|
+
/** `zone` if the browser knows it, else UTC - an all-day date is a calendar day, and needs a fixed zone to stay the same day for every reader. */
|
|
20
|
+
export function allDayZone(zone: string | undefined): string {
|
|
21
|
+
try {
|
|
22
|
+
new Intl.DateTimeFormat(undefined, { timeZone: zone });
|
|
23
|
+
return zone ?? "UTC";
|
|
24
|
+
} catch {
|
|
25
|
+
return "UTC";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The instant an ISO string names, or `undefined` when it is missing or unparseable. */
|
|
30
|
+
export function validDate(iso: string | undefined): Date | undefined {
|
|
31
|
+
const date = iso ? new Date(iso) : undefined;
|
|
32
|
+
return date && !isNaN(date.getTime()) ? date : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* When the meeting is, in the reader's own locale and time zone: one date and a time range for a same-day event, both ends in full for
|
|
37
|
+
* one that spans days, and dates only for an all-day event (drawn in the organizer's zone, since a calendar day is not an instant). `undefined`
|
|
38
|
+
* when the invitation names no usable start. A missing or unusable end shows the start alone.
|
|
39
|
+
*/
|
|
40
|
+
export function formatInviteWhen(invite: Pick<MessageInvite, "startDate" | "endDate" | "allDay" | "timezone">): string | undefined {
|
|
41
|
+
const start = validDate(invite.startDate);
|
|
42
|
+
if (!start) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const parsedEnd = validDate(invite.endDate);
|
|
46
|
+
const end = parsedEnd && parsedEnd > start ? parsedEnd : undefined;
|
|
47
|
+
if (invite.allDay) {
|
|
48
|
+
const day = new Intl.DateTimeFormat(undefined, { ...DAY_FORMAT, timeZone: allDayZone(invite.timezone) });
|
|
49
|
+
const first = day.format(start);
|
|
50
|
+
// An all-day event's end is the midnight after its last day (iCalendar's DTEND is exclusive), so the last day is a moment before it.
|
|
51
|
+
const last = end ? day.format(new Date(end.getTime() - 1)) : first;
|
|
52
|
+
return last === first ? first : `${first} - ${last}`;
|
|
53
|
+
}
|
|
54
|
+
const date = new Intl.DateTimeFormat(undefined, DAY_FORMAT);
|
|
55
|
+
const time = new Intl.DateTimeFormat(undefined, CLOCK_FORMAT);
|
|
56
|
+
// The zone is named once, after the last time shown: the times are the reader's own, not the organizer's.
|
|
57
|
+
const timeAndZone = new Intl.DateTimeFormat(undefined, { ...CLOCK_FORMAT, timeZoneName: "short" });
|
|
58
|
+
const startDay = date.format(start);
|
|
59
|
+
if (!end) {
|
|
60
|
+
return `${startDay}, ${timeAndZone.format(start)}`;
|
|
61
|
+
}
|
|
62
|
+
return startDay === date.format(end)
|
|
63
|
+
? `${startDay}, ${time.format(start)} - ${timeAndZone.format(end)}`
|
|
64
|
+
: `${startDay}, ${time.format(start)} - ${date.format(end)}, ${timeAndZone.format(end)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The meeting's start the way a list row writes it - `Thu 9/24/2026 1:00 PM` (dates only for an all-day event), in the reader's zone. */
|
|
68
|
+
export function formatRowStart(invite: Pick<MessageInvite, "startDate" | "allDay" | "timezone">): string | undefined {
|
|
69
|
+
const start = validDate(invite.startDate);
|
|
70
|
+
if (!start) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const zone = invite.allDay ? allDayZone(invite.timezone) : undefined;
|
|
74
|
+
const weekday = new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: zone }).format(start);
|
|
75
|
+
const date = new Intl.DateTimeFormat(undefined, { year: "numeric", month: "numeric", day: "numeric", timeZone: zone }).format(start);
|
|
76
|
+
return invite.allDay ? `${weekday} ${date}` : `${weekday} ${date} ${new Intl.DateTimeFormat(undefined, CLOCK_FORMAT).format(start)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `YYYY-MM-DD` of an instant in the reader's zone, or in `timeZone` when given (`en-CA` writes a date that way). */
|
|
80
|
+
export function isoDay(date: Date, timeZone?: string): string {
|
|
81
|
+
return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone }).format(date);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Where "Open in Calendar" goes: the day view of the day the meeting starts (the `date`/`view` the calendar page reads), or the calendar itself
|
|
85
|
+
* when the invitation names no usable start. An all-day event's day is the organizer's; any other is the reader's. */
|
|
86
|
+
export function calendarHref(invite: Pick<MessageInvite, "startDate" | "allDay" | "timezone">): string {
|
|
87
|
+
const start = validDate(invite.startDate);
|
|
88
|
+
if (!start) {
|
|
89
|
+
return APP_HREFS.calendar;
|
|
90
|
+
}
|
|
91
|
+
return `${APP_HREFS.calendar}?date=${isoDay(start, invite.allDay ? allDayZone(invite.timezone) : undefined)}&view=day`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The reader's answer as a list row says it. */
|
|
95
|
+
export const ANSWER_LABEL: Record<InviteResponse, string> = {
|
|
96
|
+
accepted: "Accepted",
|
|
97
|
+
tentative: "Tentative",
|
|
98
|
+
declined: "Declined",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The most conflicting events that are named; more are counted. */
|
|
102
|
+
const MAX_NAMED_CONFLICTS = 3;
|
|
103
|
+
|
|
104
|
+
/** The titles of the events an invitation conflicts with, as "A, B and 2 more". */
|
|
105
|
+
export function conflictSummary(conflicts: { title: string }[]): string {
|
|
106
|
+
const titles = conflicts.map((c) => c.title.trim() || "(no title)");
|
|
107
|
+
return titles.length <= MAX_NAMED_CONFLICTS
|
|
108
|
+
? titles.join(", ")
|
|
109
|
+
: `${titles.slice(0, MAX_NAMED_CONFLICTS).join(", ")} and ${titles.length - MAX_NAMED_CONFLICTS} more`;
|
|
110
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useCallback, useEffect, useReducer } from "react";
|
|
6
|
+
import { MessageInvite, getMessageInvite } from "@rapidmx/react-shared/calendar/inviteApi.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A per-message cache of the calendar invitation the server reads out of a message, shared by everything that draws one: the reading pane's
|
|
10
|
+
* card, the list row's RSVP chip and its popover. They ask for the same message a moment apart (the row's chip, then the pane once it is
|
|
11
|
+
* opened), and an answer given in one has to show in the others, so a lookup is made once and every answer goes through `storeInvite()`.
|
|
12
|
+
*
|
|
13
|
+
* A message with no invitation (`null`, the server's 404) is remembered too. A lookup that fails is not - nothing is cached and the next
|
|
14
|
+
* thing to ask (a remount, another row) tries again - but nothing retries on its own. An entry goes stale after `INVITE_TTL_MS`, because the
|
|
15
|
+
* calendar it describes can change elsewhere (the meeting deleted, another device answering); it is refetched when next asked for.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** How long a looked-up invitation is trusted before the next ask reads it again. */
|
|
19
|
+
export const INVITE_TTL_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
interface Entry {
|
|
22
|
+
promise: Promise<MessageInvite | null>;
|
|
23
|
+
/** `undefined` until the lookup answers; `null` for a message with no readable invitation. */
|
|
24
|
+
value?: MessageInvite | null;
|
|
25
|
+
at: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const entries = new Map<string, Entry>();
|
|
29
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
30
|
+
|
|
31
|
+
function notify(uid: string): void {
|
|
32
|
+
listeners.get(uid)?.forEach((listener) => listener());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Older servers, or a partial answer, may leave a list out: a card must never fail to draw over one. */
|
|
36
|
+
function normalize(invite: MessageInvite | null): MessageInvite | null {
|
|
37
|
+
if (!invite) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const partial: Partial<MessageInvite> = invite;
|
|
41
|
+
return { ...invite, attendees: partial.attendees ?? [], conflicts: partial.conflicts ?? [], schedule: partial.schedule ?? [] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reads a message's invitation, from the cache when it is fresh. Rejects when the lookup fails. */
|
|
45
|
+
export function loadInvite(uid: string): Promise<MessageInvite | null> {
|
|
46
|
+
const existing = entries.get(uid);
|
|
47
|
+
if (existing && (existing.value === undefined || Date.now() - existing.at < INVITE_TTL_MS)) {
|
|
48
|
+
return existing.promise;
|
|
49
|
+
}
|
|
50
|
+
const entry: Entry = {
|
|
51
|
+
at: Date.now(),
|
|
52
|
+
promise: getMessageInvite(uid).then(
|
|
53
|
+
(invite) => {
|
|
54
|
+
entry.value = normalize(invite);
|
|
55
|
+
entry.at = Date.now();
|
|
56
|
+
notify(uid);
|
|
57
|
+
return entry.value;
|
|
58
|
+
},
|
|
59
|
+
(err: unknown) => {
|
|
60
|
+
if (entries.get(uid) === entry) {
|
|
61
|
+
entries.delete(uid);
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
},
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
entries.set(uid, entry);
|
|
68
|
+
return entry.promise;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Records the invitation as it now stands after an answer, so the pane, the chip and the popover all show it. */
|
|
72
|
+
export function storeInvite(uid: string, invite: MessageInvite): void {
|
|
73
|
+
const value = normalize(invite);
|
|
74
|
+
entries.set(uid, { promise: Promise.resolve(value), value, at: Date.now() });
|
|
75
|
+
notify(uid);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Forgets everything - for tests, and for a sign-out. */
|
|
79
|
+
export function clearInviteCache(): void {
|
|
80
|
+
entries.clear();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A message's invitation, looked up once (shared with every other user of the same message) when `enabled`. `invite` is `null` until it is
|
|
85
|
+
* known, and stays `null` for a message with none or a lookup that failed - nothing to draw either way. `settled` says the lookup is over.
|
|
86
|
+
* Nothing is asked while disabled, and a failure is never retried from here.
|
|
87
|
+
*/
|
|
88
|
+
export function useMessageInvite(uid: string, enabled = true): { invite: MessageInvite | null; settled: boolean; setInvite: (invite: MessageInvite) => void } {
|
|
89
|
+
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!enabled) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
let cancelled = false;
|
|
95
|
+
const update = () => {
|
|
96
|
+
if (!cancelled) {
|
|
97
|
+
rerender();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const set = listeners.get(uid) ?? new Set();
|
|
101
|
+
set.add(update);
|
|
102
|
+
listeners.set(uid, set);
|
|
103
|
+
loadInvite(uid).then(update, () => {
|
|
104
|
+
// Nothing to show, and nothing worth interrupting the reader with: the message reads the same without it.
|
|
105
|
+
});
|
|
106
|
+
return () => {
|
|
107
|
+
cancelled = true;
|
|
108
|
+
set.delete(update);
|
|
109
|
+
};
|
|
110
|
+
}, [uid, enabled]);
|
|
111
|
+
const setInvite = useCallback((invite: MessageInvite) => storeInvite(uid, invite), [uid]);
|
|
112
|
+
const value = enabled ? entries.get(uid)?.value : undefined;
|
|
113
|
+
return { invite: value ?? null, settled: value !== undefined, setInvite };
|
|
114
|
+
}
|