@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.
- package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
- package/apps/shared/calendar/calendarReminders.ts +16 -7
- package/apps/shared/calendar/useCalendarReminders.ts +66 -61
- package/apps/shared/components/calendar/CalendarAlarmDialog.tsx +75 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +6 -3
- package/apps/shared/components/layout/AppShell.tsx +4 -2
- package/apps/shared/components/layout/UserMenu.tsx +29 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +56 -7
- package/apps/shared/components/mail/compose/quotedBody.ts +27 -12
- package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
- package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
- package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
- package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
- package/apps/shared/notifications/preferences.ts +27 -0
- package/apps/shared/notifications/sounds.ts +135 -0
- package/apps/shared/notifications/store.ts +9 -0
- package/apps/www/calendar/index.tsx +64 -32
- package/apps/www/contacts/index.tsx +58 -2
- package/apps/www/tasks/index.tsx +42 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
- package/dist/apps/shared/calendar/calendarReminders.d.ts +6 -4
- package/dist/apps/shared/calendar/calendarReminders.js +14 -6
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +24 -14
- package/dist/apps/shared/calendar/useCalendarReminders.js +49 -56
- package/dist/apps/shared/components/calendar/CalendarAlarmDialog.d.ts +13 -0
- package/dist/apps/shared/components/calendar/CalendarAlarmDialog.js +28 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
- package/dist/apps/shared/components/layout/AppShell.js +4 -3
- package/dist/apps/shared/components/layout/UserMenu.js +9 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.js +40 -5
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -2
- package/dist/apps/shared/components/mail/compose/quotedBody.js +24 -12
- package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
- package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
- package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
- package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
- package/dist/apps/shared/notifications/preferences.d.ts +8 -0
- package/dist/apps/shared/notifications/preferences.js +27 -0
- package/dist/apps/shared/notifications/sounds.d.ts +15 -0
- package/dist/apps/shared/notifications/sounds.js +101 -0
- package/dist/apps/shared/notifications/store.js +11 -0
- package/dist/apps/www/calendar/index.js +37 -8
- package/dist/apps/www/contacts/index.js +49 -3
- package/dist/apps/www/tasks/index.js +29 -1
- package/dist/lib/mail/pushClient.d.ts +10 -3
- package/dist/lib/mail/pushClient.js +18 -4
- package/dist/lib/tasks/tasksApi.d.ts +1 -0
- package/dist/lib/tasks/tasksApi.js +3 -0
- package/lib/mail/pushClient.ts +18 -4
- package/lib/tasks/tasksApi.ts +4 -0
- 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 {
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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)
|
|
77
|
-
*
|
|
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)
|
|
111
|
-
*
|
|
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
|
-
|
|
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",
|
package/lib/mail/pushClient.ts
CHANGED
|
@@ -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)
|
|
156
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/lib/tasks/tasksApi.ts
CHANGED
|
@@ -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