@rapidmx/web-client 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
  2. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
  3. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  4. package/apps/shared/components/mail/MessageDetailPane.tsx +56 -7
  5. package/apps/shared/components/mail/compose/quotedBody.ts +27 -12
  6. package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
  7. package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
  8. package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
  9. package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
  10. package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
  11. package/apps/www/calendar/index.tsx +33 -31
  12. package/apps/www/contacts/index.tsx +58 -2
  13. package/apps/www/tasks/index.tsx +42 -0
  14. package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
  15. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
  16. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  17. package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
  18. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  19. package/dist/apps/shared/components/mail/MessageDetailPane.js +40 -5
  20. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -2
  21. package/dist/apps/shared/components/mail/compose/quotedBody.js +24 -12
  22. package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
  23. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
  24. package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
  25. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
  26. package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
  27. package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
  28. package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
  29. package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
  30. package/dist/apps/www/calendar/index.js +7 -7
  31. package/dist/apps/www/contacts/index.js +49 -3
  32. package/dist/apps/www/tasks/index.js +29 -1
  33. package/dist/lib/mail/pushClient.d.ts +10 -3
  34. package/dist/lib/mail/pushClient.js +18 -4
  35. package/dist/lib/tasks/tasksApi.d.ts +1 -0
  36. package/dist/lib/tasks/tasksApi.js +3 -0
  37. package/lib/mail/pushClient.ts +18 -4
  38. package/lib/tasks/tasksApi.ts +4 -0
  39. package/package.json +1 -1
@@ -34,15 +34,16 @@ export async function olderConversationMessages(message, client) {
34
34
  }
35
35
  /**
36
36
  * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
37
- * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext.
37
+ * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
38
+ * request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
38
39
  */
39
- export async function printableOlderMessage(message) {
40
- const attachments = await listAttachments(message.folderUid, message.uid).catch(() => []);
40
+ export async function printableOlderMessage(message, client) {
41
+ const attachments = await listAttachments(message.folderUid, message.uid, client).catch(() => []);
41
42
  if (message.encrypted) {
42
43
  const unlocked = getUnlockedKeys(message.mailboxUid);
43
44
  if (unlocked) {
44
45
  const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
45
- const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid), unlocked);
46
+ const security = await evaluateMessageSecurity(await getMessageRawContent(message.uid, client), unlocked);
46
47
  const content = security.text !== undefined ? { kind: "text", text: security.text } : security.html !== undefined ? { kind: "html", html: security.html } : undefined;
47
48
  if (content) {
48
49
  return {
@@ -63,7 +64,8 @@ export async function printableOlderMessage(message) {
63
64
  return {
64
65
  subject: message.subject || "(no subject)",
65
66
  headers: printHeaders(message, formatMailAddress(message.from)),
66
- content: await fetchBodyContent(message.uid, message.version),
67
+ // Without a client this is exactly the cookie request it always was.
68
+ content: await (client ? fetchBodyContent(message.uid, message.version, undefined, client) : fetchBodyContent(message.uid, message.version)),
67
69
  attachments,
68
70
  };
69
71
  }
@@ -0,0 +1,19 @@
1
+ import { type PushEvent } from "../../../lib/mail/pushClient.js";
2
+ /** An item a push event says was created, changed or deleted: its uid, and whether it is gone. */
3
+ export interface FolderItemChange {
4
+ uid: string;
5
+ deleted: boolean;
6
+ }
7
+ /**
8
+ * The item a push event says was created, changed or deleted, when the event's `type` - the published model class name (`ContactMongo`,
9
+ * `TaskSQL`, ...) - matches `model`, or `undefined` for anything else the shared push connection delivers. Only the uid is taken: the payload
10
+ * is whatever every subscriber of the folder may see (a private calendar event's busy block), so a page fetches the item again by its uid.
11
+ */
12
+ export declare function changedItemOf(event: PushEvent, model: RegExp): FolderItemChange | undefined;
13
+ /**
14
+ * Keeps a page's folders live: while the page is mounted the tab's push connection is subscribed to `folderUids` (under the page's own
15
+ * `group` - see `PushClient.setChannels()`; Mail's connection subscribes to mail folders only, so nothing else asks for a calendar,
16
+ * contacts or tasks folder), and `onChange` is called with every item `parse` recognizes - created, changed or deleted anywhere: another
17
+ * tab, a phone over ActiveSync, the server itself. `onChange` may change between renders; the latest is called.
18
+ */
19
+ export declare function useFolderLiveUpdates(group: string, folderUids: readonly string[], parse: (event: PushEvent) => FolderItemChange | undefined, onChange: (change: FolderItemChange) => void): void;
@@ -0,0 +1,41 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useRef } from "react";
6
+ import { getPushClient } from "../../../lib/mail/pushClient.js";
7
+ /**
8
+ * The item a push event says was created, changed or deleted, when the event's `type` - the published model class name (`ContactMongo`,
9
+ * `TaskSQL`, ...) - matches `model`, or `undefined` for anything else the shared push connection delivers. Only the uid is taken: the payload
10
+ * is whatever every subscriber of the folder may see (a private calendar event's busy block), so a page fetches the item again by its uid.
11
+ */
12
+ export function changedItemOf(event, model) {
13
+ if (!model.test(event.type) || (event.action !== "create" && event.action !== "update" && event.action !== "delete")) {
14
+ return undefined;
15
+ }
16
+ const data = event.data;
17
+ return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
18
+ }
19
+ /**
20
+ * Keeps a page's folders live: while the page is mounted the tab's push connection is subscribed to `folderUids` (under the page's own
21
+ * `group` - see `PushClient.setChannels()`; Mail's connection subscribes to mail folders only, so nothing else asks for a calendar,
22
+ * contacts or tasks folder), and `onChange` is called with every item `parse` recognizes - created, changed or deleted anywhere: another
23
+ * tab, a phone over ActiveSync, the server itself. `onChange` may change between renders; the latest is called.
24
+ */
25
+ export function useFolderLiveUpdates(group, folderUids, parse, onChange) {
26
+ const key = [...new Set(folderUids)].sort().join(",");
27
+ useEffect(() => {
28
+ const client = getPushClient();
29
+ client.setChannels(key ? key.split(",") : [], group);
30
+ client.start();
31
+ return () => client.setChannels([], group);
32
+ }, [group, key]);
33
+ const latest = useRef({ parse, onChange });
34
+ latest.current = { parse, onChange };
35
+ useEffect(() => getPushClient().onEvent((event) => {
36
+ const change = latest.current.parse(event);
37
+ if (change) {
38
+ latest.current.onChange(change);
39
+ }
40
+ }), []);
41
+ }
@@ -57,9 +57,7 @@ async function openFailedDraft(event, client) {
57
57
  });
58
58
  return;
59
59
  }
60
- // loadOriginalMessage() (quotedBody.ts) does not yet accept an explicit ApiClient itself - out of scope here (that module is not one of
61
- // this task's files); it still falls back to the default global session even under an ApiClientContext.Provider.
62
- const original = await loadOriginalMessage(moved, null);
60
+ const original = await loadOriginalMessage(moved, null, {}, client);
63
61
  const attachments = await listAttachments(moved.folderUid, moved.uid, client).catch(() => []);
64
62
  const byType = (type) => moved.recipients.filter((recipient) => recipient.type === type).map(formatRecipient).join(", ");
65
63
  openComposeFromOutside({
@@ -13,7 +13,7 @@ import { ApiRequestError } from "../../../lib/util/api.js";
13
13
  import { useApiClient } from "../../../lib/util/apiClientContext.js";
14
14
  import useIsMobile from "../../../lib/util/useIsMobile.js";
15
15
  import { getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
16
- import { getPushClient } from "../../../lib/mail/pushClient.js";
16
+ import { useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
17
17
  import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
18
18
  import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
19
19
  import { createFolder } from "../../../lib/mail/mailApi.js";
@@ -49,6 +49,8 @@ import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
49
49
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
50
50
  import { bookingSettingsHref } from "../../shared/calendar/bookingPlugin.js";
51
51
  import { changedEventOf } from "../../shared/calendar/calendarLiveUpdates.js";
52
+ /** The push client channel group (`PushClient.setChannels()`) the page's calendars are subscribed under. */
53
+ const CALENDAR_CHANNEL_GROUP = "calendar";
52
54
  /** The furthest instants a `Date` can hold: the List's "range" reaches both. */
53
55
  const MAX_DATE_MS = 8.64e15;
54
56
  function CalendarPage(props) {
@@ -244,11 +246,9 @@ function CalendarContent({ userUid, bookingHref }) {
244
246
  // fetch changes nothing: the next load shows the event as it is.
245
247
  const checkedFolderUidsRef = useRef(checkedFolderUids);
246
248
  checkedFolderUidsRef.current = checkedFolderUids;
247
- useEffect(() => getPushClient().onEvent((event) => {
248
- const change = changedEventOf(event);
249
- if (!change) {
250
- return;
251
- }
249
+ // Those events are published on the calendar's own channel, so the page subscribes to every calendar it lists - checked or not, so checking
250
+ // one needs no new subscription - while it is open.
251
+ useFolderLiveUpdates(CALENDAR_CHANNEL_GROUP, calendarFolders.map((f) => f.uid), changedEventOf, (change) => {
252
252
  const { uid } = change;
253
253
  if (change.deleted) {
254
254
  setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
@@ -265,7 +265,7 @@ function CalendarContent({ userUid, bookingHref }) {
265
265
  setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
266
266
  }
267
267
  });
268
- }), [client]);
268
+ });
269
269
  // Search: `searchText` is the box, `query` what it held once typing paused (or Enter was pressed) and is what is searched for. A search covers every
270
270
  // event of the checked calendars whatever the view shows (see `searchOccurrences()`); while one runs the views show only its matches, and
271
271
  // `activeKey` is the match being pointed at (Previous/Next match step through `matches`).
@@ -7,7 +7,7 @@ import { pageTitle } from "../../shared/navigation/pageTitle.js";
7
7
  import { useNavigate } from "../../shared/navigation/index.js";
8
8
  import { useEffect, useMemo, useRef, useState } from "react";
9
9
  import { ApiRequestError } from "../../../lib/util/api.js";
10
- import { createContact, deleteContact, listContacts, ensureSuggestedContacts, listContactsInFolders, listDeletedContacts, setContactFavorite, updateContact, } from "../../../lib/contacts/contactsApi.js";
10
+ import { createContact, deleteContact, getContact, listContacts, ensureSuggestedContacts, listContactsInFolders, listDeletedContacts, setContactFavorite, updateContact, } from "../../../lib/contacts/contactsApi.js";
11
11
  import { VCARD_MAX_CHARS, VCARD_MAX_CONTACTS, contactsToVCardFile, contactToVCard, parseVCards } from "../../../lib/contacts/vcard.js";
12
12
  import { withoutDuplicates } from "../../shared/components/contacts/participantDetails.js";
13
13
  import useIsMobile from "../../../lib/util/useIsMobile.js";
@@ -33,6 +33,15 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
33
33
  import { useShortcut } from "../../shared/keyboard/useShortcut.js";
34
34
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
35
35
  import { notify } from "../../shared/notifications/store.js";
36
+ import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
37
+ /** The published model class name of a contact on either database (`ContactMongo`, `ContactSQL`) - not a `ContactList`'s. */
38
+ const CONTACT_MODEL = /^Contact(Mongo|SQL)$/;
39
+ /** The push client channel group (`PushClient.setChannels()`) the page's contacts folders are subscribed under. */
40
+ const CONTACTS_CHANNEL_GROUP = "contacts";
41
+ /** `list` with `contact` in place of the one with its uid, or added when it has none. */
42
+ function upsertContact(list, contact) {
43
+ return list.some((existing) => existing.uid === contact.uid) ? list.map((existing) => (existing.uid === contact.uid ? contact : existing)) : [...list, contact];
44
+ }
36
45
  const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
37
46
  function ContactsPage(props) {
38
47
  return (_jsx(ContactsShell, { ...props, children: _jsx(ContactsContent, { userUid: props.userUid }) }));
@@ -137,6 +146,39 @@ function ContactsContent({ userUid }) {
137
146
  cancelled = true;
138
147
  };
139
148
  }, [mailboxUid]);
149
+ // A contact created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by
150
+ // its uid and filed in the list of the folder it is in now (the mailbox's contacts, or its suggested contacts), and taken out of the other; one that is
151
+ // gone (a delete, or a 404) leaves both. A failed fetch changes nothing: the next load shows it as it is.
152
+ useFolderLiveUpdates(CONTACTS_CHANNEL_GROUP, [folderUid, suggestedFolderUid].filter((uid) => !!uid), (event) => changedItemOf(event, CONTACT_MODEL), (change) => {
153
+ const remove = (uid) => {
154
+ setContacts((previous) => previous.filter((existing) => existing.uid !== uid));
155
+ setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== uid));
156
+ };
157
+ if (change.deleted) {
158
+ remove(change.uid);
159
+ return;
160
+ }
161
+ void getContact(change.uid, client).then((fresh) => {
162
+ if (fresh.deleted === true) {
163
+ remove(fresh.uid);
164
+ }
165
+ else if (fresh.folderUid === folderUid) {
166
+ setContacts((previous) => upsertContact(previous, fresh));
167
+ setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
168
+ }
169
+ else if (fresh.folderUid === suggestedFolderUid) {
170
+ setSuggestedContacts((previous) => upsertContact(previous, fresh));
171
+ setContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
172
+ }
173
+ else {
174
+ remove(fresh.uid);
175
+ }
176
+ }, (err) => {
177
+ if (err instanceof ApiRequestError && err.status === 404) {
178
+ remove(change.uid);
179
+ }
180
+ });
181
+ });
140
182
  // restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
141
183
  // (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
142
184
  // contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
@@ -194,6 +236,9 @@ function ContactsContent({ userUid }) {
194
236
  return contacts;
195
237
  case "favorites":
196
238
  return contacts.filter((c) => c.favorite);
239
+ case "everyone":
240
+ // The mailbox's own contacts and its suggested ones in one list (a person is in only one of them: a suggestion that is accepted is moved).
241
+ return [...contacts, ...suggestedContacts];
197
242
  case "suggested":
198
243
  return suggestedContacts;
199
244
  case "list":
@@ -377,7 +422,8 @@ function ContactsContent({ userUid }) {
377
422
  });
378
423
  // A suggested contact becomes one of the mailbox's own: a copy in its contacts folder, and the suggestion removed (the person is not suggested again).
379
424
  const handleAddToContacts = singleFlight(async () => {
380
- for (const contact of checkedContacts) {
425
+ // In the All contacts view the selection can hold contacts that already are the mailbox's own: only the suggested ones are added.
426
+ for (const contact of checkedContacts.filter((checked) => checked.folderUid === suggestedFolderUid)) {
381
427
  try {
382
428
  await createContact({
383
429
  mailboxUid: contact.mailboxUid,
@@ -453,7 +499,7 @@ function ContactsContent({ userUid }) {
453
499
  return (_jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex flex-col lg:flex-row md:flex-none md:h-[calc(100dvh_-_var(--rr-header-h,4rem))] md:overflow-hidden", children: [_jsx(ContactsSidebar, { mailboxUid: mailboxUid, contacts: contacts, active: view, onSelect: handleSelectView, showDeleted: canViewDeleted, suggestedCount: suggestedFolderUid ? suggestedContacts.length : undefined }), _jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex", children: [_jsxs("div", { className: [
454
500
  "w-full md:w-[clamp(16rem,32vw,26rem)] min-w-0 md:shrink-0 md:border-r border-border md:flex flex-col min-h-0",
455
501
  mode === "new" ? "hidden md:flex" : "flex",
456
- ].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: () => setConfirmingBulkDelete(true), onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onAddToContacts: view.type === "suggested" ? handleAddToContacts : undefined, onImportFile: handleImportFile, shortcuts: true, hideNew: isMobile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), (isDeletedView ? deletedTruncated : truncated) && (_jsx("div", { className: "p-3", children: _jsxs(Alert, { children: ["This folder has more contacts than can be shown at once - only the first ", LIST_PAGE_SIZE * MAX_LIST_PAGES, " are listed."] }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 min-h-0 overflow-auto", children: _jsxs("table", { className: "w-full table-fixed text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-10 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "w-[58%] px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button",
502
+ ].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: () => setConfirmingBulkDelete(true), onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onAddToContacts: view.type === "suggested" || view.type === "everyone" ? handleAddToContacts : undefined, onImportFile: handleImportFile, shortcuts: true, hideNew: isMobile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), (isDeletedView ? deletedTruncated : truncated) && (_jsx("div", { className: "p-3", children: _jsxs(Alert, { children: ["This folder has more contacts than can be shown at once - only the first ", LIST_PAGE_SIZE * MAX_LIST_PAGES, " are listed."] }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 min-h-0 overflow-auto", children: _jsxs("table", { className: "w-full table-fixed text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-10 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "w-[58%] px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button",
457
503
  // A deleted contact has no pane to show (it can't be restored or opened), and its route would answer 404.
458
504
  disabled: isDeletedView, onClick: () => handleSelectRow(contact), className: "flex w-full min-w-0 items-center gap-2 text-left disabled:cursor-default", children: [_jsx(ContactPhotoAvatar, { lazy: true, displayName: contact.displayName, size: 28, contact: contact, email: contact.emails[0]?.address }), _jsxs("span", { className: "min-w-0 truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "max-w-0 truncate px-3 py-2 text-text-muted", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 min-h-0 flex-col md:flex", mode === "new" ? "flex" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "m-6 mb-0 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: [savedElsewhere.displayName, " was added to", " ", mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox", ".", " ", _jsx("a", { href: `/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`, "data-router-shallow": "", "data-router-prefetch": "false", className: "font-medium text-primary-dark hover:underline", children: "View that mailbox\u2019s contacts" })] })), mode === "new" && mailboxUid && folderUid ? (_jsx(ContactForm, { mailboxUid: mailboxUid, folderUid: folderUid, mailboxes: writableMailboxes, onSaved: handleSaved, onCancel: handleCancel })) : mode === "edit" && selected ? (_jsx(ContactForm, { contact: selected, onSaved: handleSaved, onCancel: handleCancel })) : selected ? (_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), canWrite: mailboxWritable, onKeysChanged: () => void reload(), onChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate }) })) : (_jsx("div", { className: "flex flex-1 items-center justify-center p-6 text-center", children: _jsx("p", { className: "text-sm text-text-muted", children: "Select a contact, or create a new one." }) }))] })] }), _jsxs(Modal, { open: confirmingBulkDelete, onClose: () => setConfirmingBulkDelete(false), title: "Delete contacts", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Delete ", checkedContacts.length, " selected ", checkedContacts.length === 1 ? "contact" : "contacts", "? They move to Deleted contacts."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingBulkDelete(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: handleBulkDelete, children: "Delete" })] })] }), isMobile && mode !== "new" && _jsx(FloatingActionButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: handleNew })] }));
459
505
  }
@@ -7,7 +7,7 @@ import { pageTitle } from "../../shared/navigation/pageTitle.js";
7
7
  import { useEffect, useMemo, useRef, useState } from "react";
8
8
  import { endOfWeek, isAfter, isBefore, isToday, startOfDay } from "date-fns";
9
9
  import { ApiRequestError } from "../../../lib/util/api.js";
10
- import { createTask, deleteTask, listTasks, setTaskCompleted, setTaskMyDay, } from "../../../lib/tasks/tasksApi.js";
10
+ import { createTask, deleteTask, getTask, listTasks, setTaskCompleted, setTaskMyDay, } from "../../../lib/tasks/tasksApi.js";
11
11
  import { listFlaggedMessages } from "../../../lib/mail/flaggedMessages.js";
12
12
  import { useApiClient } from "../../../lib/util/apiClientContext.js";
13
13
  import { useDayKey } from "../../../lib/calendar/useDayKey.js";
@@ -25,6 +25,11 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
25
25
  import { useShortcut } from "../../shared/keyboard/useShortcut.js";
26
26
  import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
27
27
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
28
+ import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
29
+ /** The published model class name of a task on either database (`TaskMongo`, `TaskSQL`) - not a `TaskList`'s. */
30
+ const TASK_MODEL = /^Task(Mongo|SQL)$/;
31
+ /** The push client channel group (`PushClient.setChannels()`) the page's tasks folder is subscribed under. */
32
+ const TASKS_CHANNEL_GROUP = "tasks";
28
33
  const INPUT_CLASS = "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
29
34
  function TasksPage(props) {
30
35
  return (_jsx(TasksShell, { ...props, children: _jsx(TasksContent, {}) }));
@@ -121,6 +126,29 @@ function TasksContent() {
121
126
  useEffect(() => {
122
127
  void reload();
123
128
  }, [folderUid]);
129
+ // A task created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by its
130
+ // uid and replaces (or joins) the list while it is still in this folder; one that is gone (a delete, a 404, or moved to another folder) leaves it. A
131
+ // failed fetch changes nothing: the next load shows it as it is.
132
+ useFolderLiveUpdates(TASKS_CHANNEL_GROUP, folderUid ? [folderUid] : [], (event) => changedItemOf(event, TASK_MODEL), (change) => {
133
+ const remove = () => setTasks((previous) => previous.filter((existing) => existing.uid !== change.uid));
134
+ if (change.deleted) {
135
+ remove();
136
+ return;
137
+ }
138
+ void getTask(change.uid, client).then((fresh) => {
139
+ if (fresh.folderUid !== folderUid) {
140
+ remove();
141
+ return;
142
+ }
143
+ setTasks((previous) => previous.some((existing) => existing.uid === fresh.uid)
144
+ ? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
145
+ : [...previous, fresh]);
146
+ }, (err) => {
147
+ if (err instanceof ApiRequestError && err.status === 404) {
148
+ remove();
149
+ }
150
+ });
151
+ });
124
152
  useEffect(() => {
125
153
  setTargetMailboxUid(undefined);
126
154
  setAddedElsewhere(null);
@@ -28,6 +28,8 @@ export type PushStatus = "idle" | "connecting" | "open" | "reconnecting" | "clos
28
28
  * caller orders its channels most important first.
29
29
  */
30
30
  export declare const PUSH_MAX_CHANNELS = 40;
31
+ /** The group `setChannels()` sets when no other is named: Mail's. */
32
+ export declare const DEFAULT_CHANNEL_GROUP = "mail";
31
33
  /** The first reconnect waits about this long, and each further failure doubles it ... */
32
34
  export declare const PUSH_BACKOFF_BASE_MS = 1000;
33
35
  /** ... up to this. */
@@ -55,6 +57,8 @@ export declare class PushClient {
55
57
  private current;
56
58
  /** What the caller wants to hear about, most important first. */
57
59
  private desired;
60
+ /** The channels each caller asked for (`setChannels()`'s `group`), merged into `desired`. */
61
+ private groups;
58
62
  /** What this socket is subscribed to (including whatever the server put it on at connect). */
59
63
  private subscribed;
60
64
  /** The channels this client itself asked for and was granted - the only ones it may unsubscribe. */
@@ -73,10 +77,13 @@ export declare class PushClient {
73
77
  */
74
78
  close(): void;
75
79
  /**
76
- * Sets the channels (folder and mailbox uids) to receive events for. Deduplicated and cut to `PUSH_MAX_CHANNELS`; on
77
- * an open socket the difference is subscribed/unsubscribed at once, otherwise it is sent when the socket opens.
80
+ * Sets the channels (folder and mailbox uids) one caller wants events for: Mail's, in the default group, or a page's
81
+ * own (the Calendar's calendars - Mail subscribes to mail folders only). Every group's channels are wanted together,
82
+ * each page's own group ahead of the default one, since a page's are what is on screen; deduplicated and cut to
83
+ * `PUSH_MAX_CHANNELS`. An empty list drops the group. On an open socket the difference is subscribed/unsubscribed at
84
+ * once, otherwise it is sent when the socket opens.
78
85
  */
79
- setChannels(channels: readonly string[]): void;
86
+ setChannels(channels: readonly string[], group?: string): void;
80
87
  /** Calls `listener` with every event; returns the function that stops it. */
81
88
  onEvent(listener: (event: PushEvent) => void): () => void;
82
89
  /** Calls `listener` whenever `status` changes; returns the function that stops it. */
@@ -37,6 +37,8 @@ const SOCKET_OPEN = 1;
37
37
  * caller orders its channels most important first.
38
38
  */
39
39
  export const PUSH_MAX_CHANNELS = 40;
40
+ /** The group `setChannels()` sets when no other is named: Mail's. */
41
+ export const DEFAULT_CHANNEL_GROUP = "mail";
40
42
  /** The first reconnect waits about this long, and each further failure doubles it ... */
41
43
  export const PUSH_BACKOFF_BASE_MS = 1000;
42
44
  /** ... up to this. */
@@ -61,6 +63,8 @@ export class PushClient {
61
63
  this.current = "idle";
62
64
  /** What the caller wants to hear about, most important first. */
63
65
  this.desired = [];
66
+ /** The channels each caller asked for (`setChannels()`'s `group`), merged into `desired`. */
67
+ this.groups = new Map();
64
68
  /** What this socket is subscribed to (including whatever the server put it on at connect). */
65
69
  this.subscribed = new Set();
66
70
  /** The channels this client itself asked for and was granted - the only ones it may unsubscribe. */
@@ -107,11 +111,21 @@ export class PushClient {
107
111
  this.setStatus("closed");
108
112
  }
109
113
  /**
110
- * Sets the channels (folder and mailbox uids) to receive events for. Deduplicated and cut to `PUSH_MAX_CHANNELS`; on
111
- * an open socket the difference is subscribed/unsubscribed at once, otherwise it is sent when the socket opens.
114
+ * Sets the channels (folder and mailbox uids) one caller wants events for: Mail's, in the default group, or a page's
115
+ * own (the Calendar's calendars - Mail subscribes to mail folders only). Every group's channels are wanted together,
116
+ * each page's own group ahead of the default one, since a page's are what is on screen; deduplicated and cut to
117
+ * `PUSH_MAX_CHANNELS`. An empty list drops the group. On an open socket the difference is subscribed/unsubscribed at
118
+ * once, otherwise it is sent when the socket opens.
112
119
  */
113
- setChannels(channels) {
114
- this.desired = [...new Set(channels)].slice(0, PUSH_MAX_CHANNELS);
120
+ setChannels(channels, group = DEFAULT_CHANNEL_GROUP) {
121
+ if (channels.length > 0) {
122
+ this.groups.set(group, [...channels]);
123
+ }
124
+ else {
125
+ this.groups.delete(group);
126
+ }
127
+ const ordered = [...this.groups.entries()].sort(([a], [b]) => Number(a === DEFAULT_CHANNEL_GROUP) - Number(b === DEFAULT_CHANNEL_GROUP));
128
+ this.desired = [...new Set(ordered.flatMap(([, list]) => list))].slice(0, PUSH_MAX_CHANNELS);
115
129
  if (this.ready) {
116
130
  this.syncSubscriptions();
117
131
  }
@@ -30,6 +30,7 @@ export interface Task {
30
30
  * own doc comment in `util/api.ts`. Omitted (the default), every function here behaves exactly as before. */
31
31
  /** Lists a folder's tasks, soonest due date first (tasks with no due date sort last). */
32
32
  export declare function listTasks(folderUid: string, params?: ListParams, client?: ApiClient): Promise<Task[]>;
33
+ export declare function getTask(uid: string, client?: ApiClient): Promise<Task>;
33
34
  export interface CreateTaskInput {
34
35
  mailboxUid: string;
35
36
  folderUid: string;
@@ -13,6 +13,9 @@ import { SETTINGS_LIST_LIMIT, buildQuery } from "../util/apiQuery.js";
13
13
  export function listTasks(folderUid, params = {}, client) {
14
14
  return withClient(client, `/mail/tasks?${buildQuery(params, { folderUid, sort: JSON.stringify({ dueDate: "ASC", uid: "ASC" }) })}`);
15
15
  }
16
+ export function getTask(uid, client) {
17
+ return withClient(client, `/mail/tasks/${encodeURIComponent(uid)}`);
18
+ }
16
19
  export function createTask(input, client) {
17
20
  return withClient(client, "/mail/tasks", {
18
21
  method: "POST",
@@ -66,6 +66,9 @@ const SOCKET_OPEN = 1;
66
66
  */
67
67
  export const PUSH_MAX_CHANNELS = 40;
68
68
 
69
+ /** The group `setChannels()` sets when no other is named: Mail's. */
70
+ export const DEFAULT_CHANNEL_GROUP = "mail";
71
+
69
72
  /** The first reconnect waits about this long, and each further failure doubles it ... */
70
73
  export const PUSH_BACKOFF_BASE_MS = 1_000;
71
74
  /** ... up to this. */
@@ -102,6 +105,8 @@ export class PushClient {
102
105
  private current: PushStatus = "idle";
103
106
  /** What the caller wants to hear about, most important first. */
104
107
  private desired: string[] = [];
108
+ /** The channels each caller asked for (`setChannels()`'s `group`), merged into `desired`. */
109
+ private groups = new Map<string, string[]>();
105
110
  /** What this socket is subscribed to (including whatever the server put it on at connect). */
106
111
  private subscribed = new Set<string>();
107
112
  /** The channels this client itself asked for and was granted - the only ones it may unsubscribe. */
@@ -152,11 +157,20 @@ export class PushClient {
152
157
  }
153
158
 
154
159
  /**
155
- * Sets the channels (folder and mailbox uids) to receive events for. Deduplicated and cut to `PUSH_MAX_CHANNELS`; on
156
- * an open socket the difference is subscribed/unsubscribed at once, otherwise it is sent when the socket opens.
160
+ * Sets the channels (folder and mailbox uids) one caller wants events for: Mail's, in the default group, or a page's
161
+ * own (the Calendar's calendars - Mail subscribes to mail folders only). Every group's channels are wanted together,
162
+ * each page's own group ahead of the default one, since a page's are what is on screen; deduplicated and cut to
163
+ * `PUSH_MAX_CHANNELS`. An empty list drops the group. On an open socket the difference is subscribed/unsubscribed at
164
+ * once, otherwise it is sent when the socket opens.
157
165
  */
158
- setChannels(channels: readonly string[]): void {
159
- this.desired = [...new Set(channels)].slice(0, PUSH_MAX_CHANNELS);
166
+ setChannels(channels: readonly string[], group = DEFAULT_CHANNEL_GROUP): void {
167
+ if (channels.length > 0) {
168
+ this.groups.set(group, [...channels]);
169
+ } else {
170
+ this.groups.delete(group);
171
+ }
172
+ const ordered = [...this.groups.entries()].sort(([a], [b]) => Number(a === DEFAULT_CHANNEL_GROUP) - Number(b === DEFAULT_CHANNEL_GROUP));
173
+ this.desired = [...new Set(ordered.flatMap(([, list]) => list))].slice(0, PUSH_MAX_CHANNELS);
160
174
  if (this.ready) {
161
175
  this.syncSubscriptions();
162
176
  }
@@ -42,6 +42,10 @@ export function listTasks(folderUid: string, params: ListParams = {}, client?: A
42
42
  return withClient(client, `/mail/tasks?${buildQuery(params, { folderUid, sort: JSON.stringify({ dueDate: "ASC", uid: "ASC" }) })}`);
43
43
  }
44
44
 
45
+ export function getTask(uid: string, client?: ApiClient): Promise<Task> {
46
+ return withClient(client, `/mail/tasks/${encodeURIComponent(uid)}`);
47
+ }
48
+
45
49
  export interface CreateTaskInput {
46
50
  mailboxUid: string;
47
51
  folderUid: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",