@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.
- package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +6 -3
- 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/www/calendar/index.tsx +33 -31
- 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/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/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/www/calendar/index.js +7 -7
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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