@rapidmx/web-client 0.8.0 → 0.10.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/README.md +192 -88
- package/apps/shared/auth/accountUrl.ts +9 -0
- package/apps/shared/auth/adminAccess.ts +99 -0
- package/apps/shared/components/admin/elevation.ts +61 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
- package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +87 -24
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
- package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
- package/apps/shared/components/layout/AppShell.tsx +411 -288
- package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -126
- package/apps/shared/components/layout/UserMenu.tsx +303 -150
- package/apps/shared/components/mail/ConversationList.tsx +269 -253
- package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
- package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
- package/apps/shared/components/mail/MailAddress.tsx +88 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
- package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1383
- package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1700
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
- package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
- package/apps/shared/components/mail/compose/composePerf.ts +46 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
- package/apps/shared/components/mail/layout/MailShell.tsx +476 -445
- package/apps/shared/components/mail/unreadStyle.tsx +75 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
- package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
- package/apps/shared/keyboard/dispatch.ts +124 -0
- package/apps/shared/keyboard/format.ts +89 -0
- package/apps/shared/keyboard/keymap.ts +114 -0
- package/apps/shared/keyboard/match.ts +44 -0
- package/apps/shared/keyboard/parse.ts +136 -0
- package/apps/shared/keyboard/platform.ts +34 -0
- package/apps/shared/keyboard/registry.ts +65 -0
- package/apps/shared/keyboard/targets.ts +79 -0
- package/apps/shared/keyboard/useShortcut.ts +50 -0
- package/apps/shared/keyboard/useShortcutProps.ts +17 -0
- package/apps/shared/mail/folderCounts.ts +302 -0
- package/apps/shared/mail/listSnapshots.ts +87 -0
- package/apps/shared/mail/mergeFirstPage.ts +47 -0
- package/apps/shared/mail/messageReadState.ts +85 -0
- package/apps/shared/mail/newMailNotifications.ts +183 -0
- package/apps/shared/mail/useMailConnection.ts +150 -0
- package/apps/shared/mail/useMailLiveUpdates.ts +232 -0
- package/apps/shared/mail/useMarkMessageRead.ts +47 -0
- package/apps/shared/mail/useNewMailNotifications.ts +163 -0
- package/apps/shared/mail/useUnreadTitle.ts +42 -0
- package/apps/shared/navigation/AppRouter.tsx +300 -0
- package/apps/shared/navigation/appHrefs.ts +23 -0
- package/apps/shared/navigation/frameContext.tsx +35 -0
- package/apps/shared/navigation/idle.ts +45 -0
- package/apps/shared/navigation/routerContext.tsx +83 -0
- package/apps/shared/navigation/routes.ts +72 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
- package/apps/shared/styles/app.css +28 -10
- package/apps/www/_routedPage.tsx +24 -0
- package/apps/www/_routes.ts +35 -0
- package/apps/www/calendar/index.tsx +55 -19
- package/apps/www/contacts/[uid].tsx +112 -107
- package/apps/www/contacts/index.tsx +584 -567
- package/apps/www/index.tsx +2280 -1854
- package/apps/www/messages/[uid].tsx +106 -101
- package/apps/www/settings/auto-reply/index.tsx +4 -1
- package/apps/www/settings/encryption/index.tsx +1252 -1249
- package/apps/www/settings/filters/[uid].tsx +4 -1
- package/apps/www/settings/filters/index.tsx +102 -99
- package/apps/www/settings/filters/new/index.tsx +138 -133
- package/apps/www/settings/labels/index.tsx +204 -201
- package/apps/www/settings/privacy/index.tsx +4 -1
- package/apps/www/settings/read-receipts/index.tsx +4 -1
- package/apps/www/settings/sharing/index.tsx +277 -274
- package/apps/www/settings/signatures/[uid].tsx +170 -167
- package/apps/www/settings/signatures/index.tsx +88 -85
- package/apps/www/settings/signatures/new/index.tsx +134 -129
- package/apps/www/tasks/index.tsx +18 -2
- package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
- package/dist/apps/shared/auth/accountUrl.js +8 -0
- package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
- package/dist/apps/shared/auth/adminAccess.js +89 -0
- package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
- package/dist/apps/shared/components/admin/elevation.js +56 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
- package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
- package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
- package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
- package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
- package/dist/apps/shared/components/layout/AppShell.js +73 -14
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
- package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +45 -8
- package/dist/apps/shared/components/layout/UserMenu.d.ts +28 -9
- package/dist/apps/shared/components/layout/UserMenu.js +87 -14
- package/dist/apps/shared/components/mail/ConversationList.js +9 -13
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +53 -28
- package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
- package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
- package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
- package/dist/apps/shared/components/mail/MailAddress.js +39 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +87 -36
- package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
- package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +122 -27
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
- package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
- package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +19 -4
- package/dist/apps/shared/components/mail/layout/MailShell.js +85 -85
- package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
- package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
- package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
- package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
- package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
- package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
- package/dist/apps/shared/keyboard/dispatch.js +109 -0
- package/dist/apps/shared/keyboard/format.d.ts +12 -0
- package/dist/apps/shared/keyboard/format.js +74 -0
- package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
- package/dist/apps/shared/keyboard/keymap.js +84 -0
- package/dist/apps/shared/keyboard/match.d.ts +20 -0
- package/dist/apps/shared/keyboard/match.js +31 -0
- package/dist/apps/shared/keyboard/parse.d.ts +31 -0
- package/dist/apps/shared/keyboard/parse.js +107 -0
- package/dist/apps/shared/keyboard/platform.d.ts +15 -0
- package/dist/apps/shared/keyboard/platform.js +22 -0
- package/dist/apps/shared/keyboard/registry.d.ts +39 -0
- package/dist/apps/shared/keyboard/registry.js +33 -0
- package/dist/apps/shared/keyboard/targets.d.ts +14 -0
- package/dist/apps/shared/keyboard/targets.js +67 -0
- package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
- package/dist/apps/shared/keyboard/useShortcut.js +35 -0
- package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
- package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
- package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
- package/dist/apps/shared/mail/folderCounts.js +212 -0
- package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
- package/dist/apps/shared/mail/listSnapshots.js +43 -0
- package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
- package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
- package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
- package/dist/apps/shared/mail/messageReadState.js +63 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
- package/dist/apps/shared/mail/newMailNotifications.js +138 -0
- package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
- package/dist/apps/shared/mail/useMailConnection.js +96 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +55 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.js +182 -0
- package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
- package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
- package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
- package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
- package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
- package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
- package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
- package/dist/apps/shared/navigation/AppRouter.js +242 -0
- package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
- package/dist/apps/shared/navigation/appHrefs.js +20 -0
- package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
- package/dist/apps/shared/navigation/frameContext.js +24 -0
- package/dist/apps/shared/navigation/idle.d.ts +14 -0
- package/dist/apps/shared/navigation/idle.js +43 -0
- package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
- package/dist/apps/shared/navigation/routerContext.js +56 -0
- package/dist/apps/shared/navigation/routes.d.ts +32 -0
- package/dist/apps/shared/navigation/routes.js +37 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
- package/dist/apps/shared/styles/app.css +28 -10
- package/dist/apps/www/_routedPage.d.ts +12 -0
- package/dist/apps/www/_routedPage.js +19 -0
- package/dist/apps/www/_routes.d.ts +11 -0
- package/dist/apps/www/_routes.js +29 -0
- package/dist/apps/www/calendar/index.d.ts +2 -2
- package/dist/apps/www/calendar/index.js +39 -8
- package/dist/apps/www/contacts/[uid].d.ts +3 -9
- package/dist/apps/www/contacts/[uid].js +6 -2
- package/dist/apps/www/contacts/index.d.ts +2 -2
- package/dist/apps/www/contacts/index.js +17 -4
- package/dist/apps/www/index.d.ts +2 -2
- package/dist/apps/www/index.js +361 -34
- package/dist/apps/www/messages/[uid].d.ts +3 -7
- package/dist/apps/www/messages/[uid].js +7 -4
- package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
- package/dist/apps/www/settings/auto-reply/index.js +3 -1
- package/dist/apps/www/settings/encryption/index.d.ts +2 -2
- package/dist/apps/www/settings/encryption/index.js +3 -1
- package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
- package/dist/apps/www/settings/filters/[uid].js +3 -1
- package/dist/apps/www/settings/filters/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/index.js +3 -1
- package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/new/index.js +6 -2
- package/dist/apps/www/settings/labels/index.d.ts +2 -2
- package/dist/apps/www/settings/labels/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.d.ts +2 -2
- package/dist/apps/www/settings/privacy/index.js +3 -1
- package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
- package/dist/apps/www/settings/read-receipts/index.js +3 -1
- package/dist/apps/www/settings/sharing/index.d.ts +2 -2
- package/dist/apps/www/settings/sharing/index.js +3 -1
- package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
- package/dist/apps/www/settings/signatures/[uid].js +3 -1
- package/dist/apps/www/settings/signatures/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/index.js +3 -1
- package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/new/index.js +6 -2
- package/dist/apps/www/tasks/index.d.ts +2 -2
- package/dist/apps/www/tasks/index.js +15 -3
- package/package.json +2 -2
|
@@ -1,567 +1,584 @@
|
|
|
1
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
-
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
-
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
-
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
import
|
|
22
|
-
import
|
|
23
|
-
import
|
|
24
|
-
import
|
|
25
|
-
import
|
|
26
|
-
import
|
|
27
|
-
import
|
|
28
|
-
import
|
|
29
|
-
import
|
|
30
|
-
import
|
|
31
|
-
import
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
a.
|
|
66
|
-
a.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
const [
|
|
82
|
-
|
|
83
|
-
const [
|
|
84
|
-
const [
|
|
85
|
-
const [
|
|
86
|
-
const [
|
|
87
|
-
|
|
88
|
-
const [
|
|
89
|
-
const [
|
|
90
|
-
const [
|
|
91
|
-
const [
|
|
92
|
-
|
|
93
|
-
const [
|
|
94
|
-
const [
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
setCheckedUids(new Set());
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
setSelectedUid(
|
|
260
|
-
setMode("
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function
|
|
277
|
-
setMode("view");
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async function
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
<
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
>
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { routedPage } from "../_routedPage.js";
|
|
6
|
+
import { useNavigate } from "../../shared/navigation/AppRouter.js";
|
|
7
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
8
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
+
import {
|
|
10
|
+
Contact,
|
|
11
|
+
createContact,
|
|
12
|
+
deleteContact,
|
|
13
|
+
listContacts,
|
|
14
|
+
listDeletedContacts,
|
|
15
|
+
setContactFavorite,
|
|
16
|
+
updateContact,
|
|
17
|
+
} from "@rapidmx/react-shared/contacts/contactsApi.js";
|
|
18
|
+
import { contactsToVCardFile, contactToVCard, parseVCards } from "@rapidmx/react-shared/contacts/vcard.js";
|
|
19
|
+
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
20
|
+
import { useCompose } from "../../shared/components/mail/compose/ComposeContext.js";
|
|
21
|
+
import ContactsShell, { ContactsShellProps, useContactsShell } from "../../shared/components/contacts/layout/ContactsShell.js";
|
|
22
|
+
import ContactsSidebar, { ContactsView } from "../../shared/components/contacts/ContactsSidebar.js";
|
|
23
|
+
import ContactsToolbar from "../../shared/components/contacts/ContactsToolbar.js";
|
|
24
|
+
import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
|
|
25
|
+
import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
|
|
26
|
+
import ContactForm from "../../shared/components/contacts/ContactForm.js";
|
|
27
|
+
import { useWritableMailboxes } from "../../shared/components/mail/writableMailboxes.js";
|
|
28
|
+
import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
|
|
29
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
30
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
31
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
32
|
+
import { LIST_PAGE_SIZE, MAX_LIST_PAGES, listAllPages } from "../../shared/mail/listAllPages.js";
|
|
33
|
+
import { clearPinnedSignerCache } from "../../shared/components/mail/pinnedSigners.js";
|
|
34
|
+
import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
|
|
35
|
+
import { useShortcut } from "../../shared/keyboard/useShortcut.js";
|
|
36
|
+
|
|
37
|
+
const INPUT_CLASS =
|
|
38
|
+
"w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
39
|
+
|
|
40
|
+
function ContactsPage(props: ContactsShellProps) {
|
|
41
|
+
return (
|
|
42
|
+
<ContactsShell {...props}>
|
|
43
|
+
<ContactsContent userUid={props.userUid} />
|
|
44
|
+
</ContactsShell>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Mode = "view" | "edit" | "new";
|
|
49
|
+
type SortColumn = "name" | "info";
|
|
50
|
+
|
|
51
|
+
/** Shown in the Deleted view when the server answered `?deleted=true` with live contacts (see `canViewDeleted`). */
|
|
52
|
+
const DELETED_NOT_PERMITTED_MESSAGE = "You don't have permission to view deleted contacts in this folder.";
|
|
53
|
+
|
|
54
|
+
function primaryInfo(contact: Contact): string {
|
|
55
|
+
return contact.emails[0]?.address ?? contact.phones[0]?.phoneNumber ?? "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** How long an export's object URL is kept alive after the click - some browsers are still reading the
|
|
59
|
+
* blob after `click()` returns, so revoking it synchronously can cancel the download. */
|
|
60
|
+
const VCARD_DOWNLOAD_URL_LIFETIME_MS = 60_000;
|
|
61
|
+
|
|
62
|
+
function downloadTextFile(filename: string, content: string): void {
|
|
63
|
+
const blob = new Blob([content], { type: "text/vcard" });
|
|
64
|
+
const url = URL.createObjectURL(blob);
|
|
65
|
+
const a = document.createElement("a");
|
|
66
|
+
a.href = url;
|
|
67
|
+
a.download = filename;
|
|
68
|
+
document.body.appendChild(a);
|
|
69
|
+
a.click();
|
|
70
|
+
a.remove();
|
|
71
|
+
setTimeout(() => URL.revokeObjectURL(url), VCARD_DOWNLOAD_URL_LIFETIME_MS);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ContactsContent({ userUid }: { userUid?: string }) {
|
|
75
|
+
const { folderUid, mailboxUid, mailboxes } = useContactsShell();
|
|
76
|
+
// The new-contact form's Mailbox choices - view-only shares are left out (see writableMailboxes.ts).
|
|
77
|
+
const writableMailboxes = useWritableMailboxes(mailboxes, userUid, mailboxUid);
|
|
78
|
+
const { openCompose } = useCompose();
|
|
79
|
+
const isMobile = useIsMobile();
|
|
80
|
+
const navigate = useNavigate();
|
|
81
|
+
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
82
|
+
const [deletedContacts, setDeletedContacts] = useState<Contact[]>([]);
|
|
83
|
+
const [deletedLoading, setDeletedLoading] = useState(false);
|
|
84
|
+
const [deletedError, setDeletedError] = useState<string | null>(null);
|
|
85
|
+
const [loading, setLoading] = useState(true);
|
|
86
|
+
const [error, setError] = useState<string | null>(null);
|
|
87
|
+
// Set when `listAllPages()` stopped at its page cap - the list shown isn't every contact.
|
|
88
|
+
const [truncated, setTruncated] = useState(false);
|
|
89
|
+
const [deletedTruncated, setDeletedTruncated] = useState(false);
|
|
90
|
+
const [query, setQuery] = useState("");
|
|
91
|
+
const [view, setView] = useState<ContactsView>({ type: "all" });
|
|
92
|
+
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
|
|
93
|
+
const [sortDesc, setSortDesc] = useState(false);
|
|
94
|
+
const [checkedUids, setCheckedUids] = useState<Set<string>>(new Set());
|
|
95
|
+
const [selectedUid, setSelectedUid] = useState<string | null>(null);
|
|
96
|
+
const [mode, setMode] = useState<Mode>("view");
|
|
97
|
+
// Set after a contact is created in a different mailbox than the one this list shows.
|
|
98
|
+
const [savedElsewhere, setSavedElsewhere] = useState<{ mailboxUid: string; displayName: string } | null>(null);
|
|
99
|
+
const [confirmingBulkDelete, setConfirmingBulkDelete] = useState(false);
|
|
100
|
+
|
|
101
|
+
/** Returns a promise so bulk actions (below) can wait for the refreshed list before re-asserting their
|
|
102
|
+
* own error message — this always clears `error` first (a legitimate reset for a fresh fetch attempt),
|
|
103
|
+
* which would otherwise silently wipe out a bulk action's own just-set failure message before the user
|
|
104
|
+
* ever saw it, since every bulk handler calls this right after its own error-setting loop. */
|
|
105
|
+
function reload(): Promise<void> {
|
|
106
|
+
if (!folderUid) {
|
|
107
|
+
setContacts([]);
|
|
108
|
+
setTruncated(false);
|
|
109
|
+
setLoading(false);
|
|
110
|
+
return Promise.resolve();
|
|
111
|
+
}
|
|
112
|
+
setLoading(true);
|
|
113
|
+
setError(null);
|
|
114
|
+
return listAllPages((page) => listContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
|
|
115
|
+
.then((result) => {
|
|
116
|
+
setContacts(result.items);
|
|
117
|
+
setTruncated(result.truncated);
|
|
118
|
+
})
|
|
119
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load contacts."))
|
|
120
|
+
.finally(() => setLoading(false));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
void reload();
|
|
125
|
+
}, [folderUid]);
|
|
126
|
+
|
|
127
|
+
// restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
|
|
128
|
+
// (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
|
|
129
|
+
// contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
|
|
130
|
+
// access to a folder, so the mailbox-level check below is only a first gate (an owner always passes; anyone else
|
|
131
|
+
// asks the server; unknown - still loading, or the check failed - hides the view), and the Deleted view's own
|
|
132
|
+
// load below detects the server ignoring the filter. `ContactsShell` only renders this with a resolved mailbox,
|
|
133
|
+
// and switching mailboxes is a full page load, so `mailboxUid` never changes under this component.
|
|
134
|
+
const ownsMailbox = mailboxes.some((mb) => mb.uid === mailboxUid && mb.ownerUserUid !== undefined && mb.ownerUserUid === userUid);
|
|
135
|
+
const [delegateCanViewDeleted, setDelegateCanViewDeleted] = useState(false);
|
|
136
|
+
// Resolving a contact's key change needs update rights on the mailbox - `undefined` while unknown.
|
|
137
|
+
const [delegateCanUpdate, setDelegateCanUpdate] = useState<boolean | undefined>(undefined);
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (ownsMailbox) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let cancelled = false;
|
|
143
|
+
getMyMailboxAccess(mailboxUid!).then(
|
|
144
|
+
(access) => {
|
|
145
|
+
if (!cancelled) {
|
|
146
|
+
setDelegateCanViewDeleted(access.canDelete && access.canUpdate);
|
|
147
|
+
setDelegateCanUpdate(access.canUpdate);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
() => undefined,
|
|
151
|
+
);
|
|
152
|
+
return () => {
|
|
153
|
+
cancelled = true;
|
|
154
|
+
};
|
|
155
|
+
}, [ownsMailbox]);
|
|
156
|
+
const canViewDeleted = ownsMailbox || delegateCanViewDeleted;
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (view.type !== "deleted" || !folderUid) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
setDeletedLoading(true);
|
|
163
|
+
setDeletedError(null);
|
|
164
|
+
listAllPages((page) => listDeletedContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
|
|
165
|
+
.then((result) => {
|
|
166
|
+
// A server that dropped the `deleted` filter (no delete+update right on this folder) answers with live
|
|
167
|
+
// contacts, which must never be listed as deleted.
|
|
168
|
+
if (result.items.some((c) => c.deleted !== true)) {
|
|
169
|
+
setDeletedContacts([]);
|
|
170
|
+
setDeletedTruncated(false);
|
|
171
|
+
setDeletedError(DELETED_NOT_PERMITTED_MESSAGE);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
setDeletedContacts(result.items);
|
|
175
|
+
setDeletedTruncated(result.truncated);
|
|
176
|
+
})
|
|
177
|
+
.catch((err) => setDeletedError(err instanceof ApiRequestError ? err.message : "Could not load deleted contacts."))
|
|
178
|
+
.finally(() => setDeletedLoading(false));
|
|
179
|
+
// Re-fetches every time the Deleted view is (re-)selected, matching this page's own `reload()`
|
|
180
|
+
// convention elsewhere rather than caching a possibly-stale list across visits.
|
|
181
|
+
}, [view, folderUid]);
|
|
182
|
+
|
|
183
|
+
const viewFiltered = useMemo(() => {
|
|
184
|
+
switch (view.type) {
|
|
185
|
+
case "all":
|
|
186
|
+
return contacts;
|
|
187
|
+
case "favorites":
|
|
188
|
+
return contacts.filter((c) => c.favorite);
|
|
189
|
+
case "list":
|
|
190
|
+
return contacts.filter((c) => c.contactListUid === view.uid);
|
|
191
|
+
case "category":
|
|
192
|
+
return contacts.filter((c) => (c.categories ?? []).includes(view.name));
|
|
193
|
+
case "deleted":
|
|
194
|
+
return deletedContacts;
|
|
195
|
+
}
|
|
196
|
+
}, [contacts, deletedContacts, view]);
|
|
197
|
+
|
|
198
|
+
const filtered = useMemo(() => {
|
|
199
|
+
const q = query.trim().toLowerCase();
|
|
200
|
+
if (!q) {
|
|
201
|
+
return viewFiltered;
|
|
202
|
+
}
|
|
203
|
+
return viewFiltered.filter(
|
|
204
|
+
(c) => c.displayName.toLowerCase().includes(q) || c.emails.some((e) => e.address.toLowerCase().includes(q)),
|
|
205
|
+
);
|
|
206
|
+
}, [viewFiltered, query]);
|
|
207
|
+
|
|
208
|
+
const sorted = useMemo(() => {
|
|
209
|
+
const arr = [...filtered];
|
|
210
|
+
arr.sort((a, b) => {
|
|
211
|
+
const cmp = (sortColumn === "name" ? a.displayName : primaryInfo(a)).localeCompare(
|
|
212
|
+
sortColumn === "name" ? b.displayName : primaryInfo(b),
|
|
213
|
+
);
|
|
214
|
+
return sortDesc ? -cmp : cmp;
|
|
215
|
+
});
|
|
216
|
+
return arr;
|
|
217
|
+
}, [filtered, sortColumn, sortDesc]);
|
|
218
|
+
|
|
219
|
+
const selected = contacts.find((c) => c.uid === selectedUid) ?? null;
|
|
220
|
+
const isDeletedView = view.type === "deleted";
|
|
221
|
+
const checkedContacts = sorted.filter((c) => checkedUids.has(c.uid));
|
|
222
|
+
const allChecked = sorted.length > 0 && sorted.every((c) => checkedUids.has(c.uid));
|
|
223
|
+
|
|
224
|
+
function handleSort(column: SortColumn) {
|
|
225
|
+
if (column === sortColumn) {
|
|
226
|
+
setSortDesc((prev) => !prev);
|
|
227
|
+
} else {
|
|
228
|
+
setSortColumn(column);
|
|
229
|
+
setSortDesc(false);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function toggleChecked(uid: string) {
|
|
234
|
+
setCheckedUids((prev) => {
|
|
235
|
+
const next = new Set(prev);
|
|
236
|
+
if (next.has(uid)) {
|
|
237
|
+
next.delete(uid);
|
|
238
|
+
} else {
|
|
239
|
+
next.add(uid);
|
|
240
|
+
}
|
|
241
|
+
return next;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function toggleCheckAll() {
|
|
246
|
+
setCheckedUids(allChecked ? new Set() : new Set(sorted.map((c) => c.uid)));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function handleSelectView(next: ContactsView) {
|
|
250
|
+
setView(next);
|
|
251
|
+
setCheckedUids(new Set());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function handleSelectRow(contact: Contact) {
|
|
255
|
+
if (isMobile) {
|
|
256
|
+
navigate(`/contacts/${encodeURIComponent(contact.uid)}`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
setSelectedUid(contact.uid);
|
|
260
|
+
setMode("view");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function handleNew() {
|
|
264
|
+
setSelectedUid(null);
|
|
265
|
+
setMode("new");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Keyboard shortcuts - the toolbar's New contact and the search box. (A dialog open silences them.)
|
|
269
|
+
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
|
270
|
+
useShortcut(SHORTCUTS.contacts.create, handleNew);
|
|
271
|
+
useShortcut(SHORTCUTS.contacts.search, () => {
|
|
272
|
+
searchInputRef.current?.focus();
|
|
273
|
+
searchInputRef.current?.select();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
function handleSaved(contact: Contact) {
|
|
277
|
+
setMode("view");
|
|
278
|
+
if (contact.mailboxUid !== mailboxUid) {
|
|
279
|
+
// Created in another mailbox (via the form's Mailbox selector) - it won't be in this list.
|
|
280
|
+
setSelectedUid(null);
|
|
281
|
+
setSavedElsewhere({ mailboxUid: contact.mailboxUid, displayName: contact.displayName });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
setSavedElsewhere(null);
|
|
285
|
+
setSelectedUid(contact.uid);
|
|
286
|
+
void reload();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function handleCancel() {
|
|
290
|
+
setMode("view");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function handleDelete(contact: Contact) {
|
|
294
|
+
try {
|
|
295
|
+
await deleteContact(contact.uid, contact.version);
|
|
296
|
+
// Trusted signer pins come from contacts - a deleted contact's keys must stop vouching for signatures.
|
|
297
|
+
clearPinnedSignerCache();
|
|
298
|
+
setSelectedUid(null);
|
|
299
|
+
setMode("view");
|
|
300
|
+
void reload();
|
|
301
|
+
} catch (err) {
|
|
302
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function handleBulkDelete() {
|
|
307
|
+
setConfirmingBulkDelete(false);
|
|
308
|
+
let bulkError: string | null = null;
|
|
309
|
+
for (const contact of checkedContacts) {
|
|
310
|
+
try {
|
|
311
|
+
await deleteContact(contact.uid, contact.version);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
bulkError = err instanceof ApiRequestError ? err.message : "Could not delete one or more contacts.";
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
clearPinnedSignerCache();
|
|
317
|
+
setCheckedUids(new Set());
|
|
318
|
+
await reload();
|
|
319
|
+
setError(bulkError);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// `ContactsToolbar`'s Edit button — the only caller — is itself `disabled` unless exactly one contact
|
|
323
|
+
// is checked, so `checkedContacts[0]` is guaranteed to exist whenever this actually runs; no length
|
|
324
|
+
// guard needed, matching this codebase's established pattern for the same class of "always non-empty
|
|
325
|
+
// by the time it's called" value guaranteed by a disabled control (see `ComposeToolbar.run()`).
|
|
326
|
+
function handleToolbarEdit() {
|
|
327
|
+
setSelectedUid(checkedContacts[0].uid);
|
|
328
|
+
setMode("edit");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function handleEmail() {
|
|
332
|
+
const addresses = checkedContacts.map((c) => c.emails[0]?.address).filter((a): a is string => Boolean(a));
|
|
333
|
+
// No mailboxUid: a fresh message defaults to the caller's own mailbox, not whichever mailbox's
|
|
334
|
+
// contacts happen to be open - the compose window's From field can still change it.
|
|
335
|
+
openCompose({ to: addresses.join(", ") });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function handleToggleFavorite() {
|
|
339
|
+
const allFavorited = checkedContacts.every((c) => c.favorite);
|
|
340
|
+
let bulkError: string | null = null;
|
|
341
|
+
for (const contact of checkedContacts) {
|
|
342
|
+
try {
|
|
343
|
+
await setContactFavorite(contact, !allFavorited);
|
|
344
|
+
} catch (err) {
|
|
345
|
+
bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
clearPinnedSignerCache();
|
|
349
|
+
await reload();
|
|
350
|
+
setError(bulkError);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function handleAddCategory() {
|
|
354
|
+
const category = window.prompt("Category name");
|
|
355
|
+
if (!category?.trim()) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
let bulkError: string | null = null;
|
|
359
|
+
for (const contact of checkedContacts) {
|
|
360
|
+
const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
|
|
361
|
+
try {
|
|
362
|
+
// Only the changed field - sending the whole fetched contact back includes server-managed
|
|
363
|
+
// fields restapi rejects, and would overwrite any concurrent edit to the other fields.
|
|
364
|
+
await updateContact({ uid: contact.uid, version: contact.version, categories });
|
|
365
|
+
} catch (err) {
|
|
366
|
+
bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
clearPinnedSignerCache();
|
|
370
|
+
await reload();
|
|
371
|
+
setError(bulkError);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function handleExportVCard() {
|
|
375
|
+
downloadTextFile(
|
|
376
|
+
checkedContacts.length === 1 ? `${checkedContacts[0].displayName}.vcf` : "contacts.vcf",
|
|
377
|
+
checkedContacts.length === 1 ? contactToVCard(checkedContacts[0]) : contactsToVCardFile(checkedContacts),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function handleImportFile(file: File) {
|
|
382
|
+
if (!mailboxUid || !folderUid) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const text = await file.text();
|
|
386
|
+
const parsed = parseVCards(text);
|
|
387
|
+
let bulkError: string | null = null;
|
|
388
|
+
for (const input of parsed) {
|
|
389
|
+
try {
|
|
390
|
+
await createContact({ mailboxUid, folderUid, ...input });
|
|
391
|
+
} catch (err) {
|
|
392
|
+
bulkError = err instanceof ApiRequestError ? err.message : "Could not import one or more contacts.";
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
clearPinnedSignerCache();
|
|
396
|
+
await reload();
|
|
397
|
+
setError(bulkError);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return (
|
|
401
|
+
<div className="flex-1 flex min-h-0">
|
|
402
|
+
<ContactsSidebar mailboxUid={mailboxUid} contacts={contacts} active={view} onSelect={handleSelectView} showDeleted={canViewDeleted} />
|
|
403
|
+
{/* Hidden on mobile while the "new contact" form (the one form-pane state mobile keeps
|
|
404
|
+
in-place — see the pane's own comment below) is showing, so the two never compete for the
|
|
405
|
+
same row's width. Desktop always shows both side by side, unchanged. */}
|
|
406
|
+
<div
|
|
407
|
+
className={[
|
|
408
|
+
"w-full md:w-96 shrink-0 md:border-r border-border md:flex flex-col min-h-0",
|
|
409
|
+
mode === "new" ? "hidden md:flex" : "flex",
|
|
410
|
+
].join(" ")}
|
|
411
|
+
>
|
|
412
|
+
<ContactsToolbar
|
|
413
|
+
selectedCount={checkedContacts.length}
|
|
414
|
+
allSelectedFavorited={checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite)}
|
|
415
|
+
onNewContact={handleNew}
|
|
416
|
+
onEdit={handleToolbarEdit}
|
|
417
|
+
onDelete={() => setConfirmingBulkDelete(true)}
|
|
418
|
+
onEmail={handleEmail}
|
|
419
|
+
onToggleFavorite={handleToggleFavorite}
|
|
420
|
+
onAddCategory={handleAddCategory}
|
|
421
|
+
onExportVCard={handleExportVCard}
|
|
422
|
+
onImportFile={handleImportFile}
|
|
423
|
+
shortcuts
|
|
424
|
+
/>
|
|
425
|
+
<div className="p-3 border-b border-border">
|
|
426
|
+
<input
|
|
427
|
+
ref={searchInputRef}
|
|
428
|
+
type="search"
|
|
429
|
+
value={query}
|
|
430
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
431
|
+
placeholder="Search contacts"
|
|
432
|
+
aria-label="Search contacts"
|
|
433
|
+
className={INPUT_CLASS}
|
|
434
|
+
/>
|
|
435
|
+
</div>
|
|
436
|
+
{error && (
|
|
437
|
+
<div className="p-3">
|
|
438
|
+
<Alert>{error}</Alert>
|
|
439
|
+
</div>
|
|
440
|
+
)}
|
|
441
|
+
{(isDeletedView ? deletedTruncated : truncated) && (
|
|
442
|
+
<div className="p-3">
|
|
443
|
+
<Alert>
|
|
444
|
+
This folder has more contacts than can be shown at once - only the first {LIST_PAGE_SIZE * MAX_LIST_PAGES} are
|
|
445
|
+
listed.
|
|
446
|
+
</Alert>
|
|
447
|
+
</div>
|
|
448
|
+
)}
|
|
449
|
+
{deletedError && isDeletedView && (
|
|
450
|
+
<div className="p-3">
|
|
451
|
+
<Alert>{deletedError}</Alert>
|
|
452
|
+
</div>
|
|
453
|
+
)}
|
|
454
|
+
{(isDeletedView ? deletedLoading : loading) ? (
|
|
455
|
+
<p className="p-4 text-sm text-text-muted">Loading…</p>
|
|
456
|
+
) : sorted.length === 0 ? (
|
|
457
|
+
<p className="p-4 text-sm text-text-muted">No contacts found.</p>
|
|
458
|
+
) : (
|
|
459
|
+
<div className="flex-1 overflow-auto">
|
|
460
|
+
<table className="w-full text-sm">
|
|
461
|
+
<thead>
|
|
462
|
+
<tr className="border-b border-border text-left">
|
|
463
|
+
{!isDeletedView && (
|
|
464
|
+
<th className="w-8 px-3 py-2">
|
|
465
|
+
<input
|
|
466
|
+
type="checkbox"
|
|
467
|
+
aria-label="Select all contacts"
|
|
468
|
+
checked={allChecked}
|
|
469
|
+
onChange={toggleCheckAll}
|
|
470
|
+
/>
|
|
471
|
+
</th>
|
|
472
|
+
)}
|
|
473
|
+
<th className="px-3 py-2">
|
|
474
|
+
<button
|
|
475
|
+
type="button"
|
|
476
|
+
onClick={() => handleSort("name")}
|
|
477
|
+
className="text-xs font-bold uppercase tracking-wide text-text-muted"
|
|
478
|
+
>
|
|
479
|
+
Name{sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""}
|
|
480
|
+
</button>
|
|
481
|
+
</th>
|
|
482
|
+
<th className="px-3 py-2">
|
|
483
|
+
<button
|
|
484
|
+
type="button"
|
|
485
|
+
onClick={() => handleSort("info")}
|
|
486
|
+
className="text-xs font-bold uppercase tracking-wide text-text-muted"
|
|
487
|
+
>
|
|
488
|
+
Contact info{sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""}
|
|
489
|
+
</button>
|
|
490
|
+
</th>
|
|
491
|
+
</tr>
|
|
492
|
+
</thead>
|
|
493
|
+
<tbody>
|
|
494
|
+
{sorted.map((contact) => (
|
|
495
|
+
<tr
|
|
496
|
+
key={contact.uid}
|
|
497
|
+
className={["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(
|
|
498
|
+
" ",
|
|
499
|
+
)}
|
|
500
|
+
>
|
|
501
|
+
{!isDeletedView && (
|
|
502
|
+
<td className="px-3 py-2">
|
|
503
|
+
<input
|
|
504
|
+
type="checkbox"
|
|
505
|
+
aria-label={`Select ${contact.displayName}`}
|
|
506
|
+
checked={checkedUids.has(contact.uid)}
|
|
507
|
+
onChange={() => toggleChecked(contact.uid)}
|
|
508
|
+
/>
|
|
509
|
+
</td>
|
|
510
|
+
)}
|
|
511
|
+
<td className="px-3 py-2">
|
|
512
|
+
<button type="button" onClick={() => handleSelectRow(contact)} className="flex items-center gap-2 text-left">
|
|
513
|
+
<ContactAvatar displayName={contact.displayName} size={28} />
|
|
514
|
+
<span className="truncate font-medium">
|
|
515
|
+
{contact.displayName}
|
|
516
|
+
{contact.favorite && <span aria-label="Favorite"> ★</span>}
|
|
517
|
+
</span>
|
|
518
|
+
</button>
|
|
519
|
+
</td>
|
|
520
|
+
<td className="px-3 py-2 text-text-muted truncate">{primaryInfo(contact)}</td>
|
|
521
|
+
</tr>
|
|
522
|
+
))}
|
|
523
|
+
</tbody>
|
|
524
|
+
</table>
|
|
525
|
+
</div>
|
|
526
|
+
)}
|
|
527
|
+
</div>
|
|
528
|
+
{/* On mobile, "selected"/"edit" are unreachable (row taps navigate to /contacts/:uid instead —
|
|
529
|
+
see handleSelectRow), so this pane only needs to show there for "new", which stays in-place
|
|
530
|
+
on every device (an unsaved contact has no uid for a route). Always visible on desktop. */}
|
|
531
|
+
<div className={["flex-1 min-w-0 overflow-y-auto p-6 md:block", mode === "new" ? "block" : "hidden"].join(" ")}>
|
|
532
|
+
{savedElsewhere && mode !== "new" && (
|
|
533
|
+
<p role="status" className="mb-4 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
|
|
534
|
+
{savedElsewhere.displayName} was added to{" "}
|
|
535
|
+
{mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox"}.{" "}
|
|
536
|
+
<a
|
|
537
|
+
href={`/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`}
|
|
538
|
+
className="font-medium text-primary-dark hover:underline"
|
|
539
|
+
>
|
|
540
|
+
View that mailbox’s contacts
|
|
541
|
+
</a>
|
|
542
|
+
</p>
|
|
543
|
+
)}
|
|
544
|
+
{mode === "new" && mailboxUid && folderUid ? (
|
|
545
|
+
<ContactForm
|
|
546
|
+
mailboxUid={mailboxUid}
|
|
547
|
+
folderUid={folderUid}
|
|
548
|
+
mailboxes={writableMailboxes}
|
|
549
|
+
onSaved={handleSaved}
|
|
550
|
+
onCancel={handleCancel}
|
|
551
|
+
/>
|
|
552
|
+
) : mode === "edit" && selected ? (
|
|
553
|
+
<ContactForm contact={selected} onSaved={handleSaved} onCancel={handleCancel} />
|
|
554
|
+
) : selected ? (
|
|
555
|
+
<ContactDetailPane
|
|
556
|
+
contact={selected}
|
|
557
|
+
onEdit={() => setMode("edit")}
|
|
558
|
+
onDelete={() => handleDelete(selected)}
|
|
559
|
+
onKeysChanged={() => void reload()}
|
|
560
|
+
canResolveKeys={ownsMailbox || delegateCanUpdate}
|
|
561
|
+
/>
|
|
562
|
+
) : (
|
|
563
|
+
<p className="text-sm text-text-muted">Select a contact, or create a new one.</p>
|
|
564
|
+
)}
|
|
565
|
+
</div>
|
|
566
|
+
<Modal open={confirmingBulkDelete} onClose={() => setConfirmingBulkDelete(false)} title="Delete contacts">
|
|
567
|
+
<p className="text-sm mb-5">
|
|
568
|
+
Delete {checkedContacts.length} selected {checkedContacts.length === 1 ? "contact" : "contacts"}? They
|
|
569
|
+
move to Deleted contacts.
|
|
570
|
+
</p>
|
|
571
|
+
<div className="flex gap-3 justify-end">
|
|
572
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={() => setConfirmingBulkDelete(false)}>
|
|
573
|
+
Cancel
|
|
574
|
+
</Button>
|
|
575
|
+
<Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={handleBulkDelete}>
|
|
576
|
+
Delete
|
|
577
|
+
</Button>
|
|
578
|
+
</div>
|
|
579
|
+
</Modal>
|
|
580
|
+
</div>
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export default routedPage("/contacts", ContactsPage);
|