@rapidmx/web-client 0.33.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 (60) hide show
  1. package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
  2. package/apps/shared/calendar/calendarReminders.ts +16 -7
  3. package/apps/shared/calendar/useCalendarReminders.ts +66 -61
  4. package/apps/shared/components/calendar/CalendarAlarmDialog.tsx +75 -0
  5. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
  6. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  7. package/apps/shared/components/layout/AppShell.tsx +4 -2
  8. package/apps/shared/components/layout/UserMenu.tsx +29 -1
  9. package/apps/shared/components/mail/MessageDetailPane.tsx +56 -7
  10. package/apps/shared/components/mail/compose/quotedBody.ts +27 -12
  11. package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
  12. package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
  13. package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
  14. package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
  15. package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
  16. package/apps/shared/notifications/preferences.ts +27 -0
  17. package/apps/shared/notifications/sounds.ts +135 -0
  18. package/apps/shared/notifications/store.ts +9 -0
  19. package/apps/www/calendar/index.tsx +64 -32
  20. package/apps/www/contacts/index.tsx +58 -2
  21. package/apps/www/tasks/index.tsx +42 -0
  22. package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
  23. package/dist/apps/shared/calendar/calendarReminders.d.ts +6 -4
  24. package/dist/apps/shared/calendar/calendarReminders.js +14 -6
  25. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +24 -14
  26. package/dist/apps/shared/calendar/useCalendarReminders.js +49 -56
  27. package/dist/apps/shared/components/calendar/CalendarAlarmDialog.d.ts +13 -0
  28. package/dist/apps/shared/components/calendar/CalendarAlarmDialog.js +28 -0
  29. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
  30. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  31. package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
  32. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  33. package/dist/apps/shared/components/layout/AppShell.js +4 -3
  34. package/dist/apps/shared/components/layout/UserMenu.js +9 -3
  35. package/dist/apps/shared/components/mail/MessageDetailPane.js +40 -5
  36. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -2
  37. package/dist/apps/shared/components/mail/compose/quotedBody.js +24 -12
  38. package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
  39. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
  40. package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
  41. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
  42. package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
  43. package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
  44. package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
  45. package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
  46. package/dist/apps/shared/notifications/preferences.d.ts +8 -0
  47. package/dist/apps/shared/notifications/preferences.js +27 -0
  48. package/dist/apps/shared/notifications/sounds.d.ts +15 -0
  49. package/dist/apps/shared/notifications/sounds.js +101 -0
  50. package/dist/apps/shared/notifications/store.js +11 -0
  51. package/dist/apps/www/calendar/index.js +37 -8
  52. package/dist/apps/www/contacts/index.js +49 -3
  53. package/dist/apps/www/tasks/index.js +29 -1
  54. package/dist/lib/mail/pushClient.d.ts +10 -3
  55. package/dist/lib/mail/pushClient.js +18 -4
  56. package/dist/lib/tasks/tasksApi.d.ts +1 -0
  57. package/dist/lib/tasks/tasksApi.js +3 -0
  58. package/lib/mail/pushClient.ts +18 -4
  59. package/lib/tasks/tasksApi.ts +4 -0
  60. package/package.json +1 -1
@@ -0,0 +1,101 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { getNotificationSoundsEnabled } from "./preferences.js";
6
+ /** The shortest time between two plays of the same sound. */
7
+ export const MIN_GAP_MS = 750;
8
+ /** A soft two-note rising chime. */
9
+ const CHIME = [
10
+ { frequency: 1046.5, start: 0, duration: 0.5, type: "sine", gain: 0.16 },
11
+ { frequency: 1568, start: 0.14, duration: 0.8, type: "sine", gain: 0.14 },
12
+ ];
13
+ /** A struck bell, twice: a fundamental with the inharmonic partials of a bell, each dying away slowly. */
14
+ const BELL = [0, 0.75].flatMap((start) => [
15
+ { ratio: 1, gain: 0.16, duration: 1.4 },
16
+ { ratio: 2.76, gain: 0.08, duration: 1.0 },
17
+ { ratio: 5.4, gain: 0.04, duration: 0.7 },
18
+ { ratio: 8.93, gain: 0.02, duration: 0.4 },
19
+ ].map((partial) => ({ frequency: 880 * partial.ratio, start, duration: partial.duration, type: "sine", gain: partial.gain })));
20
+ /** Two short falling buzzes - nothing like the other two. */
21
+ const ERROR = [
22
+ { frequency: 300, endFrequency: 190, start: 0, duration: 0.2, type: "sawtooth", gain: 0.09 },
23
+ { frequency: 300, endFrequency: 190, start: 0.26, duration: 0.2, type: "sawtooth", gain: 0.09 },
24
+ ];
25
+ const SOUNDS = { mail: CHIME, calendar: BELL, error: ERROR };
26
+ let context;
27
+ const lastPlayed = {};
28
+ function audioContext() {
29
+ if (context) {
30
+ return context;
31
+ }
32
+ const Ctor = typeof window === "undefined" ? undefined : (window.AudioContext ?? window.webkitAudioContext);
33
+ if (!Ctor) {
34
+ return undefined;
35
+ }
36
+ try {
37
+ context = new Ctor();
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ return context;
43
+ }
44
+ function playTone(audio, tone) {
45
+ const begin = audio.currentTime + tone.start;
46
+ const end = begin + tone.duration;
47
+ const oscillator = audio.createOscillator();
48
+ const volume = audio.createGain();
49
+ oscillator.type = tone.type;
50
+ oscillator.frequency.setValueAtTime(tone.frequency, begin);
51
+ if (tone.endFrequency !== undefined) {
52
+ oscillator.frequency.exponentialRampToValueAtTime(tone.endFrequency, end);
53
+ }
54
+ // A short attack so it doesn't click, then an exponential die-away.
55
+ volume.gain.setValueAtTime(0.0001, begin);
56
+ volume.gain.exponentialRampToValueAtTime(tone.gain, begin + 0.01);
57
+ volume.gain.exponentialRampToValueAtTime(0.0001, end);
58
+ oscillator.connect(volume);
59
+ volume.connect(audio.destination);
60
+ oscillator.start(begin);
61
+ oscillator.stop(end + 0.05);
62
+ }
63
+ /** Plays `sound`, unless the person turned notification sounds off, the same sound just played, or the browser won't make sound yet. Never throws. */
64
+ export function playNotificationSound(sound, now = Date.now()) {
65
+ if (!getNotificationSoundsEnabled()) {
66
+ return;
67
+ }
68
+ const last = lastPlayed[sound];
69
+ if (last !== undefined && now - last < MIN_GAP_MS) {
70
+ return;
71
+ }
72
+ try {
73
+ const audio = audioContext();
74
+ if (!audio) {
75
+ return;
76
+ }
77
+ lastPlayed[sound] = now;
78
+ const play = () => {
79
+ for (const tone of SOUNDS[sound]) {
80
+ playTone(audio, tone);
81
+ }
82
+ };
83
+ if (audio.state === "suspended") {
84
+ // Allowed once the page has been interacted with; until then this is refused and the sound is skipped.
85
+ void audio.resume().then(play, () => undefined);
86
+ }
87
+ else {
88
+ play();
89
+ }
90
+ }
91
+ catch {
92
+ // Audio is a nicety: a failure to make a sound never reaches the notification.
93
+ }
94
+ }
95
+ /** Forgets the audio context and what was played when - for tests. */
96
+ export function resetNotificationSounds() {
97
+ context = undefined;
98
+ for (const key of Object.keys(lastPlayed)) {
99
+ delete lastPlayed[key];
100
+ }
101
+ }
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { getNotificationsEnabled } from "./preferences.js";
6
+ import { playNotificationSound } from "./sounds.js";
6
7
  export const MAX_VISIBLE = 3;
7
8
  export const MAX_QUEUED = 20;
8
9
  export const QUEUE_STALE_MS = 30000;
@@ -240,6 +241,16 @@ export function notify(input) {
240
241
  emit();
241
242
  return id;
242
243
  }
244
+ // What is shown is heard: a chime for new mail, a bell for a calendar notice, a buzz for an error.
245
+ if (entry.kind === "mail") {
246
+ playNotificationSound("mail");
247
+ }
248
+ else if (entry.kind === "calendar") {
249
+ playNotificationSound("calendar");
250
+ }
251
+ else if (entry.kind === "error") {
252
+ playNotificationSound("error");
253
+ }
243
254
  const room = visibleCount() < MAX_VISIBLE;
244
255
  entries.push(entry);
245
256
  if (room) {
@@ -4,6 +4,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { pageTitle } from "../../shared/navigation/pageTitle.js";
7
+ import { useLocation, useNavigate } from "../../shared/navigation/index.js";
7
8
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
8
9
  import { DndContext, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
9
10
  import { HiOutlineBars3, HiOutlinePlus } from "react-icons/hi2";
@@ -12,11 +13,11 @@ import { ApiRequestError } from "../../../lib/util/api.js";
12
13
  import { useApiClient } from "../../../lib/util/apiClientContext.js";
13
14
  import useIsMobile from "../../../lib/util/useIsMobile.js";
14
15
  import { getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
15
- import { getPushClient } from "../../../lib/mail/pushClient.js";
16
+ import { useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
16
17
  import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
17
18
  import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
18
19
  import { createFolder } from "../../../lib/mail/mailApi.js";
19
- import { expandAllOccurrences } from "../../../lib/calendar/recurrence.js";
20
+ import { expandAllOccurrences, expandOccurrences } from "../../../lib/calendar/recurrence.js";
20
21
  import { useDayKey } from "../../../lib/calendar/useDayKey.js";
21
22
  import { allOccurrences, initialMatchIndex, occurrencesInRange, searchOccurrences, searchTerms, stepSequenceIndex } from "../../../lib/calendar/eventSearch.js";
22
23
  import Drawer from "../../../lib/components/overlays/Drawer.js";
@@ -48,6 +49,8 @@ import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
48
49
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
49
50
  import { bookingSettingsHref } from "../../shared/calendar/bookingPlugin.js";
50
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";
51
54
  /** The furthest instants a `Date` can hold: the List's "range" reaches both. */
52
55
  const MAX_DATE_MS = 8.64e15;
53
56
  function CalendarPage(props) {
@@ -243,11 +246,9 @@ function CalendarContent({ userUid, bookingHref }) {
243
246
  // fetch changes nothing: the next load shows the event as it is.
244
247
  const checkedFolderUidsRef = useRef(checkedFolderUids);
245
248
  checkedFolderUidsRef.current = checkedFolderUids;
246
- useEffect(() => getPushClient().onEvent((event) => {
247
- const change = changedEventOf(event);
248
- if (!change) {
249
- return;
250
- }
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) => {
251
252
  const { uid } = change;
252
253
  if (change.deleted) {
253
254
  setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
@@ -264,7 +265,7 @@ function CalendarContent({ userUid, bookingHref }) {
264
265
  setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
265
266
  }
266
267
  });
267
- }), [client]);
268
+ });
268
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
269
270
  // event of the checked calendars whatever the view shows (see `searchOccurrences()`); while one runs the views show only its matches, and
270
271
  // `activeKey` is the match being pointed at (Previous/Next match step through `matches`).
@@ -443,6 +444,34 @@ function CalendarContent({ userUid, bookingHref }) {
443
444
  function closeModal() {
444
445
  setModal(null);
445
446
  }
447
+ // `?event=<uid>&start=<iso>` opens that event's card on the day it is on - where a calendar alarm's View button leads. `start` names which occurrence
448
+ // of a recurring event. Read from the page's own location, so it also works from the Calendar itself (a shallow navigation), and then taken off the
449
+ // address so a reload doesn't open it again.
450
+ const { search: locationSearch } = useLocation();
451
+ const navigateTo = useNavigate();
452
+ const openedFromAddressRef = useRef("");
453
+ useEffect(() => {
454
+ const params = new URLSearchParams(locationSearch);
455
+ const eventUid = params.get("event");
456
+ const wanted = `${eventUid}|${params.get("start")}`;
457
+ if (!eventUid || openedFromAddressRef.current === wanted) {
458
+ return;
459
+ }
460
+ openedFromAddressRef.current = wanted;
461
+ const requestedStart = new Date(params.get("start") ?? "");
462
+ navigateTo("/calendar", { replace: true });
463
+ getCalendarEvent(eventUid, client)
464
+ .then((event) => {
465
+ const from = isNaN(requestedStart.getTime()) ? new Date(event.startDate) : requestedStart;
466
+ const candidates = expandOccurrences(event, new Date(from.getTime() - 86400000), new Date(from.getTime() + 86400000));
467
+ const match = candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ?? candidates[0];
468
+ if (match) {
469
+ setViewDate(occurrenceDay(match));
470
+ openEvent(match);
471
+ }
472
+ })
473
+ .catch((err) => notifyApiError(err, "Couldn't open the event"));
474
+ }, [locationSearch]);
446
475
  // Keyboard shortcuts - the toolbar's own actions. (A dialog open - the event editor - silences them.)
447
476
  useShortcut(SHORTCUTS.calendar.create, () => openNewEvent(), { enabled: !!mailboxUid && !!folderUid });
448
477
  useShortcut(SHORTCUTS.calendar.today, goToday);
@@ -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.33.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",