@rapidmx/web-client 0.14.1 → 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.
Files changed (171) hide show
  1. package/apps/admin/domains/[uid].tsx +3 -2
  2. package/apps/shared/appearance/AppearanceForm.tsx +9 -1
  3. package/apps/shared/calendar/bookingPlugin.ts +100 -0
  4. package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
  6. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
  7. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
  8. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
  9. package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
  10. package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
  11. package/apps/shared/components/calendar/EventDetails.tsx +401 -0
  12. package/apps/shared/components/calendar/EventEditor.tsx +640 -0
  13. package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
  14. package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
  15. package/apps/shared/components/calendar/EventModal.tsx +126 -866
  16. package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
  17. package/apps/shared/components/calendar/EventShell.tsx +202 -0
  18. package/apps/shared/components/calendar/FindATime.tsx +379 -0
  19. package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
  20. package/apps/shared/components/calendar/MonthView.tsx +15 -3
  21. package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
  22. package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
  23. package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
  24. package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
  25. package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
  26. package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
  27. package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
  28. package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
  29. package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
  30. package/apps/shared/components/calendar/eventForm.ts +108 -0
  31. package/apps/shared/components/calendar/eventFormat.ts +183 -0
  32. package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
  33. package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
  34. package/apps/shared/components/layout/UserMenu.tsx +80 -28
  35. package/apps/shared/components/mail/ConversationList.tsx +100 -48
  36. package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
  37. package/apps/shared/components/mail/InviteCard.tsx +387 -0
  38. package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
  39. package/apps/shared/components/mail/SwipeRow.tsx +120 -0
  40. package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
  41. package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
  42. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
  43. package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
  44. package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
  45. package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
  46. package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
  47. package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
  48. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  49. package/apps/shared/gestures/useEnterSlide.ts +54 -0
  50. package/apps/shared/gestures/useSwipe.ts +114 -0
  51. package/apps/shared/gestures/useSwipeSlide.ts +113 -0
  52. package/apps/shared/gestures/useWheelPaging.ts +107 -0
  53. package/apps/shared/mail/newMailNotifications.ts +171 -183
  54. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  55. package/apps/shared/notifications/preferences.ts +36 -0
  56. package/apps/shared/notifications/store.ts +560 -551
  57. package/apps/www/_routes.ts +1 -0
  58. package/apps/www/calendar/index.tsx +139 -37
  59. package/apps/www/index.tsx +273 -126
  60. package/apps/www/messages/[uid].tsx +31 -4
  61. package/apps/www/settings/profile/index.tsx +248 -0
  62. package/dist/apps/admin/domains/[uid].js +4 -2
  63. package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
  64. package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
  65. package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
  66. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
  67. package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
  68. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  69. package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
  70. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
  71. package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
  72. package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
  73. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
  74. package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
  75. package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
  76. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
  77. package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
  78. package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
  79. package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
  80. package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
  81. package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
  82. package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
  83. package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
  84. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
  85. package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
  86. package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
  87. package/dist/apps/shared/components/calendar/EventModal.js +57 -459
  88. package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
  89. package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
  90. package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
  91. package/dist/apps/shared/components/calendar/EventShell.js +134 -0
  92. package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
  93. package/dist/apps/shared/components/calendar/FindATime.js +190 -0
  94. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
  95. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
  96. package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
  97. package/dist/apps/shared/components/calendar/MonthView.js +11 -5
  98. package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
  99. package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
  100. package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
  101. package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
  102. package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
  103. package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
  104. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  105. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
  106. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
  107. package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
  108. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
  109. package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
  110. package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
  111. package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
  112. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
  113. package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
  114. package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
  115. package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
  116. package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
  117. package/dist/apps/shared/components/calendar/eventForm.js +1 -0
  118. package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
  119. package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
  120. package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
  121. package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
  122. package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
  123. package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
  124. package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
  125. package/dist/apps/shared/components/layout/UserMenu.js +70 -23
  126. package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
  127. package/dist/apps/shared/components/mail/ConversationList.js +21 -4
  128. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  129. package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
  130. package/dist/apps/shared/components/mail/InviteCard.js +150 -0
  131. package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
  132. package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
  133. package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
  134. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
  135. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
  136. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
  137. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
  138. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
  139. package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
  140. package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
  141. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
  142. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
  143. package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
  144. package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
  145. package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
  146. package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
  147. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  148. package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
  149. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  150. package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
  151. package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
  152. package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
  153. package/dist/apps/shared/gestures/useSwipe.js +90 -0
  154. package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
  155. package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
  156. package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
  157. package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
  158. package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
  159. package/dist/apps/shared/mail/newMailNotifications.js +3 -12
  160. package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
  161. package/dist/apps/shared/notifications/preferences.d.ts +13 -0
  162. package/dist/apps/shared/notifications/preferences.js +36 -0
  163. package/dist/apps/shared/notifications/store.d.ts +1 -0
  164. package/dist/apps/shared/notifications/store.js +7 -23
  165. package/dist/apps/www/_routes.js +1 -0
  166. package/dist/apps/www/calendar/index.js +76 -6
  167. package/dist/apps/www/index.js +140 -39
  168. package/dist/apps/www/messages/[uid].js +13 -5
  169. package/dist/apps/www/settings/profile/index.d.ts +4 -0
  170. package/dist/apps/www/settings/profile/index.js +124 -0
  171. package/package.json +2 -2
@@ -0,0 +1,387 @@
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 { HiOutlineCalendarDays, HiOutlineCheck } from "react-icons/hi2";
7
+ import {
8
+ InviteParticipant,
9
+ InviteResponse,
10
+ MessageInvite,
11
+ ProposedTime,
12
+ acceptProposal,
13
+ proposeNewTime,
14
+ removeMessageInvite,
15
+ respondToMessageInvite,
16
+ } from "@rapidmx/react-shared/calendar/inviteApi.js";
17
+ import { guestPermissionsOf } from "@rapidmx/react-shared/calendar/calendarApi.js";
18
+ import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
19
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
20
+ import EventDescriptionView from "../calendar/EventDescriptionView.js";
21
+ import RequestChangeForm from "../calendar/RequestChangeForm.js";
22
+ import { VISIBILITY_LABEL, describeGuestPermissions } from "../calendar/eventFormat.js";
23
+ import { notifyApiError } from "../../notifications/apiErrors.js";
24
+ import { calendarHref, conflictSummary, formatInviteWhen } from "./invite/inviteFormat.js";
25
+ import { useMessageInvite } from "./invite/inviteStore.js";
26
+ import ProposeTimeForm from "./invite/ProposeTimeForm.js";
27
+
28
+ export { formatInviteWhen, isCalendarAttachment } from "./invite/inviteFormat.js";
29
+
30
+ const HEADINGS: Record<string, string> = {
31
+ REQUEST: "Meeting invitation",
32
+ CANCEL: "Meeting canceled",
33
+ REPLY: "Meeting response",
34
+ COUNTER: "New time proposed",
35
+ };
36
+
37
+ /** The heading of a `COUNTER` that is a guest's request for a change (rather than a different time). */
38
+ const CHANGE_REQUEST_HEADING = "Change requested";
39
+
40
+ /** How an answer reads in "Bob accepted." (a REPLY) - a participant who has not answered has not answered. */
41
+ const REPLY_VERBS: Partial<Record<NonNullable<InviteParticipant["responseStatus"]>, string>> = {
42
+ accepted: "accepted",
43
+ tentative: "tentatively accepted",
44
+ declined: "declined",
45
+ };
46
+
47
+ /** The reader's own answer, as the line under the invitation says it. */
48
+ const ANSWER_TEXT: Record<InviteResponse, string> = {
49
+ accepted: "You accepted this meeting.",
50
+ tentative: "You tentatively accepted this meeting.",
51
+ declined: "You declined this meeting. It is not on your calendar.",
52
+ };
53
+
54
+ const ANSWER_CLASS: Record<InviteResponse, string> = {
55
+ accepted: "text-success",
56
+ tentative: "text-text",
57
+ declined: "text-danger",
58
+ };
59
+
60
+ const RESPONSES: { response: InviteResponse; label: string; failure: string }[] = [
61
+ { response: "accepted", label: "Accept", failure: "Couldn't accept this meeting" },
62
+ { response: "tentative", label: "Tentative", failure: "Couldn't respond tentatively to this meeting" },
63
+ { response: "declined", label: "Decline", failure: "Couldn't decline this meeting" },
64
+ ];
65
+
66
+ /** The most attendees that are listed by name; a longer list is only counted. */
67
+ const MAX_NAMED_ATTENDEES = 5;
68
+
69
+ /** What the card is doing for the reader right now: an answer being sent, an add/remove, a proposal, an accepted proposal. */
70
+ type Pending = InviteResponse | "add" | "remove" | "propose" | "acceptProposal";
71
+
72
+ function attendeeSummary(attendees: InviteParticipant[]): string | undefined {
73
+ if (attendees.length === 0) {
74
+ return undefined;
75
+ }
76
+ const count = `${attendees.length} ${attendees.length === 1 ? "attendee" : "attendees"}`;
77
+ return attendees.length <= MAX_NAMED_ATTENDEES ? `${count}: ${attendees.map((a) => a.displayName || a.address).join(", ")}` : count;
78
+ }
79
+
80
+ export interface InviteCardProps {
81
+ /** The message whose calendar invitation to show, if it carries one. Nothing is drawn until the server says there is one. */
82
+ messageUid: string;
83
+ /** The level of the card's heading, so it sits below whatever heading its surroundings have (`2` in the reading pane, `3` inside a thread's card). */
84
+ headingLevel?: 2 | 3;
85
+ }
86
+
87
+ /**
88
+ * The calendar invitation a message carries, as Outlook and Gmail draw it: what the meeting is, when and where, who organizes it, and the buttons
89
+ * that answer it - Accept, Tentative, Decline (and Propose new time) for a request; "Add to calendar" for a published event; "Remove from
90
+ * calendar" for a cancellation; "Accept proposal" for an attendee's proposed new time. An attendee's answer to the reader's own invitation is
91
+ * one line ("Bob tentatively accepted."). Everything that follows an action (the meeting going onto the calendar or off it, the reply to the
92
+ * organizer) is the server's; this only asks for it and draws the invitation the server sends back.
93
+ *
94
+ * Asks the server once per message (through the shared invitation cache, so the list row's RSVP chip and this card agree). A message with no readable
95
+ * invitation, and a lookup that fails for any reason, draw nothing - a lookup that fails is never retried and never disturbs the message around it.
96
+ * An answer that arrives after the message changed or this was unmounted is dropped.
97
+ */
98
+ export default function InviteCard({ messageUid, headingLevel = 2 }: InviteCardProps) {
99
+ const { invite, setInvite } = useMessageInvite(messageUid);
100
+ const [pending, setPending] = useState<Pending | null>(null);
101
+ const [proposing, setProposing] = useState(false);
102
+ // Said once after a proposal went out: the invitation itself doesn't change.
103
+ const [proposed, setProposed] = useState(false);
104
+ // A guest's request for a change (or for guests to be added): the form is open, and once it went out.
105
+ const [requesting, setRequesting] = useState(false);
106
+ const [requested, setRequested] = useState(false);
107
+ const headingId = useId();
108
+
109
+ if (!invite) {
110
+ return null;
111
+ }
112
+
113
+ /** Runs one of the card's actions: the buttons are off while it does, its result replaces what the card shows, and a failure leaves the card as it was. */
114
+ async function run(action: Pending, failure: string, call: () => Promise<MessageInvite>): Promise<boolean> {
115
+ setPending(action);
116
+ try {
117
+ setInvite(await call());
118
+ return true;
119
+ } catch (err) {
120
+ notifyApiError(err, failure);
121
+ return false;
122
+ } finally {
123
+ setPending(null);
124
+ }
125
+ }
126
+
127
+ async function sendProposal(proposal: ProposedTime) {
128
+ if (await run("propose", "Couldn't send the proposed time", () => proposeNewTime(messageUid, proposal))) {
129
+ setProposing(false);
130
+ setProposed(true);
131
+ }
132
+ }
133
+
134
+ const busy = pending !== null;
135
+ const Heading = `h${headingLevel}` as const;
136
+ const method = invite.method.toUpperCase();
137
+ const isReply = method === "REPLY";
138
+ const isCounter = method === "COUNTER";
139
+ // A `COUNTER` a guest sent to change the event (not just its time): whether the organizer's server applied it already.
140
+ const changeRequest = isCounter ? invite.changeRequest : undefined;
141
+ const permissions = guestPermissionsOf(invite.guestPermissions ?? {});
142
+ const hiddenList = !invite.isOrganizer && !permissions.guestsCanSeeGuestList;
143
+ const canRequest = !!invite.calendarEventUid && (!!invite.canRequestChange || !!invite.canRequestInvite);
144
+ const title = invite.summary?.trim() || "(no title)";
145
+ const when = formatInviteWhen(invite);
146
+ const attendees = hiddenList ? "The organizer has hidden the guest list" : attendeeSummary(invite.attendees);
147
+ const hasDescription = !!(invite.descriptionHtml?.trim() || invite.description?.trim());
148
+ const visibility = invite.visibility && invite.visibility !== "default" ? VISIBILITY_LABEL[invite.visibility] : undefined;
149
+ const sender = invite.reply ?? invite.attendees[0];
150
+ const senderName = sender ? sender.displayName || sender.address : "Someone";
151
+ const showResponses = invite.canRespond && !invite.isOrganizer;
152
+ const conflicts = !isReply && !isCounter && method !== "CANCEL" && invite.conflicts.length > 0 ? conflictSummary(invite.conflicts) : undefined;
153
+ const hasActions =
154
+ showResponses ||
155
+ invite.canPropose ||
156
+ invite.canAcceptProposal ||
157
+ invite.canAdd ||
158
+ invite.canRemove ||
159
+ canRequest ||
160
+ (invite.onCalendar && !!invite.calendarEventUid);
161
+
162
+ return (
163
+ <section aria-labelledby={headingId} className="rounded-sm border border-border bg-surface-alt px-3 py-3 flex flex-col gap-2 text-sm text-text">
164
+ <Heading id={headingId} className="flex items-center gap-2 text-sm font-semibold">
165
+ <HiOutlineCalendarDays size={16} aria-hidden="true" className="shrink-0" />
166
+ {changeRequest ? CHANGE_REQUEST_HEADING : (HEADINGS[method] ?? "Calendar event")}
167
+ </Heading>
168
+ {isReply && (
169
+ // Someone answering the reader's own invitation: a line of news, nothing to do.
170
+ <p className="break-words">
171
+ <span className="font-semibold">{senderName}</span> {(sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded"}.
172
+ </p>
173
+ )}
174
+ {isCounter && !changeRequest && (
175
+ <p className="break-words">
176
+ <span className="font-semibold">{senderName}</span> proposed a new time{when ? <>: {when}</> : "."}
177
+ </p>
178
+ )}
179
+ {changeRequest && (
180
+ <p className="break-words">
181
+ {changeRequest.applied ? (
182
+ <>
183
+ <span className="font-semibold">{senderName}</span>&rsquo;s change was applied.
184
+ </>
185
+ ) : (
186
+ <>
187
+ <span className="font-semibold">{senderName}</span> requested a change.
188
+ </>
189
+ )}
190
+ </p>
191
+ )}
192
+ <p className="font-medium break-words">{title}</p>
193
+ {!isReply && (
194
+ <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
195
+ {when && (!isCounter || !!changeRequest) && (
196
+ <>
197
+ <dt className="text-text-muted">When</dt>
198
+ <dd className="break-words">
199
+ {when}
200
+ {invite.recurring && <span className="text-text-muted"> (Repeats)</span>}
201
+ </dd>
202
+ </>
203
+ )}
204
+ {invite.location && (
205
+ <>
206
+ <dt className="text-text-muted">Where</dt>
207
+ <dd className="break-words">{invite.location}</dd>
208
+ </>
209
+ )}
210
+ {hasDescription && (!isCounter || !!changeRequest) && (
211
+ <>
212
+ <dt className="text-text-muted">Description</dt>
213
+ <dd className="break-words">
214
+ <EventDescriptionView html={invite.descriptionHtml} text={invite.description} />
215
+ </dd>
216
+ </>
217
+ )}
218
+ {invite.organizer && !isCounter && (
219
+ <>
220
+ <dt className="text-text-muted">Organizer</dt>
221
+ <dd className="break-words">{formatMailAddress(invite.organizer)}</dd>
222
+ </>
223
+ )}
224
+ {attendees && (!isCounter || !!changeRequest) && (
225
+ <>
226
+ <dt className="text-text-muted">Attendees</dt>
227
+ <dd className="break-words">{attendees}</dd>
228
+ </>
229
+ )}
230
+ {visibility && !isCounter && (
231
+ <>
232
+ <dt className="text-text-muted">Visibility</dt>
233
+ <dd className="break-words">{visibility}</dd>
234
+ </>
235
+ )}
236
+ </dl>
237
+ )}
238
+ {isReply && when && <p className="text-text-muted break-words">{when}</p>}
239
+ {conflicts && (
240
+ <p className="text-danger break-words">
241
+ <span className="font-medium">Conflicts with:</span> {conflicts}
242
+ </p>
243
+ )}
244
+ {invite.guestPermissions && !isReply && !isCounter && method !== "CANCEL" && (
245
+ <p className="text-xs text-text-muted break-words">{describeGuestPermissions(permissions)}</p>
246
+ )}
247
+ {invite.isOrganizer && !isReply && !isCounter && <p className="text-text-muted">You are the organizer of this meeting.</p>}
248
+ {method === "CANCEL" && <p className="font-medium text-danger">This meeting was canceled.</p>}
249
+ {invite.response && !invite.isOrganizer && !isReply && !isCounter && (
250
+ <p role="status" className={`font-medium ${ANSWER_CLASS[invite.response]}`}>
251
+ {ANSWER_TEXT[invite.response]}
252
+ </p>
253
+ )}
254
+ {isCounter && invite.response === "accepted" && !changeRequest?.applied && (
255
+ <p role="status" className="font-medium text-success">
256
+ You accepted the proposed time.
257
+ </p>
258
+ )}
259
+ {proposed && (
260
+ <p role="status" className="font-medium text-text">
261
+ Your proposed time was sent to the organizer.
262
+ </p>
263
+ )}
264
+ {requested && (
265
+ <p role="status" className="font-medium text-text">
266
+ Your change was sent to the organizer.
267
+ </p>
268
+ )}
269
+ {invite.outdated && <p className="text-xs text-text-muted">A newer version of this meeting is already on your calendar.</p>}
270
+ {hasActions && (
271
+ <div className="flex flex-wrap items-center gap-2">
272
+ {showResponses && (
273
+ <div role="group" aria-label="Respond to this meeting" aria-busy={busy || undefined} className="flex flex-wrap gap-2">
274
+ {RESPONSES.map(({ response, label, failure }) => {
275
+ const current = invite.response === response;
276
+ return (
277
+ <Button
278
+ key={response}
279
+ type="button"
280
+ variant="secondary"
281
+ className={["!w-auto", current ? "!bg-primary/10 !border-primary disabled:!opacity-100" : ""].join(" ")}
282
+ aria-pressed={current}
283
+ aria-busy={pending === response || undefined}
284
+ loading={pending === response}
285
+ disabled={busy || current}
286
+ onClick={() => void run(response, failure, () => respondToMessageInvite(messageUid, response))}
287
+ >
288
+ {current && <HiOutlineCheck size={14} aria-hidden="true" />}
289
+ {label}
290
+ </Button>
291
+ );
292
+ })}
293
+ </div>
294
+ )}
295
+ {invite.canPropose && !proposing && (
296
+ <Button type="button" variant="text" disabled={busy} onClick={() => setProposing(true)}>
297
+ Propose new time
298
+ </Button>
299
+ )}
300
+ {canRequest && !requesting && (
301
+ <Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={() => setRequesting(true)}>
302
+ {invite.canRequestChange ? "Request a change" : "Add guests"}
303
+ </Button>
304
+ )}
305
+ {invite.canAcceptProposal && invite.response !== "accepted" && (
306
+ <Button
307
+ type="button"
308
+ variant="secondary"
309
+ className="!w-auto"
310
+ aria-busy={pending === "acceptProposal" || undefined}
311
+ loading={pending === "acceptProposal"}
312
+ disabled={busy}
313
+ onClick={() => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid))}
314
+ >
315
+ Accept proposal
316
+ </Button>
317
+ )}
318
+ {invite.canAdd && (
319
+ <Button
320
+ type="button"
321
+ variant="secondary"
322
+ className="!w-auto"
323
+ aria-busy={pending === "add" || undefined}
324
+ loading={pending === "add"}
325
+ disabled={busy}
326
+ // A published event has no organizer to answer, so adding it is accepting it: the server sends no reply.
327
+ onClick={() => void run("add", "Couldn't add this event to your calendar", () => respondToMessageInvite(messageUid, "accepted"))}
328
+ >
329
+ Add to calendar
330
+ </Button>
331
+ )}
332
+ {invite.canRemove && (
333
+ <Button
334
+ type="button"
335
+ variant="secondary"
336
+ className="!w-auto"
337
+ aria-busy={pending === "remove" || undefined}
338
+ loading={pending === "remove"}
339
+ disabled={busy}
340
+ onClick={() => void run("remove", "Couldn't remove this meeting from your calendar", () => removeMessageInvite(messageUid))}
341
+ >
342
+ Remove from calendar
343
+ </Button>
344
+ )}
345
+ {invite.onCalendar && invite.calendarEventUid && (
346
+ <a href={calendarHref(invite)} className="text-sm font-medium text-primary-dark hover:underline">
347
+ Open in Calendar
348
+ </a>
349
+ )}
350
+ </div>
351
+ )}
352
+ {requesting && (
353
+ <div className="rounded-md bg-surface p-3">
354
+ <RequestChangeForm
355
+ subject={{
356
+ uid: invite.calendarEventUid as string,
357
+ title: invite.summary ?? "",
358
+ location: invite.location,
359
+ startDate: invite.startDate,
360
+ endDate: invite.endDate,
361
+ allDay: invite.allDay,
362
+ recurring: invite.recurring,
363
+ description: invite.description,
364
+ descriptionHtml: invite.descriptionHtml,
365
+ guestAddresses: invite.attendees.map((attendee) => attendee.address),
366
+ }}
367
+ canChange={!!invite.canRequestChange}
368
+ canInvite={!!invite.canRequestInvite}
369
+ onSent={() => {
370
+ setRequesting(false);
371
+ setRequested(true);
372
+ }}
373
+ onCancel={() => setRequesting(false)}
374
+ />
375
+ </div>
376
+ )}
377
+ {proposing && (
378
+ <ProposeTimeForm
379
+ invite={invite}
380
+ busy={pending === "propose"}
381
+ onSubmit={(proposal) => void sendProposal(proposal)}
382
+ onCancel={() => setProposing(false)}
383
+ />
384
+ )}
385
+ </section>
386
+ );
387
+ }
@@ -43,6 +43,9 @@ import {
43
43
  replySubject,
44
44
  } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
45
45
  import MoveToFolderDialog from "./MoveToFolderDialog.js";
46
+ import InviteCard from "./InviteCard.js";
47
+ import { isCalendarAttachment } from "./invite/inviteFormat.js";
48
+ import { useMessageInvite } from "./invite/inviteStore.js";
46
49
  import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
47
50
  import type { MessageSecurityResult, SignatureFailureReason } from "@rapidmx/react-shared/crypto/messageSecurity.js";
48
51
  import { extractAddresses, type MimeAttachment } from "@rapidmx/react-shared/crypto/mime.js";
@@ -992,6 +995,14 @@ function MessageDetailContent({
992
995
  const forwarded = message.flags.forwarded === true;
993
996
  const cardUnread = threadHeader?.unread ?? false;
994
997
  const showFooter = footer ?? !inThread;
998
+ // Only a message the server can read and that carries a calendar file is worth asking about an invitation. An encrypted message is out: the
999
+ // server holds only its ciphertext (the calendar file is inside it), so there is nothing for it to find. Drafts and Outbox hold messages
1000
+ // that are being written or sent, not received invitations.
1001
+ const mayHoldInvite = !message.encrypted && !inOutbox && message.folderUid !== draftsFolderUid && attachments.some(isCalendarAttachment);
1002
+ // The calendar file of a message whose invitation card is drawn is the card, not a chip in the attachment list (a nameless "attachment" to click):
1003
+ // it is listed again if the card could not be drawn (no invitation the server can read, or the lookup failed), so the file is never out of reach.
1004
+ const { invite: shownInvite } = useMessageInvite(message.uid, mayHoldInvite);
1005
+ const listedAttachments = shownInvite ? attachments.filter((attachment) => !isCalendarAttachment(attachment)) : attachments;
995
1006
 
996
1007
  const senderRecipient = { displayName: senderName, address: senderAddress };
997
1008
  const sendingStatus = (
@@ -1348,6 +1359,7 @@ function MessageDetailContent({
1348
1359
  </div>
1349
1360
  ))}
1350
1361
  {receiptError && <Alert>{receiptError}</Alert>}
1362
+ {mayHoldInvite && <InviteCard messageUid={message.uid} headingLevel={inThread ? 3 : 2} />}
1351
1363
  {security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (
1352
1364
  <p role="status" className="py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
1353
1365
  These attachments come from a message whose signature couldn&rsquo;t be verified. Open them only if
@@ -1370,9 +1382,9 @@ function MessageDetailContent({
1370
1382
  ))}
1371
1383
  </ul>
1372
1384
  )
1373
- : attachments.length > 0 && (
1385
+ : listedAttachments.length > 0 && (
1374
1386
  <ul className="flex flex-wrap gap-2">
1375
- {attachments.map((attachment) => (
1387
+ {listedAttachments.map((attachment) => (
1376
1388
  <li key={attachment.uid}>
1377
1389
  <a
1378
1390
  href={attachmentContentUrl(attachment.uid)}
@@ -1421,6 +1433,16 @@ function MessageDetailContent({
1421
1433
  >
1422
1434
  Reply
1423
1435
  </Button>
1436
+ <Button
1437
+ type="button"
1438
+ variant="secondary"
1439
+ className="!w-auto"
1440
+ aria-label="Reply all to this message"
1441
+ disabled={preparingCompose}
1442
+ onClick={() => void handleReplyOrForward("replyAll")}
1443
+ >
1444
+ Reply All
1445
+ </Button>
1424
1446
  <Button
1425
1447
  type="button"
1426
1448
  variant="secondary"
@@ -0,0 +1,120 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { HTMLAttributes, ReactNode, useEffect, useRef, useState } from "react";
6
+ import { HiOutlineArchiveBox, HiOutlineFolder } from "react-icons/hi2";
7
+ import { useSwipe } from "../../gestures/useSwipe.js";
8
+
9
+ export interface SwipeRowProps extends HTMLAttributes<HTMLElement> {
10
+ /** The element the row is: a list item, or the row's own `div` inside one. */
11
+ as?: "li" | "div";
12
+ /** Swipes are only followed while this is `true`: the phone layout, and not while rows are being selected. */
13
+ enabled: boolean;
14
+ /** Right to left. Resolves whether it went through; `false` brings the row back, anything else leaves it gone (its list removes it). */
15
+ onArchive: () => Promise<boolean>;
16
+ /** Left to right. The row comes back at once, and this asks where to (a prompt for the folder). */
17
+ onMove: () => void;
18
+ children: ReactNode;
19
+ }
20
+
21
+ /** How long the row takes to leave, or come back - `motion-reduce` skips it. */
22
+ const SLIDE = "transform 150ms ease-out";
23
+
24
+ /**
25
+ * A row of a list of mail that slides with a finger: right to left it takes the row away to Archive (a green panel with the
26
+ * archive icon follows the row in), left to right it comes back and asks for a folder to move it to (a blue panel with a
27
+ * folder). The panels are children of the row placed just outside its edges, so they travel with it and fill the gap it leaves
28
+ * without the row's own markup, or the list's, changing; the list has to clip them (`overflow-x-clip`).
29
+ *
30
+ * Only a swipe that starts out horizontal counts (see `useSwipe()`), so scrolling the list is untouched, and it is an addition
31
+ * to, never the only way of, archiving and moving: the selection bar and the reading pane do the same.
32
+ */
33
+ export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove, children, className, style, ...rest }: SwipeRowProps) {
34
+ const [offset, setOffset] = useState(0);
35
+ const [width, setWidth] = useState(0);
36
+ /** The row is on its way out (or waiting for the server to confirm it), so a finger can't drag it. */
37
+ const [leaving, setLeaving] = useState(false);
38
+ const [dragging, setDragging] = useState(false);
39
+ const element = useRef<HTMLElement | null>(null);
40
+ const mounted = useRef(true);
41
+ useEffect(() => {
42
+ mounted.current = true;
43
+ return () => {
44
+ mounted.current = false;
45
+ };
46
+ }, []);
47
+
48
+ const swipe = useSwipe({
49
+ enabled: enabled && !leaving,
50
+ onDrag: (next) => {
51
+ setDragging(next !== 0);
52
+ setOffset(next);
53
+ },
54
+ onSwipe: (direction) => {
55
+ if (direction === "right") {
56
+ onMove();
57
+ return;
58
+ }
59
+ // A swipe is only ever heard by the row's own element, which is what `element` holds.
60
+ const rowWidth = element.current!.clientWidth;
61
+ setWidth(rowWidth);
62
+ setLeaving(true);
63
+ setOffset(-rowWidth);
64
+ void onArchive().then((archived) => {
65
+ // Unmounted on success, once the list has dropped the row; a refusal brings it back.
66
+ if (!archived && mounted.current) {
67
+ setLeaving(false);
68
+ setOffset(0);
69
+ }
70
+ });
71
+ },
72
+ });
73
+
74
+ if (!enabled) {
75
+ return (
76
+ <Tag className={className} style={style} {...rest}>
77
+ {children}
78
+ </Tag>
79
+ );
80
+ }
81
+ const active = offset !== 0;
82
+ return (
83
+ <Tag
84
+ {...rest}
85
+ {...swipe.handlers}
86
+ ref={(node: HTMLElement | null) => {
87
+ element.current = node;
88
+ }}
89
+ className={className}
90
+ style={{
91
+ ...style,
92
+ ...swipe.style,
93
+ transform: active ? `translateX(${offset}px)` : undefined,
94
+ transition: dragging ? "none" : SLIDE,
95
+ }}
96
+ data-swiping={active ? "true" : undefined}
97
+ >
98
+ {children}
99
+ {(active || leaving) && (
100
+ <>
101
+ <span
102
+ aria-hidden="true"
103
+ data-swipe-panel="move"
104
+ className="absolute inset-y-0 right-full w-full bg-primary text-white flex items-center justify-end pr-6"
105
+ >
106
+ <HiOutlineFolder size={24} />
107
+ </span>
108
+ <span
109
+ aria-hidden="true"
110
+ data-swipe-panel="archive"
111
+ style={width ? { width } : undefined}
112
+ className="absolute inset-y-0 left-full w-full bg-success text-white flex items-center justify-start pl-6"
113
+ >
114
+ <HiOutlineArchiveBox size={24} />
115
+ </span>
116
+ </>
117
+ )}
118
+ </Tag>
119
+ );
120
+ }
@@ -1342,10 +1342,12 @@ export default function ComposeWindow({
1342
1342
  aria-labelledby={titleId}
1343
1343
  style={!isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
1344
1344
  className={[
1345
- "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1345
+ "shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1346
+ // `fixed` and `relative` may not both be on the element: Tailwind emits `.relative` after `.fixed`, so it wins and the
1347
+ // sheet sat in the flow of the little fixed container the windows are stacked in instead of covering the screen.
1346
1348
  isMobile
1347
1349
  ? "fixed inset-0 w-full h-full rounded-none border-0"
1348
- : ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1350
+ : ["relative border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1349
1351
  ].join(" ")}
1350
1352
  >
1351
1353
  {!isMobile && (
@@ -1421,7 +1423,9 @@ export default function ComposeWindow({
1421
1423
  <select
1422
1424
  id={`compose-from-${id}`}
1423
1425
  className={`${FIELD_INPUT} disabled:opacity-55`}
1424
- value={mailboxUid ?? ""}
1426
+ // Two or more options only exist once the mailboxes have loaded, and the same answer that lets them list
1427
+ // (one that isn't known to be view-only) is what picks the default sender, so a sender is always chosen by now.
1428
+ value={mailboxUid}
1425
1429
  disabled={hasUploads}
1426
1430
  title={hasUploads ? "The sender can't be changed after adding attachments or images." : undefined}
1427
1431
  onChange={(e) => handleFromChange(e.target.value)}