@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.
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 -862
  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 -455
  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 +3 -3
@@ -3,18 +3,23 @@ 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 { useEffect, useLayoutEffect, useRef, useState } from "react";
6
+ import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
7
7
  import { createPortal } from "react-dom";
8
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
8
9
  import { HiOutlineShieldCheck } from "react-icons/hi2";
9
10
  import { formatProfileName, getMyProfile, getMyUsername, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
11
+ import { listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
10
12
  import { accountUrlOf } from "../../auth/accountUrl.js";
11
13
  import { DEFAULT_TRUSTED_ROLES, lookUpAdminAccess } from "../../auth/adminAccess.js";
12
14
  import { ariaKeyShortcuts } from "../../keyboard/format.js";
13
15
  import { SHORTCUTS } from "../../keyboard/keymap.js";
14
16
  import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
15
17
  import { SETTINGS_HREF } from "../../navigation/appHrefs.js";
18
+ import { MailConnectionContext } from "../../mail/useMailConnection.js";
16
19
  import ThemeSwitch from "./ThemeSwitch.js";
17
- import { desktopPermission, getNewMailPopupsEnabled, requestDesktopPermission, setDesktopOfferDismissed, setNewMailPopupsEnabled, } from "../../mail/newMailNotifications.js";
20
+ import { desktopPermission, requestDesktopPermission, setDesktopOfferDismissed, } from "../../mail/newMailNotifications.js";
21
+ import { getNotificationsEnabled, setNotificationsEnabled } from "../../notifications/preferences.js";
22
+ import { dismissAll } from "../../notifications/store.js";
18
23
  function Avatar({ profile, initials, large }) {
19
24
  const sizeClass = large ? "w-10 h-10 text-base" : "w-8 h-8 text-sm";
20
25
  if (profile?.avatar) {
@@ -27,13 +32,17 @@ function Avatar({ profile, initials, large }) {
27
32
  * trigger that opens a dropdown showing the caller's avatar/name, an "Account" link to auth-server's account page,
28
33
  * an optional "Settings" and "Admin Console" link, and "Sign Out". This service has no local user directory (see
29
34
  * `.claude/NOTES.md`), so the name and avatar come from auth-server, and the displayed name (and the initials badge)
30
- * falls back down a chain: the profile's name (`GET /api/profiles/me`), else the caller's username - their first
31
- * verified `name` alias (`GET /api/aliases?type=name`, asked for only when the profile gave no name) - else the bare
32
- * uid. Either lookup can fail (auth-server's CORS list not including this origin, or no profile document at all - a
33
- * 404 for some accounts) and neither ever surfaces as an error. The avatar image comes from the profile alone.
35
+ * falls back down a chain: the profile's name (`GET /api/profiles/me`), else the display name of the mailbox the
36
+ * caller owns (`listMailboxes()`, or the list the app frame already holds; a shared or delegated mailbox does not count), else the caller's username - their first
37
+ * verified `name` alias (`GET /api/aliases?type=name`) - else the bare uid. The mailbox and the username are asked for
38
+ * only when the profile gave no name, and the username only when the mailbox gave none. Every lookup can fail
39
+ * (auth-server's CORS list not including this origin, or no profile document at all - a 404 for some accounts) and none
40
+ * ever surfaces as an error. The avatar image comes from the profile alone.
34
41
  */
35
42
  export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, detectAdmin, trustedRoles = DEFAULT_TRUSTED_ROLES, showSettingsLink, showNotificationSettings, onShowShortcuts, placement = "down", onShowNotifications, unseenErrors = 0, }) {
36
43
  const env = useKeyEnvironment();
44
+ // A phone has no keyboard to speak of, so the shortcuts list (and the dialog it opens) has nothing to offer there.
45
+ const isMobile = useIsMobile();
37
46
  const [open, setOpen] = useState(false);
38
47
  // Where the menu sits, in window coordinates: it is drawn through a portal into `<body>` with `position: fixed`, so no ancestor's
39
48
  // `overflow`, `z-index` or stacking context - a custom branding header's, say - can clip it or put it behind the page.
@@ -43,7 +52,14 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
43
52
  const [popups, setPopups] = useState(true);
44
53
  const [permission, setPermission] = useState("unsupported");
45
54
  const [profile, setProfile] = useState(undefined);
55
+ // Whether the profile lookup has answered, with a profile or without: the fallback names are only worth asking for after that.
56
+ const [profileSettled, setProfileSettled] = useState(false);
46
57
  const [username, setUsername] = useState(undefined);
58
+ // The mailboxes this menu listed itself; `null` until it has, or when it has no need to.
59
+ const [ownListing, setOwnListing] = useState(null);
60
+ // Inside the app frame the mailboxes are already listed for the whole session (see `useMailConnection()`), so they are reused, not listed again.
61
+ const connection = useContext(MailConnectionContext);
62
+ const framed = connection !== null;
47
63
  const containerRef = useRef(null);
48
64
  useEffect(() => {
49
65
  if (!authServerUrl) {
@@ -56,24 +72,50 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
56
72
  loaded = await getMyProfile(authServerUrl);
57
73
  }
58
74
  catch {
59
- // Unreachable, blocked, or no profile document (404) - fall through to the username.
75
+ // Unreachable, blocked, or no profile document (404) - fall through to the fallback names.
60
76
  }
61
- if (cancelled) {
62
- return;
63
- }
64
- setProfile(loaded);
65
- // The username is only the fallback for a missing name - no second request when the profile has one.
66
- if (!formatProfileName(loaded)) {
67
- const alias = await getMyUsername(authServerUrl);
68
- if (!cancelled) {
69
- setUsername(alias);
70
- }
77
+ if (!cancelled) {
78
+ setProfile(loaded);
79
+ setProfileSettled(true);
71
80
  }
72
81
  })();
73
82
  return () => {
74
83
  cancelled = true;
75
84
  };
76
85
  }, [authServerUrl]);
86
+ // The mailbox's display name and then the username are only fallbacks for a missing name - nothing more is asked when the profile has
87
+ // one, and no username is asked for when the mailbox has a name. Without `authServerUrl` the profile never settles, so nothing is.
88
+ const needsFallbackName = profileSettled && !formatProfileName(profile);
89
+ useEffect(() => {
90
+ if (!needsFallbackName || framed) {
91
+ return;
92
+ }
93
+ let cancelled = false;
94
+ // A failure (no mail server, no access) is just "no mailbox name" - the username is asked for next.
95
+ listMailboxes({ limit: 50 }).then((list) => !cancelled && setOwnListing(Array.isArray(list) ? list : []), () => !cancelled && setOwnListing([]));
96
+ return () => {
97
+ cancelled = true;
98
+ };
99
+ }, [needsFallbackName, framed]);
100
+ // `null` while the mailboxes are still on their way.
101
+ const listed = framed ? (connection.status === "checking" ? null : connection.mailboxes) : ownListing;
102
+ const mailboxesKnown = listed !== null;
103
+ // Only a mailbox the caller owns counts: a shared or delegated one carries somebody else's name.
104
+ const mailboxName = needsFallbackName ? listed?.find((mb) => mb.ownerUserUid === userUid)?.displayName?.trim() || undefined : undefined;
105
+ useEffect(() => {
106
+ if (!authServerUrl || !needsFallbackName || !mailboxesKnown || mailboxName) {
107
+ return;
108
+ }
109
+ let cancelled = false;
110
+ void getMyUsername(authServerUrl).then((alias) => {
111
+ if (!cancelled) {
112
+ setUsername(alias);
113
+ }
114
+ });
115
+ return () => {
116
+ cancelled = true;
117
+ };
118
+ }, [authServerUrl, needsFallbackName, mailboxesKnown, mailboxName]);
77
119
  // Keyed on the joined names, not the array: a caller's fresh `["admin"]` each render must not re-ask.
78
120
  const trustedRolesKey = JSON.stringify(trustedRoles);
79
121
  useEffect(() => {
@@ -94,13 +136,17 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
94
136
  // Read fresh each time the menu opens - another tab may have changed either since - and only on the client.
95
137
  useEffect(() => {
96
138
  if (open && showNotificationSettings) {
97
- setPopups(getNewMailPopupsEnabled());
139
+ setPopups(getNotificationsEnabled());
98
140
  setPermission(desktopPermission());
99
141
  }
100
142
  }, [open, showNotificationSettings]);
101
143
  function togglePopups() {
102
- setNewMailPopupsEnabled(!popups);
144
+ setNotificationsEnabled(!popups);
103
145
  setPopups(!popups);
146
+ if (popups) {
147
+ // Turned off: what is on screen goes too (it stays in "Recent notifications").
148
+ dismissAll();
149
+ }
104
150
  }
105
151
  async function enableDesktopNotifications() {
106
152
  // Asked from this click, as browsers require; a "no" (or "yes") answers the first pop-up's offer as well.
@@ -151,15 +197,16 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
151
197
  window.removeEventListener("scroll", place, true);
152
198
  };
153
199
  }, [open, placement]);
154
- const name = formatProfileName(profile) ?? username ?? userUid;
155
- const initials = profileInitials(profile, userUid, username);
200
+ const name = formatProfileName(profile) ?? mailboxName ?? username ?? userUid;
201
+ // The badge takes its letter from whichever fallback name is shown, so it never disagrees with the name beside it.
202
+ const initials = profileInitials(profile, userUid, mailboxName ?? username);
156
203
  const accountUrl = accountUrlOf(authServerUrl);
157
204
  return (_jsxs("div", { className: "relative", ref: containerRef, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "true", "aria-expanded": open, "aria-label": "Account menu", className: "relative flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt", children: [_jsx(Avatar, { profile: profile, initials: initials }), unseenErrors > 0 && (_jsx("span", { "data-testid": "unseen-errors-dot", "aria-hidden": "true", className: "absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface bg-danger" })), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 20 20", fill: "none", className: `text-text-muted transition-transform ${open ? "rotate-180" : ""}`, "aria-hidden": "true", children: _jsx("path", { d: "M5 7.5L10 12.5L15 7.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open &&
158
205
  anchor &&
159
- createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), _jsx(ThemeSwitch, {}), accountUrl && (_jsx("a", { role: "menuitem", href: accountUrl, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Account" })), showSettingsLink && (_jsx("a", { role: "menuitem", href: SETTINGS_HREF, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Settings" })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx("span", { children: "New mail pop-ups" }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsx("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: "Turn on desktop notifications" })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
206
+ createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), _jsx(ThemeSwitch, {}), accountUrl && (_jsx("a", { role: "menuitem", href: accountUrl, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Account" })), showSettingsLink && (_jsx("a", { role: "menuitem", href: SETTINGS_HREF, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Settings" })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx("span", { children: "Notifications" }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsx("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: "Turn on desktop notifications" })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
160
207
  setOpen(false);
161
208
  onShowNotifications();
162
- }, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx("span", { children: "Recent notifications" }), unseenErrors > 0 && (_jsxs("span", { className: "rounded-pill bg-danger px-1.5 text-xs font-bold text-white", children: [_jsx("span", { "aria-hidden": "true", children: unseenErrors }), _jsxs("span", { className: "sr-only", children: [unseenErrors, " unseen ", unseenErrors === 1 ? "error" : "errors"] })] }))] })), onShowShortcuts && (_jsx("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
209
+ }, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx("span", { children: "Recent notifications" }), unseenErrors > 0 && (_jsxs("span", { className: "rounded-pill bg-danger px-1.5 text-xs font-bold text-white", children: [_jsx("span", { "aria-hidden": "true", children: unseenErrors }), _jsxs("span", { className: "sr-only", children: [unseenErrors, " unseen ", unseenErrors === 1 ? "error" : "errors"] })] }))] })), onShowShortcuts && !isMobile && (_jsx("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
163
210
  setOpen(false);
164
211
  onShowShortcuts();
165
212
  }, className: "block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: "Keyboard shortcuts" })), (showAdminLink || detectedAdmin) && (_jsxs("a", { role: "menuitem", href: "/admin", className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineShieldCheck, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Admin Console"] })), _jsx("button", { role: "menuitem", type: "button", onClick: onSignOut, className: "block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Sign Out" })] }), document.body)] }));
@@ -17,6 +17,8 @@ export interface ConversationListProps {
17
17
  * read, which this list would otherwise keep showing as unread until the thread is collapsed and
18
18
  * expanded again. */
19
19
  messageOverrides?: Record<string, Message>;
20
+ /** Called with one of a conversation's own messages carrying the answer the reader just gave to its meeting request (from that row's RSVP chip), so the caller can keep it. An answer given from the conversation's own (parent) row is kept by this list itself. */
21
+ onMeetingResponded?: (updated: Message) => void;
20
22
  /** Turns each parent row into a checkbox row: select mode here ticks whole conversations, since a
21
23
  * conversation is what this list's rows are. A child row stays a plain "open this message" button - a
22
24
  * mixed conversation/message selection has no sensible bulk semantics (see `MailListToolbar`). */
@@ -24,6 +26,15 @@ export interface ConversationListProps {
24
26
  /** The `conversationId`s currently ticked. */
25
27
  selectedConversationIds?: Set<string>;
26
28
  onToggleSelected?: (conversation: ConversationSummary) => void;
29
+ /**
30
+ * What swiping a parent row does on a phone: right to left archives the conversation (resolving whether it went through), left
31
+ * to right asks where to move it. Absent, or `enabled: false` (a desktop), rows don't follow a finger.
32
+ */
33
+ swipe?: {
34
+ enabled: boolean;
35
+ onArchive: (conversation: ConversationSummary) => Promise<boolean>;
36
+ onMove: (conversation: ConversationSummary) => void;
37
+ };
27
38
  /**
28
39
  * Whether a conversation's own messages read newest first, matching the order sense the rows themselves
29
40
  * are in ("Newest on top"). `listConversationMessages()` always answers oldest first - the order a
@@ -44,4 +55,4 @@ export interface ConversationListProps {
44
55
  * nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
45
56
  * its own name.
46
57
  */
47
- export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, selectMode, selectedConversationIds, onToggleSelected, newestFirst, }: ConversationListProps): React.JSX.Element;
58
+ export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, swipe, newestFirst, }: ConversationListProps): React.JSX.Element;
@@ -11,6 +11,8 @@ import MailAddress from "./MailAddress.js";
11
11
  import { EncryptedPreview, conversationLooksEncrypted } from "./reading/EncryptedPreview.js";
12
12
  import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
13
13
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "./unreadStyle.js";
14
+ import SwipeRow from "./SwipeRow.js";
15
+ import InviteRowChip from "./invite/InviteRowChip.js";
14
16
  /** Every participant as `Name <address>`, for the tooltip and the accessible text of the "+N" a crowded row folds them into. */
15
17
  function participantList(conversation) {
16
18
  return conversation.participants.map((participant) => formatMailAddress(participant)).join(", ");
@@ -28,11 +30,16 @@ function participantList(conversation) {
28
30
  * nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
29
31
  * its own name.
30
32
  */
31
- export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, selectMode, selectedConversationIds, onToggleSelected, newestFirst, }) {
33
+ export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, swipe, newestFirst, }) {
32
34
  const [expanded, setExpanded] = useState(new Set());
33
35
  const [messagesById, setMessagesById] = useState({});
34
36
  const [loadingIds, setLoadingIds] = useState(new Set());
35
37
  const [errorsById, setErrorsById] = useState({});
38
+ // The answers given from a row's RSVP chip, by message uid: a row draws them at once, whichever row (the conversation's, or one of its messages) they were given from.
39
+ const [answers, setAnswers] = useState({});
40
+ function answered(updated) {
41
+ setAnswers((prev) => ({ ...prev, [updated.uid]: updated.meetingResponse }));
42
+ }
36
43
  function toggle(conversation) {
37
44
  const id = conversation.conversationId;
38
45
  setExpanded((prev) => {
@@ -69,7 +76,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
69
76
  if (conversations.length === 0) {
70
77
  return _jsx("p", { className: "p-4 text-sm text-text-muted", children: "No conversations in this folder." });
71
78
  }
72
- return (_jsx("ul", { children: conversations.map((conversation) => {
79
+ return (_jsx("ul", { className: swipe?.enabled ? "overflow-x-clip" : undefined, children: conversations.map((conversation) => {
73
80
  const id = conversation.conversationId;
74
81
  const isExpanded = expanded.has(id);
75
82
  const unread = conversation.unreadCount > 0;
@@ -79,10 +86,20 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
79
86
  const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
80
87
  const panelId = `conversation-messages-${id}`;
81
88
  const ticked = selectedConversationIds?.has(id) ?? false;
82
- return (_jsxs("li", { children: [_jsxs("div", { "data-message-uid": conversation.latestMessageUid, "data-unread": unread ? "true" : undefined, className: rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: unread }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: ["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" "), children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["flex-1 min-w-0 text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: unread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: ["flex-1", senderClass(unread)].join(" ") }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: ["text-xs shrink-0", dateClass(unread)].join(" "), children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: ["text-sm truncate", subjectClass(unread)].join(" "), children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversationLooksEncrypted(conversation) ? _jsx(EncryptedPreview, {}) : conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] })] }), _jsxs("ul", { id: panelId, hidden: !isExpanded, children: [errorsById[id] && (_jsx("li", { role: "alert", className: "pl-8 pr-4 py-2 text-xs text-danger border-b border-border", children: errorsById[id] })), loadingIds.has(id) && (_jsx("li", { className: "pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border", children: "Loading messages\u2026" })), children?.map((fetched) => {
89
+ return (_jsxs("li", { children: [_jsxs(SwipeRow, { as: "div", enabled: !!swipe?.enabled && !selectMode, onArchive: () => swipe.onArchive(conversation), onMove: () => swipe.onMove(conversation), "data-message-uid": conversation.latestMessageUid, "data-unread": unread ? "true" : undefined, className: rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: unread }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: ["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" "), children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: unread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: ["flex-1", senderClass(unread)].join(" ") }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: ["text-xs shrink-0", dateClass(unread)].join(" "), children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: ["text-sm truncate", subjectClass(unread)].join(" "), children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversationLooksEncrypted(conversation) ? _jsx(EncryptedPreview, {}) : conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: {
90
+ uid: conversation.latestMessageUid,
91
+ meetingMethod: conversation.latestMeetingMethod ?? undefined,
92
+ meetingResponse: answers[conversation.latestMessageUid] ??
93
+ messageOverrides?.[conversation.latestMessageUid]?.meetingResponse ??
94
+ conversation.latestMeetingResponse ??
95
+ undefined,
96
+ }, onResponded: answered, className: "mr-4" })] })] }), _jsxs("ul", { id: panelId, hidden: !isExpanded, children: [errorsById[id] && (_jsx("li", { role: "alert", className: "pl-8 pr-4 py-2 text-xs text-danger border-b border-border", children: errorsById[id] })), loadingIds.has(id) && (_jsx("li", { className: "pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border", children: "Loading messages\u2026" })), children?.map((fetched) => {
83
97
  const message = messageOverrides?.[fetched.uid] ?? fetched;
84
98
  const messageUnread = isUnread(message);
85
- return (_jsxs("li", { "data-message-uid": message.uid, "data-unread": messageUnread ? "true" : undefined, className: rowClass({ unread: messageUnread, selected: message.uid === selectedUid }), children: [_jsx(UnreadBar, { unread: messageUnread }), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, message.uid), className: ["w-full text-left pl-8 pr-4 py-2", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: messageUnread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(messageUnread) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(messageUnread)].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview || (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] })] }, message.uid));
99
+ return (_jsxs("li", { "data-message-uid": message.uid, "data-unread": messageUnread ? "true" : undefined, className: rowClass({ unread: messageUnread, selected: message.uid === selectedUid }), children: [_jsx(UnreadBar, { unread: messageUnread }), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, message.uid), className: ["w-full text-left pl-8 pr-4 py-2", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: messageUnread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(messageUnread) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(messageUnread)].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview || (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: { ...message, meetingResponse: answers[message.uid] ?? message.meetingResponse }, onResponded: (updated) => {
100
+ answered(updated);
101
+ onMeetingResponded?.(updated);
102
+ }, className: "ml-8 mr-4" })] }, message.uid));
86
103
  })] })] }, id));
87
104
  }) }));
88
105
  }
@@ -11,8 +11,11 @@ export declare const THREAD_PAGE_SIZE = 100;
11
11
  * into an unbounded run of requests. Past it the pane says so and the rest stay readable from the list.
12
12
  */
13
13
  export declare const THREAD_MESSAGE_LIMIT = 500;
14
+ /** What the pane needs to know of a conversation up front: the thread itself is loaded by `conversationId`. The subject and
15
+ * count fill the header while it loads, so a caller that only has a message (the mobile route) gives its subject and a count of 1. */
16
+ export type ConversationThreadHead = Pick<ConversationSummary, "conversationId" | "subject" | "messageCount">;
14
17
  export interface ConversationThreadPaneProps {
15
- conversation: ConversationSummary | null;
18
+ conversation: ConversationThreadHead | null;
16
19
  /** The mailbox the conversation was listed from - `listConversationMessages()` is mailbox-scoped. */
17
20
  mailboxUid: string;
18
21
  /**
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ export { formatInviteWhen, isCalendarAttachment } from "./invite/inviteFormat.js";
3
+ export interface InviteCardProps {
4
+ /** The message whose calendar invitation to show, if it carries one. Nothing is drawn until the server says there is one. */
5
+ messageUid: string;
6
+ /** 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). */
7
+ headingLevel?: 2 | 3;
8
+ }
9
+ /**
10
+ * 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
11
+ * that answer it - Accept, Tentative, Decline (and Propose new time) for a request; "Add to calendar" for a published event; "Remove from
12
+ * calendar" for a cancellation; "Accept proposal" for an attendee's proposed new time. An attendee's answer to the reader's own invitation is
13
+ * one line ("Bob tentatively accepted."). Everything that follows an action (the meeting going onto the calendar or off it, the reply to the
14
+ * organizer) is the server's; this only asks for it and draws the invitation the server sends back.
15
+ *
16
+ * 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
17
+ * 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.
18
+ * An answer that arrives after the message changed or this was unmounted is dropped.
19
+ */
20
+ export default function InviteCard({ messageUid, headingLevel }: InviteCardProps): React.JSX.Element | null;
@@ -0,0 +1,150 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useId, useState } from "react";
7
+ import { HiOutlineCalendarDays, HiOutlineCheck } from "react-icons/hi2";
8
+ import { acceptProposal, proposeNewTime, removeMessageInvite, respondToMessageInvite, } from "@rapidmx/react-shared/calendar/inviteApi.js";
9
+ import { guestPermissionsOf } from "@rapidmx/react-shared/calendar/calendarApi.js";
10
+ import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
11
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
+ import EventDescriptionView from "../calendar/EventDescriptionView.js";
13
+ import RequestChangeForm from "../calendar/RequestChangeForm.js";
14
+ import { VISIBILITY_LABEL, describeGuestPermissions } from "../calendar/eventFormat.js";
15
+ import { notifyApiError } from "../../notifications/apiErrors.js";
16
+ import { calendarHref, conflictSummary, formatInviteWhen } from "./invite/inviteFormat.js";
17
+ import { useMessageInvite } from "./invite/inviteStore.js";
18
+ import ProposeTimeForm from "./invite/ProposeTimeForm.js";
19
+ export { formatInviteWhen, isCalendarAttachment } from "./invite/inviteFormat.js";
20
+ const HEADINGS = {
21
+ REQUEST: "Meeting invitation",
22
+ CANCEL: "Meeting canceled",
23
+ REPLY: "Meeting response",
24
+ COUNTER: "New time proposed",
25
+ };
26
+ /** The heading of a `COUNTER` that is a guest's request for a change (rather than a different time). */
27
+ const CHANGE_REQUEST_HEADING = "Change requested";
28
+ /** How an answer reads in "Bob accepted." (a REPLY) - a participant who has not answered has not answered. */
29
+ const REPLY_VERBS = {
30
+ accepted: "accepted",
31
+ tentative: "tentatively accepted",
32
+ declined: "declined",
33
+ };
34
+ /** The reader's own answer, as the line under the invitation says it. */
35
+ const ANSWER_TEXT = {
36
+ accepted: "You accepted this meeting.",
37
+ tentative: "You tentatively accepted this meeting.",
38
+ declined: "You declined this meeting. It is not on your calendar.",
39
+ };
40
+ const ANSWER_CLASS = {
41
+ accepted: "text-success",
42
+ tentative: "text-text",
43
+ declined: "text-danger",
44
+ };
45
+ const RESPONSES = [
46
+ { response: "accepted", label: "Accept", failure: "Couldn't accept this meeting" },
47
+ { response: "tentative", label: "Tentative", failure: "Couldn't respond tentatively to this meeting" },
48
+ { response: "declined", label: "Decline", failure: "Couldn't decline this meeting" },
49
+ ];
50
+ /** The most attendees that are listed by name; a longer list is only counted. */
51
+ const MAX_NAMED_ATTENDEES = 5;
52
+ function attendeeSummary(attendees) {
53
+ if (attendees.length === 0) {
54
+ return undefined;
55
+ }
56
+ const count = `${attendees.length} ${attendees.length === 1 ? "attendee" : "attendees"}`;
57
+ return attendees.length <= MAX_NAMED_ATTENDEES ? `${count}: ${attendees.map((a) => a.displayName || a.address).join(", ")}` : count;
58
+ }
59
+ /**
60
+ * 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
61
+ * that answer it - Accept, Tentative, Decline (and Propose new time) for a request; "Add to calendar" for a published event; "Remove from
62
+ * calendar" for a cancellation; "Accept proposal" for an attendee's proposed new time. An attendee's answer to the reader's own invitation is
63
+ * one line ("Bob tentatively accepted."). Everything that follows an action (the meeting going onto the calendar or off it, the reply to the
64
+ * organizer) is the server's; this only asks for it and draws the invitation the server sends back.
65
+ *
66
+ * 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
67
+ * 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.
68
+ * An answer that arrives after the message changed or this was unmounted is dropped.
69
+ */
70
+ export default function InviteCard({ messageUid, headingLevel = 2 }) {
71
+ const { invite, setInvite } = useMessageInvite(messageUid);
72
+ const [pending, setPending] = useState(null);
73
+ const [proposing, setProposing] = useState(false);
74
+ // Said once after a proposal went out: the invitation itself doesn't change.
75
+ const [proposed, setProposed] = useState(false);
76
+ // A guest's request for a change (or for guests to be added): the form is open, and once it went out.
77
+ const [requesting, setRequesting] = useState(false);
78
+ const [requested, setRequested] = useState(false);
79
+ const headingId = useId();
80
+ if (!invite) {
81
+ return null;
82
+ }
83
+ /** 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. */
84
+ async function run(action, failure, call) {
85
+ setPending(action);
86
+ try {
87
+ setInvite(await call());
88
+ return true;
89
+ }
90
+ catch (err) {
91
+ notifyApiError(err, failure);
92
+ return false;
93
+ }
94
+ finally {
95
+ setPending(null);
96
+ }
97
+ }
98
+ async function sendProposal(proposal) {
99
+ if (await run("propose", "Couldn't send the proposed time", () => proposeNewTime(messageUid, proposal))) {
100
+ setProposing(false);
101
+ setProposed(true);
102
+ }
103
+ }
104
+ const busy = pending !== null;
105
+ const Heading = `h${headingLevel}`;
106
+ const method = invite.method.toUpperCase();
107
+ const isReply = method === "REPLY";
108
+ const isCounter = method === "COUNTER";
109
+ // A `COUNTER` a guest sent to change the event (not just its time): whether the organizer's server applied it already.
110
+ const changeRequest = isCounter ? invite.changeRequest : undefined;
111
+ const permissions = guestPermissionsOf(invite.guestPermissions ?? {});
112
+ const hiddenList = !invite.isOrganizer && !permissions.guestsCanSeeGuestList;
113
+ const canRequest = !!invite.calendarEventUid && (!!invite.canRequestChange || !!invite.canRequestInvite);
114
+ const title = invite.summary?.trim() || "(no title)";
115
+ const when = formatInviteWhen(invite);
116
+ const attendees = hiddenList ? "The organizer has hidden the guest list" : attendeeSummary(invite.attendees);
117
+ const hasDescription = !!(invite.descriptionHtml?.trim() || invite.description?.trim());
118
+ const visibility = invite.visibility && invite.visibility !== "default" ? VISIBILITY_LABEL[invite.visibility] : undefined;
119
+ const sender = invite.reply ?? invite.attendees[0];
120
+ const senderName = sender ? sender.displayName || sender.address : "Someone";
121
+ const showResponses = invite.canRespond && !invite.isOrganizer;
122
+ const conflicts = !isReply && !isCounter && method !== "CANCEL" && invite.conflicts.length > 0 ? conflictSummary(invite.conflicts) : undefined;
123
+ const hasActions = showResponses ||
124
+ invite.canPropose ||
125
+ invite.canAcceptProposal ||
126
+ invite.canAdd ||
127
+ invite.canRemove ||
128
+ canRequest ||
129
+ (invite.onCalendar && !!invite.calendarEventUid);
130
+ return (_jsxs("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", children: [_jsxs(Heading, { id: headingId, className: "flex items-center gap-2 text-sm font-semibold", children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0" }), changeRequest ? CHANGE_REQUEST_HEADING : (HEADINGS[method] ?? "Calendar event")] }), isReply && (_jsxs("p", { className: "break-words", children: [_jsx("span", { className: "font-semibold", children: senderName }), " ", (sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded", "."] })), isCounter && !changeRequest && (_jsxs("p", { className: "break-words", children: [_jsx("span", { className: "font-semibold", children: senderName }), " proposed a new time", when ? _jsxs(_Fragment, { children: [": ", when] }) : "."] })), changeRequest && (_jsx("p", { className: "break-words", children: changeRequest.applied ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), "\u2019s change was applied."] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), " requested a change."] })) })), _jsx("p", { className: "font-medium break-words", children: title }), !isReply && (_jsxs("dl", { className: "grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1", children: [when && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "When" }), _jsxs("dd", { className: "break-words", children: [when, invite.recurring && _jsx("span", { className: "text-text-muted", children: " (Repeats)" })] })] })), invite.location && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Where" }), _jsx("dd", { className: "break-words", children: invite.location })] })), hasDescription && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { className: "break-words", children: _jsx(EventDescriptionView, { html: invite.descriptionHtml, text: invite.description }) })] })), invite.organizer && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Organizer" }), _jsx("dd", { className: "break-words", children: formatMailAddress(invite.organizer) })] })), attendees && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Attendees" }), _jsx("dd", { className: "break-words", children: attendees })] })), visibility && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Visibility" }), _jsx("dd", { className: "break-words", children: visibility })] }))] })), isReply && when && _jsx("p", { className: "text-text-muted break-words", children: when }), conflicts && (_jsxs("p", { className: "text-danger break-words", children: [_jsx("span", { className: "font-medium", children: "Conflicts with:" }), " ", conflicts] })), invite.guestPermissions && !isReply && !isCounter && method !== "CANCEL" && (_jsx("p", { className: "text-xs text-text-muted break-words", children: describeGuestPermissions(permissions) })), invite.isOrganizer && !isReply && !isCounter && _jsx("p", { className: "text-text-muted", children: "You are the organizer of this meeting." }), method === "CANCEL" && _jsx("p", { className: "font-medium text-danger", children: "This meeting was canceled." }), invite.response && !invite.isOrganizer && !isReply && !isCounter && (_jsx("p", { role: "status", className: `font-medium ${ANSWER_CLASS[invite.response]}`, children: ANSWER_TEXT[invite.response] })), isCounter && invite.response === "accepted" && !changeRequest?.applied && (_jsx("p", { role: "status", className: "font-medium text-success", children: "You accepted the proposed time." })), proposed && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your proposed time was sent to the organizer." })), requested && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your change was sent to the organizer." })), invite.outdated && _jsx("p", { className: "text-xs text-text-muted", children: "A newer version of this meeting is already on your calendar." }), hasActions && (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [showResponses && (_jsx("div", { role: "group", "aria-label": "Respond to this meeting", "aria-busy": busy || undefined, className: "flex flex-wrap gap-2", children: RESPONSES.map(({ response, label, failure }) => {
131
+ const current = invite.response === response;
132
+ return (_jsxs(Button, { type: "button", variant: "secondary", className: ["!w-auto", current ? "!bg-primary/10 !border-primary disabled:!opacity-100" : ""].join(" "), "aria-pressed": current, "aria-busy": pending === response || undefined, loading: pending === response, disabled: busy || current, onClick: () => void run(response, failure, () => respondToMessageInvite(messageUid, response)), children: [current && _jsx(HiOutlineCheck, { size: 14, "aria-hidden": "true" }), label] }, response));
133
+ }) })), invite.canPropose && !proposing && (_jsx(Button, { type: "button", variant: "text", disabled: busy, onClick: () => setProposing(true), children: "Propose new time" })), canRequest && !requesting && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => setRequesting(true), children: invite.canRequestChange ? "Request a change" : "Add guests" })), invite.canAcceptProposal && invite.response !== "accepted" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "acceptProposal" || undefined, loading: pending === "acceptProposal", disabled: busy, onClick: () => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid)), children: "Accept proposal" })), invite.canAdd && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "add" || undefined, loading: pending === "add", disabled: busy,
134
+ // A published event has no organizer to answer, so adding it is accepting it: the server sends no reply.
135
+ onClick: () => void run("add", "Couldn't add this event to your calendar", () => respondToMessageInvite(messageUid, "accepted")), children: "Add to calendar" })), invite.canRemove && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "remove" || undefined, loading: pending === "remove", disabled: busy, onClick: () => void run("remove", "Couldn't remove this meeting from your calendar", () => removeMessageInvite(messageUid)), children: "Remove from calendar" })), invite.onCalendar && invite.calendarEventUid && (_jsx("a", { href: calendarHref(invite), className: "text-sm font-medium text-primary-dark hover:underline", children: "Open in Calendar" }))] })), requesting && (_jsx("div", { className: "rounded-md bg-surface p-3", children: _jsx(RequestChangeForm, { subject: {
136
+ uid: invite.calendarEventUid,
137
+ title: invite.summary ?? "",
138
+ location: invite.location,
139
+ startDate: invite.startDate,
140
+ endDate: invite.endDate,
141
+ allDay: invite.allDay,
142
+ recurring: invite.recurring,
143
+ description: invite.description,
144
+ descriptionHtml: invite.descriptionHtml,
145
+ guestAddresses: invite.attendees.map((attendee) => attendee.address),
146
+ }, canChange: !!invite.canRequestChange, canInvite: !!invite.canRequestInvite, onSent: () => {
147
+ setRequesting(false);
148
+ setRequested(true);
149
+ }, onCancel: () => setRequesting(false) }) })), proposing && (_jsx(ProposeTimeForm, { invite: invite, busy: pending === "propose", onSubmit: (proposal) => void sendProposal(proposal), onCancel: () => setProposing(false) }))] }));
150
+ }
@@ -9,6 +9,9 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
9
  import { approveReceipt, archiveMessage, attachmentContentUrl, cancelScheduledSend, declineReceipt, getMailbox, getMessage, getMessageRawContent, moveMessage, recallMessage, setMessageLabels, } from "@rapidmx/react-shared/mail/mailApi.js";
10
10
  import { buildForwardQuote, buildReplyQuote, buildReplyRecipients, buildReplyThreading, forwardSubject, replySubject, } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
11
11
  import MoveToFolderDialog from "./MoveToFolderDialog.js";
12
+ import InviteCard from "./InviteCard.js";
13
+ import { isCalendarAttachment } from "./invite/inviteFormat.js";
14
+ import { useMessageInvite } from "./invite/inviteStore.js";
12
15
  import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
13
16
  import { extractAddresses } from "@rapidmx/react-shared/crypto/mime.js";
14
17
  import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
@@ -744,6 +747,14 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
744
747
  const forwarded = message.flags.forwarded === true;
745
748
  const cardUnread = threadHeader?.unread ?? false;
746
749
  const showFooter = footer ?? !inThread;
750
+ // 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
751
+ // server holds only its ciphertext (the calendar file is inside it), so there is nothing for it to find. Drafts and Outbox hold messages
752
+ // that are being written or sent, not received invitations.
753
+ const mayHoldInvite = !message.encrypted && !inOutbox && message.folderUid !== draftsFolderUid && attachments.some(isCalendarAttachment);
754
+ // 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):
755
+ // 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.
756
+ const { invite: shownInvite } = useMessageInvite(message.uid, mayHoldInvite);
757
+ const listedAttachments = shownInvite ? attachments.filter((attachment) => !isCalendarAttachment(attachment)) : attachments;
747
758
  const senderRecipient = { displayName: senderName, address: senderAddress };
748
759
  const sendingStatus = (_jsxs(_Fragment, { children: [sendInProgress && (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Sending\u2026" })), isSentItems &&
749
760
  (message.recallRequestedAt ? (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Recall requested" })) : (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setConfirming(true), children: "Recall this message" }))), inOutbox && !sendInProgress && message.scheduledSendTime && (_jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsxs("span", { className: "text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt", children: ["Scheduled for ", new Date(message.scheduledSendTime).toLocaleString()] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Cancel" })] })), inOutbox && !sendInProgress && !message.scheduledSendTime && draftsFolderUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Move to Drafts" }))] }));
@@ -768,8 +779,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
768
779
  .filter((l) => !!l)
769
780
  .map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), ["delivery", "read"]
770
781
  .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
771
- .map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && _jsx(Alert, { children: receiptError }), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
782
+ .map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && _jsx(Alert, { children: receiptError }), mayHoldInvite && _jsx(InviteCard, { messageUid: message.uid, headingLevel: inThread ? 3 : 2 }), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
772
783
  ? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: _jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` }) }, `${index}:${attachment.filename ?? ""}`))) }))
773
- : attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: attachments.map((attachment) => (_jsx("li", { children: _jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] }) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
784
+ : listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: _jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] }) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
774
785
  return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
775
786
  }
@@ -0,0 +1,22 @@
1
+ import React, { HTMLAttributes, ReactNode } from "react";
2
+ export interface SwipeRowProps extends HTMLAttributes<HTMLElement> {
3
+ /** The element the row is: a list item, or the row's own `div` inside one. */
4
+ as?: "li" | "div";
5
+ /** Swipes are only followed while this is `true`: the phone layout, and not while rows are being selected. */
6
+ enabled: boolean;
7
+ /** Right to left. Resolves whether it went through; `false` brings the row back, anything else leaves it gone (its list removes it). */
8
+ onArchive: () => Promise<boolean>;
9
+ /** Left to right. The row comes back at once, and this asks where to (a prompt for the folder). */
10
+ onMove: () => void;
11
+ children: ReactNode;
12
+ }
13
+ /**
14
+ * 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
15
+ * 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
16
+ * folder). The panels are children of the row placed just outside its edges, so they travel with it and fill the gap it leaves
17
+ * without the row's own markup, or the list's, changing; the list has to clip them (`overflow-x-clip`).
18
+ *
19
+ * Only a swipe that starts out horizontal counts (see `useSwipe()`), so scrolling the list is untouched, and it is an addition
20
+ * to, never the only way of, archiving and moving: the selection bar and the reading pane do the same.
21
+ */
22
+ export default function SwipeRow({ as: Tag, enabled, onArchive, onMove, children, className, style, ...rest }: SwipeRowProps): React.JSX.Element;