@rapidmx/web-client 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (443) hide show
  1. package/README.md +373 -88
  2. package/apps/admin/_layout.tsx +8 -2
  3. package/apps/admin/index.tsx +7 -1
  4. package/apps/admin/ingest-queue/index.tsx +2 -1
  5. package/apps/admin/mailboxes/[uid].tsx +23 -10
  6. package/apps/admin/quarantine/index.tsx +2 -1
  7. package/apps/admin/signing-certificates/index.tsx +343 -0
  8. package/apps/escrow/_layout.tsx +8 -2
  9. package/apps/shared/appearance/AppearanceForm.tsx +392 -0
  10. package/apps/shared/appearance/AppearanceHead.tsx +49 -0
  11. package/apps/shared/appearance/AppearanceProvider.tsx +470 -0
  12. package/apps/shared/appearance/ColorField.tsx +107 -0
  13. package/apps/shared/appearance/appearanceCache.ts +106 -0
  14. package/apps/shared/appearance/appearanceContext.ts +77 -0
  15. package/apps/shared/appearance/bootScript.ts +35 -0
  16. package/apps/shared/appearance/color.ts +169 -0
  17. package/apps/shared/appearance/photo.ts +88 -0
  18. package/apps/shared/appearance/resolvedTheme.ts +67 -0
  19. package/apps/shared/appearance/theme.ts +355 -0
  20. package/apps/shared/auth/accountUrl.ts +9 -0
  21. package/apps/shared/auth/adminAccess.ts +99 -0
  22. package/apps/shared/components/admin/layout/AdminShell.tsx +22 -10
  23. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +107 -60
  24. package/apps/shared/components/admin/settings/BrandingForm.tsx +24 -0
  25. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
  26. package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
  27. package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
  28. package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
  29. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  30. package/apps/shared/components/contacts/ContactForm.tsx +11 -5
  31. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
  32. package/apps/shared/components/contacts/ContactsToolbar.tsx +98 -115
  33. package/apps/shared/components/contacts/layout/ContactsShell.tsx +25 -8
  34. package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
  35. package/apps/shared/components/layout/AppShell.tsx +477 -288
  36. package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
  37. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  38. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  39. package/apps/shared/components/layout/RailIcon.tsx +100 -0
  40. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
  41. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
  42. package/apps/shared/components/layout/UserMenu.tsx +372 -185
  43. package/apps/shared/components/mail/ConversationList.tsx +271 -262
  44. package/apps/shared/components/mail/ConversationThreadPane.tsx +147 -94
  45. package/apps/shared/components/mail/LazyReadingPane.tsx +113 -0
  46. package/apps/shared/components/mail/MailSelectionBar.tsx +227 -222
  47. package/apps/shared/components/mail/MessageDetailPane.tsx +1537 -1387
  48. package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
  49. package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
  50. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -160
  51. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  52. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1644 -1736
  53. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  54. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  55. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  56. package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
  57. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  58. package/apps/shared/components/mail/layout/MailShell.tsx +490 -472
  59. package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
  60. package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
  61. package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
  62. package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
  63. package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
  64. package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
  65. package/apps/shared/components/mail/reading/color.ts +245 -0
  66. package/apps/shared/components/mail/reading/frameControl.ts +142 -0
  67. package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
  68. package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
  69. package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
  70. package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
  71. package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
  72. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  73. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
  74. package/apps/shared/components/settings/layout/SettingsShell.tsx +253 -240
  75. package/apps/shared/components/sharing/PrincipalPicker.tsx +145 -0
  76. package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
  77. package/apps/shared/components/tasks/layout/TasksShell.tsx +24 -8
  78. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  79. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  80. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  81. package/apps/shared/keyboard/dispatch.ts +124 -0
  82. package/apps/shared/keyboard/format.ts +89 -0
  83. package/apps/shared/keyboard/keymap.ts +114 -0
  84. package/apps/shared/keyboard/match.ts +44 -0
  85. package/apps/shared/keyboard/parse.ts +136 -0
  86. package/apps/shared/keyboard/platform.ts +34 -0
  87. package/apps/shared/keyboard/registry.ts +65 -0
  88. package/apps/shared/keyboard/targets.ts +79 -0
  89. package/apps/shared/keyboard/useShortcut.ts +50 -0
  90. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  91. package/apps/shared/mail/folderCounts.ts +308 -0
  92. package/apps/shared/mail/folderTree.ts +143 -0
  93. package/apps/shared/mail/listSnapshots.ts +87 -0
  94. package/apps/shared/mail/messageReadState.ts +85 -0
  95. package/apps/shared/mail/newMailNotifications.ts +183 -0
  96. package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
  97. package/apps/shared/mail/outbox/composeBridge.ts +69 -0
  98. package/apps/shared/mail/outbox/outboxState.ts +88 -0
  99. package/apps/shared/mail/outbox/pendingSends.ts +146 -0
  100. package/apps/shared/mail/outbox/sendDecision.ts +157 -0
  101. package/apps/shared/mail/outbox/sendJob.ts +427 -0
  102. package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
  103. package/apps/shared/mail/outbox/sendState.ts +35 -0
  104. package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
  105. package/apps/shared/mail/useMailConnection.ts +203 -0
  106. package/apps/shared/mail/useMailLiveUpdates.ts +277 -224
  107. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  108. package/apps/shared/mail/useNewMailNotifications.ts +178 -0
  109. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  110. package/apps/shared/navigation/AppRouter.tsx +300 -0
  111. package/apps/shared/navigation/appHrefs.ts +23 -0
  112. package/apps/shared/navigation/frameContext.tsx +35 -0
  113. package/apps/shared/navigation/idle.ts +45 -0
  114. package/apps/shared/navigation/routerContext.tsx +83 -0
  115. package/apps/shared/navigation/routes.ts +72 -0
  116. package/apps/shared/notifications/NotificationCenter.tsx +215 -0
  117. package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
  118. package/apps/shared/notifications/apiErrors.ts +98 -0
  119. package/apps/shared/notifications/headerOffset.ts +35 -0
  120. package/apps/shared/notifications/pushStatus.ts +56 -0
  121. package/apps/shared/notifications/store.ts +549 -0
  122. package/apps/shared/notifications/systemErrors.ts +63 -0
  123. package/apps/shared/notifications/useNotifications.ts +41 -0
  124. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  125. package/apps/shared/signing/enrollmentStorage.ts +33 -0
  126. package/apps/shared/signing/enrollmentTracker.ts +385 -0
  127. package/apps/shared/signing/enrollmentView.ts +251 -0
  128. package/apps/shared/signing/signingInfo.ts +58 -0
  129. package/apps/shared/signing/useNow.ts +19 -0
  130. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
  131. package/apps/shared/styles/app.css +108 -10
  132. package/apps/www/_layout.tsx +8 -2
  133. package/apps/www/_routedPage.tsx +24 -0
  134. package/apps/www/_routes.ts +36 -0
  135. package/apps/www/calendar/index.tsx +64 -33
  136. package/apps/www/contacts/[uid].tsx +109 -107
  137. package/apps/www/contacts/index.tsx +62 -42
  138. package/apps/www/index.tsx +2291 -1917
  139. package/apps/www/messages/[uid].tsx +108 -101
  140. package/apps/www/settings/appearance/index.tsx +21 -0
  141. package/apps/www/settings/auto-reply/index.tsx +7 -8
  142. package/apps/www/settings/encryption/index.tsx +1287 -1249
  143. package/apps/www/settings/filters/[uid].tsx +7 -2
  144. package/apps/www/settings/filters/index.tsx +102 -99
  145. package/apps/www/settings/filters/new/index.tsx +140 -133
  146. package/apps/www/settings/labels/index.tsx +204 -201
  147. package/apps/www/settings/privacy/index.tsx +7 -9
  148. package/apps/www/settings/read-receipts/index.tsx +7 -8
  149. package/apps/www/settings/sharing/index.tsx +256 -274
  150. package/apps/www/settings/signatures/[uid].tsx +172 -167
  151. package/apps/www/settings/signatures/index.tsx +88 -85
  152. package/apps/www/settings/signatures/new/index.tsx +135 -129
  153. package/apps/www/tasks/index.tsx +29 -18
  154. package/dist/apps/admin/_layout.d.ts +5 -1
  155. package/dist/apps/admin/_layout.js +3 -2
  156. package/dist/apps/admin/index.js +3 -2
  157. package/dist/apps/admin/ingest-queue/index.js +2 -1
  158. package/dist/apps/admin/mailboxes/[uid].js +9 -6
  159. package/dist/apps/admin/quarantine/index.js +2 -1
  160. package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
  161. package/dist/apps/admin/signing-certificates/index.js +161 -0
  162. package/dist/apps/escrow/_layout.d.ts +5 -1
  163. package/dist/apps/escrow/_layout.js +3 -2
  164. package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
  165. package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
  166. package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
  167. package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
  168. package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
  169. package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
  170. package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
  171. package/dist/apps/shared/appearance/ColorField.js +40 -0
  172. package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
  173. package/dist/apps/shared/appearance/appearanceCache.js +80 -0
  174. package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
  175. package/dist/apps/shared/appearance/appearanceContext.js +29 -0
  176. package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
  177. package/dist/apps/shared/appearance/bootScript.js +33 -0
  178. package/dist/apps/shared/appearance/color.d.ts +65 -0
  179. package/dist/apps/shared/appearance/color.js +132 -0
  180. package/dist/apps/shared/appearance/photo.d.ts +17 -0
  181. package/dist/apps/shared/appearance/photo.js +79 -0
  182. package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
  183. package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
  184. package/dist/apps/shared/appearance/theme.d.ts +111 -0
  185. package/dist/apps/shared/appearance/theme.js +251 -0
  186. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  187. package/dist/apps/shared/auth/accountUrl.js +8 -0
  188. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  189. package/dist/apps/shared/auth/adminAccess.js +89 -0
  190. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  191. package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
  192. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
  193. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
  194. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
  195. package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
  196. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  197. package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
  198. package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
  199. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
  200. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  201. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  202. package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
  203. package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
  204. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +6 -2
  205. package/dist/apps/shared/components/contacts/ContactsToolbar.js +28 -10
  206. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  207. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +19 -6
  208. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
  209. package/dist/apps/shared/components/layout/AppShell.d.ts +34 -4
  210. package/dist/apps/shared/components/layout/AppShell.js +122 -17
  211. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
  212. package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
  213. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  214. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  215. package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
  216. package/dist/apps/shared/components/layout/RailIcon.js +85 -0
  217. package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
  218. package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
  219. package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
  220. package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
  221. package/dist/apps/shared/components/layout/UserMenu.d.ts +25 -3
  222. package/dist/apps/shared/components/layout/UserMenu.js +88 -7
  223. package/dist/apps/shared/components/mail/ConversationList.js +5 -11
  224. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +6 -2
  225. package/dist/apps/shared/components/mail/ConversationThreadPane.js +87 -40
  226. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  227. package/dist/apps/shared/components/mail/LazyReadingPane.js +83 -0
  228. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -4
  229. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  230. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +25 -8
  231. package/dist/apps/shared/components/mail/MessageDetailPane.js +167 -73
  232. package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
  233. package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
  234. package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
  235. package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
  236. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +38 -0
  237. package/dist/apps/shared/components/mail/compose/ComposeContext.js +97 -6
  238. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  239. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +3 -2
  240. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +316 -339
  241. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  242. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  243. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  244. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  245. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  246. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  247. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
  248. package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
  249. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  250. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  251. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +14 -4
  252. package/dist/apps/shared/components/mail/layout/MailShell.js +90 -104
  253. package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
  254. package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
  255. package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
  256. package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
  257. package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
  258. package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
  259. package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
  260. package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
  261. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
  262. package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
  263. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
  264. package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
  265. package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
  266. package/dist/apps/shared/components/mail/reading/color.js +201 -0
  267. package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
  268. package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
  269. package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
  270. package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
  271. package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
  272. package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
  273. package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
  274. package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
  275. package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
  276. package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
  277. package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
  278. package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
  279. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  280. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  281. package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
  282. package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
  283. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  284. package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -5
  285. package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +26 -0
  286. package/dist/apps/shared/components/sharing/PrincipalPicker.js +77 -0
  287. package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
  288. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  289. package/dist/apps/shared/components/tasks/layout/TasksShell.js +18 -6
  290. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  291. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  292. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  293. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  294. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  295. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  296. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  297. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  298. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  299. package/dist/apps/shared/keyboard/format.js +74 -0
  300. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  301. package/dist/apps/shared/keyboard/keymap.js +84 -0
  302. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  303. package/dist/apps/shared/keyboard/match.js +31 -0
  304. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  305. package/dist/apps/shared/keyboard/parse.js +107 -0
  306. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  307. package/dist/apps/shared/keyboard/platform.js +22 -0
  308. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  309. package/dist/apps/shared/keyboard/registry.js +33 -0
  310. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  311. package/dist/apps/shared/keyboard/targets.js +67 -0
  312. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  313. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  314. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  315. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  316. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  317. package/dist/apps/shared/mail/folderCounts.js +218 -0
  318. package/dist/apps/shared/mail/folderTree.d.ts +55 -0
  319. package/dist/apps/shared/mail/folderTree.js +116 -0
  320. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  321. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  322. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  323. package/dist/apps/shared/mail/messageReadState.js +63 -0
  324. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  325. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  326. package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
  327. package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
  328. package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
  329. package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
  330. package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
  331. package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
  332. package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
  333. package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
  334. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
  335. package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
  336. package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
  337. package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
  338. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
  339. package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
  340. package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
  341. package/dist/apps/shared/mail/outbox/sendState.js +8 -0
  342. package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
  343. package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
  344. package/dist/apps/shared/mail/useMailConnection.d.ts +56 -0
  345. package/dist/apps/shared/mail/useMailConnection.js +138 -0
  346. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +29 -9
  347. package/dist/apps/shared/mail/useMailLiveUpdates.js +60 -32
  348. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  349. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  350. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +35 -0
  351. package/dist/apps/shared/mail/useNewMailNotifications.js +130 -0
  352. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  353. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  354. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  355. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  356. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  357. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  358. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  359. package/dist/apps/shared/navigation/frameContext.js +24 -0
  360. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  361. package/dist/apps/shared/navigation/idle.js +43 -0
  362. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  363. package/dist/apps/shared/navigation/routerContext.js +56 -0
  364. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  365. package/dist/apps/shared/navigation/routes.js +37 -0
  366. package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
  367. package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
  368. package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
  369. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
  370. package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
  371. package/dist/apps/shared/notifications/apiErrors.js +86 -0
  372. package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
  373. package/dist/apps/shared/notifications/headerOffset.js +33 -0
  374. package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
  375. package/dist/apps/shared/notifications/pushStatus.js +54 -0
  376. package/dist/apps/shared/notifications/store.d.ts +136 -0
  377. package/dist/apps/shared/notifications/store.js +416 -0
  378. package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
  379. package/dist/apps/shared/notifications/systemErrors.js +58 -0
  380. package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
  381. package/dist/apps/shared/notifications/useNotifications.js +18 -0
  382. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  383. package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
  384. package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
  385. package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
  386. package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
  387. package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
  388. package/dist/apps/shared/signing/enrollmentView.js +214 -0
  389. package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
  390. package/dist/apps/shared/signing/signingInfo.js +48 -0
  391. package/dist/apps/shared/signing/useNow.d.ts +2 -0
  392. package/dist/apps/shared/signing/useNow.js +18 -0
  393. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
  394. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
  395. package/dist/apps/shared/styles/app.css +108 -10
  396. package/dist/apps/www/_layout.d.ts +5 -1
  397. package/dist/apps/www/_layout.js +3 -2
  398. package/dist/apps/www/_routedPage.d.ts +12 -0
  399. package/dist/apps/www/_routedPage.js +19 -0
  400. package/dist/apps/www/_routes.d.ts +11 -0
  401. package/dist/apps/www/_routes.js +30 -0
  402. package/dist/apps/www/calendar/index.d.ts +2 -2
  403. package/dist/apps/www/calendar/index.js +41 -11
  404. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  405. package/dist/apps/www/contacts/[uid].js +11 -9
  406. package/dist/apps/www/contacts/index.d.ts +2 -2
  407. package/dist/apps/www/contacts/index.js +30 -24
  408. package/dist/apps/www/index.d.ts +2 -2
  409. package/dist/apps/www/index.js +332 -48
  410. package/dist/apps/www/messages/[uid].d.ts +3 -7
  411. package/dist/apps/www/messages/[uid].js +10 -5
  412. package/dist/apps/www/settings/appearance/index.d.ts +4 -0
  413. package/dist/apps/www/settings/appearance/index.js +13 -0
  414. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  415. package/dist/apps/www/settings/auto-reply/index.js +6 -7
  416. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  417. package/dist/apps/www/settings/encryption/index.js +106 -96
  418. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  419. package/dist/apps/www/settings/filters/[uid].js +6 -2
  420. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  421. package/dist/apps/www/settings/filters/index.js +3 -1
  422. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  423. package/dist/apps/www/settings/filters/new/index.js +9 -3
  424. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  425. package/dist/apps/www/settings/labels/index.js +3 -1
  426. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  427. package/dist/apps/www/settings/privacy/index.js +8 -9
  428. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  429. package/dist/apps/www/settings/read-receipts/index.js +6 -7
  430. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  431. package/dist/apps/www/settings/sharing/index.js +22 -34
  432. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  433. package/dist/apps/www/settings/signatures/[uid].js +6 -2
  434. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  435. package/dist/apps/www/settings/signatures/index.js +3 -1
  436. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  437. package/dist/apps/www/settings/signatures/new/index.js +9 -4
  438. package/dist/apps/www/tasks/index.d.ts +2 -2
  439. package/dist/apps/www/tasks/index.js +22 -16
  440. package/package.json +2 -2
  441. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
  442. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
  443. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
@@ -0,0 +1,470 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { PropsWithChildren, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
6
+ import {
7
+ AppearanceInput,
8
+ AppearancePreferences,
9
+ DEFAULT_BACKGROUND,
10
+ appearanceBackgroundUrl,
11
+ deleteAppearanceBackground,
12
+ diffAppearance,
13
+ getAppearance,
14
+ isDefaultAppearance,
15
+ normalizeAppearance,
16
+ parseAppearanceEvent,
17
+ saveAppearance,
18
+ uploadAppearanceBackground,
19
+ } from "@rapidmx/react-shared/appearance/preferencesApi.js";
20
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
21
+ import { getPushClient } from "@rapidmx/react-shared/mail/pushClient.js";
22
+ import { AppearanceApi, AppearanceContext, AppearancePatch } from "./appearanceContext.js";
23
+ import { cacheKeyOf, readAppearanceCache, timeOf, writeAppearanceCache } from "./appearanceCache.js";
24
+ import { measureImage, rangeFromLevels } from "./photo.js";
25
+ import { useSystemPrefersDark } from "./resolvedTheme.js";
26
+ import { APPEARANCE_STYLE_ID, PhotoRange, appearanceCss } from "./theme.js";
27
+
28
+ /** How long a change waits for a further one before it is saved - a slider being dragged is one save, not sixty. */
29
+ export const APPEARANCE_SAVE_DELAY_MS = 400;
30
+
31
+ /** The `imageVersion` the preview of a just-chosen file has until the server names the stored one. */
32
+ const LOCAL_VERSION = "local";
33
+
34
+ const DEFAULTS: AppearancePreferences = { version: 1, mode: "system" };
35
+
36
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
37
+
38
+ /** `prefs` with `patch` merged in, normalised (so an out-of-range number or bad colour can't get through). See `AppearancePatch`. */
39
+ export function mergeAppearance(prefs: AppearancePreferences, patch: AppearancePatch): AppearancePreferences {
40
+ const next: Record<string, unknown> = { version: 1, mode: patch.mode ?? prefs.mode };
41
+ const colors: Record<string, string> = { ...prefs.colors };
42
+ for (const [key, value] of Object.entries(patch.colors ?? {})) {
43
+ if (value === null || value === undefined) {
44
+ delete colors[key];
45
+ } else {
46
+ colors[key] = value;
47
+ }
48
+ }
49
+ if (Object.keys(colors).length > 0) {
50
+ next.colors = colors;
51
+ }
52
+ if (patch.background !== null) {
53
+ const background = patch.background ? { ...(prefs.background ?? DEFAULT_BACKGROUND), ...patch.background } : prefs.background;
54
+ if (background) {
55
+ next.background = background;
56
+ }
57
+ }
58
+ if (prefs.updatedAt !== undefined) {
59
+ next.updatedAt = prefs.updatedAt;
60
+ }
61
+ return normalizeAppearance(next) as AppearancePreferences;
62
+ }
63
+
64
+ function toInput(prefs: AppearancePreferences): AppearanceInput {
65
+ const { updatedAt: _updatedAt, ...input } = prefs;
66
+ return input;
67
+ }
68
+
69
+ /** Whether two preference objects say the same thing, ignoring the server's timestamp. */
70
+ function sameContent(a: AppearancePreferences | undefined, b: AppearancePreferences | undefined): boolean {
71
+ return JSON.stringify(a && toInput(a)) === JSON.stringify(b && toInput(b));
72
+ }
73
+
74
+ function messageOf(err: unknown, fallback: string): string {
75
+ return err instanceof ApiRequestError && err.message ? err.message : fallback;
76
+ }
77
+
78
+ export interface AppearanceProviderProps {
79
+ /** The signed-in user - what the cache is filed under. */
80
+ userUid?: string;
81
+ /** The user's stored preferences as the server rendered the page (its `appearance` prop), so the first paint is already themed. Absent when the
82
+ * page wasn't given them (or the user has none). The server caches a page for up to a minute, so this can be older than what this browser last
83
+ * applied: the browser's own copy is used instead when it is newer (see `appearanceCache.ts`). */
84
+ initial?: unknown;
85
+ /** Whether to ask the server for the preferences once mounted (the default), which corrects a page that was cached. The admin and escrow
86
+ * consoles pass `false`: they use what the last webmail page cached in this browser. */
87
+ lookUp?: boolean;
88
+ }
89
+
90
+ /**
91
+ * Owns the user's appearance for the whole app: keeps the theme applied to the page and everything Settings > Appearance needs.
92
+ *
93
+ * - **Applies it.** One `<style id="rr-appearance">` in `<head>` (`appearanceCss()`) holds the user's colour tokens and the background
94
+ * layer, and `<html data-theme>` says which scheme is showing - the chosen one, or for "System" the operating system's, live.
95
+ * - **Instantly.** `setPrefs()`, `uploadBackground()`, `removeBackground()` and `reset()` change the page in the same frame; the save
96
+ * happens after (`APPEARANCE_SAVE_DELAY_MS` of quiet, coalesced), sending only what changed - the server merges - and a failed one
97
+ * rolls back to what the server has and sets `error`.
98
+ * - **No flash.** It starts from the server's `initial` preferences, or the browser's newer cached copy (which the inline boot script has
99
+ * already put in place before anything painted), and then asks the server what is true now.
100
+ * - **Stays current.** Another tab or device's change arrives as a push event and is applied unless this tab has changes of its own
101
+ * still in flight.
102
+ */
103
+ export default function AppearanceProvider({ userUid, initial, lookUp = true, children }: PropsWithChildren<AppearanceProviderProps>) {
104
+ const initialPrefs = useMemo(() => normalizeAppearance(initial), [initial]);
105
+ const [prefs, setPrefsState] = useState<AppearancePreferences | undefined>(initialPrefs);
106
+ // The server (or the user) has spoken: until then the page shows whatever the boot script applied from the cache.
107
+ const [authoritative, setAuthoritative] = useState(initialPrefs !== undefined);
108
+ const [local, setLocal] = useState<string | undefined>(undefined);
109
+ const [measured, setMeasured] = useState<{ version: string; range: PhotoRange } | undefined>(() => {
110
+ // Read here, not in an effect, so the first stylesheet the provider writes already has the measured panel opacity the boot script used.
111
+ const cache = typeof window === "undefined" ? undefined : readAppearanceCache();
112
+ const range = rangeFromLevels(cache?.measured?.levels);
113
+ return cache?.measured && range ? { version: cache.measured.version, range } : undefined;
114
+ });
115
+ const [saving, setSaving] = useState(false);
116
+ const [error, setError] = useState<string | null>(null);
117
+ const systemDark = useSystemPrefersDark();
118
+
119
+ const prefsRef = useRef(prefs);
120
+ prefsRef.current = prefs;
121
+ /** What the server last acknowledged - what a failed save rolls back to, and what a save is a difference from. */
122
+ const serverRef = useRef<AppearancePreferences | undefined>(initialPrefs);
123
+ /** When this tab last changed the preferences itself (milliseconds), for the cache's time - see `appearanceCache.ts`. */
124
+ const changedAtRef = useRef(0);
125
+ const pendingRef = useRef<AppearancePreferences | undefined>(undefined);
126
+ const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
127
+ const flightRef = useRef<Promise<void> | undefined>(undefined);
128
+ const localRef = useRef(local);
129
+ localRef.current = local;
130
+
131
+ const resolved = (prefs?.mode ?? "system") === "system" ? (systemDark ? "dark" : "light") : (prefs?.mode as "light" | "dark");
132
+
133
+ const apply = useCallback((next: AppearancePreferences | undefined) => {
134
+ prefsRef.current = next;
135
+ setPrefsState(next);
136
+ setAuthoritative(true);
137
+ }, []);
138
+
139
+ /** `apply()` for a change the user made here: it is newer than anything the server has said so far. */
140
+ const applyLocal = useCallback(
141
+ (next: AppearancePreferences | undefined) => {
142
+ changedAtRef.current = Date.now();
143
+ apply(next);
144
+ },
145
+ [apply],
146
+ );
147
+
148
+ /** Puts the server's last acknowledged preferences back and says why. */
149
+ const rollback = useCallback(
150
+ (err: unknown, fallback: string) => {
151
+ pendingRef.current = undefined;
152
+ apply(serverRef.current);
153
+ setError(messageOf(err, fallback));
154
+ },
155
+ [apply],
156
+ );
157
+
158
+ /** Saves whatever is pending, and whatever arrives while that is in flight, one request at a time. */
159
+ const flush = useCallback((): Promise<void> => {
160
+ clearTimeout(timerRef.current);
161
+ timerRef.current = undefined;
162
+ if (flightRef.current) {
163
+ return flightRef.current;
164
+ }
165
+ if (!pendingRef.current) {
166
+ return Promise.resolve();
167
+ }
168
+ const flight = (async () => {
169
+ try {
170
+ while (pendingRef.current) {
171
+ const toSave = pendingRef.current;
172
+ pendingRef.current = undefined;
173
+ // Only what differs from what the server has: it merges, and refuses a body that names the image or a kind it can't yet show.
174
+ const update = diffAppearance(serverRef.current, toInput(toSave));
175
+ if (!update) {
176
+ continue;
177
+ }
178
+ const stored = await saveAppearance(update);
179
+ serverRef.current = stored;
180
+ if (!pendingRef.current && prefsRef.current) {
181
+ // Nothing newer waiting: take the server's timestamp (the values are the ones sent).
182
+ apply({ ...prefsRef.current, updatedAt: stored.updatedAt });
183
+ }
184
+ }
185
+ } catch (err) {
186
+ rollback(err, "Your appearance could not be saved. It has been put back.");
187
+ } finally {
188
+ flightRef.current = undefined;
189
+ setSaving(pendingRef.current !== undefined);
190
+ }
191
+ })();
192
+ flightRef.current = flight;
193
+ return flight;
194
+ }, [apply, rollback]);
195
+
196
+ /** Queues `next` to be saved after a quiet moment. */
197
+ const scheduleSave = useCallback(
198
+ (next: AppearancePreferences) => {
199
+ pendingRef.current = next;
200
+ setSaving(true);
201
+ clearTimeout(timerRef.current);
202
+ timerRef.current = setTimeout(() => void flush(), APPEARANCE_SAVE_DELAY_MS);
203
+ },
204
+ [flush],
205
+ );
206
+
207
+ // A change still waiting when the provider goes away (the page is closing) is sent now rather than lost.
208
+ useEffect(() => {
209
+ function flushWhenHidden() {
210
+ if (document.visibilityState === "hidden") {
211
+ void flush();
212
+ }
213
+ }
214
+ document.addEventListener("visibilitychange", flushWhenHidden);
215
+ return () => {
216
+ document.removeEventListener("visibilitychange", flushWhenHidden);
217
+ void flush();
218
+ };
219
+ }, [flush]);
220
+
221
+ const setPrefs = useCallback(
222
+ (patch: AppearancePatch) => {
223
+ setError(null);
224
+ const next = mergeAppearance(prefsRef.current ?? DEFAULTS, patch);
225
+ applyLocal(next);
226
+ scheduleSave(next);
227
+ },
228
+ [applyLocal, scheduleSave],
229
+ );
230
+
231
+ const releaseLocal = useCallback(() => {
232
+ if (localRef.current) {
233
+ URL.revokeObjectURL(localRef.current);
234
+ localRef.current = undefined;
235
+ }
236
+ setLocal(undefined);
237
+ }, []);
238
+
239
+ const uploadBackground = useCallback(
240
+ async (file: Blob) => {
241
+ setError(null);
242
+ releaseLocal();
243
+ const url = URL.createObjectURL(file);
244
+ localRef.current = url;
245
+ setLocal(url);
246
+ applyLocal(mergeAppearance(prefsRef.current ?? DEFAULTS, { background: { kind: "image", imageVersion: LOCAL_VERSION } }));
247
+ try {
248
+ // Whatever was changed a moment ago is stored first, so its (older) save can't land after the picture.
249
+ await flush();
250
+ const stored = await uploadAppearanceBackground(file);
251
+ if (stored.background?.imageVersion === undefined) {
252
+ throw new ApiRequestError("The server did not keep the image.", 502);
253
+ }
254
+ serverRef.current = stored;
255
+ const version = String(stored.background.imageVersion);
256
+ const merged = mergeAppearance(prefsRef.current ?? DEFAULTS, {
257
+ background: { kind: "image", imageVersion: stored.background.imageVersion },
258
+ });
259
+ apply({ ...merged, updatedAt: stored.updatedAt });
260
+ if (!sameContent(merged, stored)) {
261
+ // The user moved a slider while the image was uploading: the server has the older values.
262
+ scheduleSave(merged);
263
+ }
264
+ // The local preview stays until the stored image has loaded, so swapping to it doesn't blink.
265
+ const preload = new Image();
266
+ const swap = () => {
267
+ setMeasured((current) => (current?.version === LOCAL_VERSION ? { ...current, version } : current));
268
+ if (localRef.current === url) {
269
+ releaseLocal();
270
+ }
271
+ };
272
+ preload.onload = swap;
273
+ preload.onerror = swap;
274
+ preload.src = appearanceBackgroundUrl(version);
275
+ } catch (err) {
276
+ releaseLocal();
277
+ rollback(err, "That image could not be uploaded.");
278
+ }
279
+ },
280
+ [apply, applyLocal, flush, releaseLocal, rollback, scheduleSave],
281
+ );
282
+
283
+ const removeBackground = useCallback(async () => {
284
+ setError(null);
285
+ const next = mergeAppearance(prefsRef.current ?? DEFAULTS, { background: { kind: "none", imageVersion: undefined } });
286
+ applyLocal(next);
287
+ releaseLocal();
288
+ try {
289
+ await flush();
290
+ serverRef.current = (await deleteAppearanceBackground()) ?? serverRef.current;
291
+ scheduleSave(next);
292
+ } catch (err) {
293
+ rollback(err, "The background could not be removed.");
294
+ }
295
+ }, [applyLocal, flush, releaseLocal, rollback, scheduleSave]);
296
+
297
+ const reset = useCallback(async () => {
298
+ setError(null);
299
+ const hadImage = prefsRef.current?.background?.imageVersion !== undefined;
300
+ applyLocal(DEFAULTS);
301
+ releaseLocal();
302
+ pendingRef.current = undefined;
303
+ try {
304
+ await flush();
305
+ if (hadImage) {
306
+ serverRef.current = (await deleteAppearanceBackground()) ?? serverRef.current;
307
+ }
308
+ const update = diffAppearance(serverRef.current, toInput(DEFAULTS));
309
+ if (update) {
310
+ serverRef.current = await saveAppearance(update);
311
+ }
312
+ } catch (err) {
313
+ rollback(err, "Your appearance could not be reset.");
314
+ }
315
+ }, [applyLocal, flush, releaseLocal, rollback]);
316
+
317
+ // The page's copy can be a minute old (the server caches a rendered page), and this browser may have changed the preferences since: the
318
+ // browser's own copy, when it is newer, is what to show - before the first paint, so nothing flips.
319
+ useIsomorphicLayoutEffect(() => {
320
+ if (initialPrefs === undefined) {
321
+ return;
322
+ }
323
+ const cache = readAppearanceCache();
324
+ if (cache && (!cache.uid || cache.uid === userUid) && cache.t > timeOf(initialPrefs.updatedAt)) {
325
+ apply(cache.prefs);
326
+ }
327
+ // Once, for the page as it was rendered.
328
+ }, []);
329
+
330
+ // Without the page's copy, the cache is what to show until the server answers; then the server's answer is, unless the user changed something
331
+ // meanwhile. With it, the server is asked too, to correct a page that was cached.
332
+ useEffect(() => {
333
+ if (!userUid) {
334
+ return;
335
+ }
336
+ if (initialPrefs === undefined) {
337
+ const cache = readAppearanceCache();
338
+ if (cache && (!cache.uid || cache.uid === userUid)) {
339
+ setPrefsState((current) => current ?? cache.prefs);
340
+ }
341
+ }
342
+ if (!lookUp) {
343
+ return;
344
+ }
345
+ let cancelled = false;
346
+ getAppearance().then(
347
+ (stored) => {
348
+ if (cancelled || pendingRef.current || flightRef.current || timerRef.current) {
349
+ return;
350
+ }
351
+ serverRef.current = stored;
352
+ if (!sameContent(stored, prefsRef.current)) {
353
+ apply(stored);
354
+ }
355
+ },
356
+ () => undefined,
357
+ );
358
+ return () => {
359
+ cancelled = true;
360
+ };
361
+ }, [initialPrefs, userUid, lookUp, apply]);
362
+
363
+ // Another tab or device changed it.
364
+ useEffect(() => {
365
+ if (!userUid) {
366
+ return;
367
+ }
368
+ return getPushClient().onEvent((event) => {
369
+ const pushed = parseAppearanceEvent(event);
370
+ if (pushed === undefined) {
371
+ return;
372
+ }
373
+ // Our own change still travelling (or its echo, which says what is already on screen) must not undo what is on screen.
374
+ if (pendingRef.current || flightRef.current || timerRef.current || sameContent(pushed ?? undefined, prefsRef.current)) {
375
+ return;
376
+ }
377
+ serverRef.current = pushed ?? undefined;
378
+ apply(pushed ?? undefined);
379
+ });
380
+ }, [userUid, apply]);
381
+
382
+ const version = prefs?.background?.imageVersion;
383
+ const imageUrl = local ?? (version === undefined || version === LOCAL_VERSION ? undefined : appearanceBackgroundUrl(version));
384
+ const measureKey = local ? LOCAL_VERSION : version === undefined ? undefined : String(version);
385
+ const isImage = prefs?.background?.kind === "image";
386
+
387
+ // Measure the picture (once per image) so the panels over it are only as opaque as it needs.
388
+ useEffect(() => {
389
+ if (!isImage || !imageUrl || !measureKey || measured?.version === measureKey) {
390
+ return;
391
+ }
392
+ let cancelled = false;
393
+ void measureImage(imageUrl).then((range) => {
394
+ if (!cancelled && range) {
395
+ setMeasured({ version: measureKey, range });
396
+ }
397
+ });
398
+ return () => {
399
+ cancelled = true;
400
+ };
401
+ }, [isImage, imageUrl, measureKey, measured?.version]);
402
+
403
+ const range = measured && measured.version === measureKey ? measured.range : undefined;
404
+
405
+ // Apply: the stylesheet, then the scheme.
406
+ useIsomorphicLayoutEffect(() => {
407
+ if (!authoritative && !prefs) {
408
+ // Nothing known yet: leave what the boot script applied from the cache.
409
+ return;
410
+ }
411
+ const css = appearanceCss(prefs, { measured: range, imageUrl });
412
+ let element = document.getElementById(APPEARANCE_STYLE_ID);
413
+ if (!css) {
414
+ element?.remove();
415
+ } else {
416
+ if (!element) {
417
+ element = document.createElement("style");
418
+ element.id = APPEARANCE_STYLE_ID;
419
+ document.head.appendChild(element);
420
+ }
421
+ element.textContent = css;
422
+ element.setAttribute("data-key", cacheKeyOf(prefs as AppearancePreferences));
423
+ }
424
+ // What the next page load starts from: never a blob: preview, and "nothing chosen" is remembered too (an empty stylesheet), so a page
425
+ // cached by the server from before a reset doesn't bring the old background back.
426
+ if (!local) {
427
+ writeAppearanceCache(
428
+ prefs ?? DEFAULTS,
429
+ css,
430
+ Math.max(timeOf(prefs?.updatedAt), changedAtRef.current),
431
+ userUid,
432
+ range && measureKey ? { version: measureKey, range } : undefined,
433
+ );
434
+ }
435
+ }, [authoritative, prefs, range, imageUrl, local, userUid, measureKey]);
436
+
437
+ useIsomorphicLayoutEffect(() => {
438
+ document.documentElement.setAttribute("data-theme", resolved);
439
+ }, [resolved]);
440
+
441
+ // A blob: URL that outlives the provider leaks the picture's memory.
442
+ useEffect(
443
+ () => () => {
444
+ if (localRef.current) {
445
+ URL.revokeObjectURL(localRef.current);
446
+ }
447
+ },
448
+ [],
449
+ );
450
+
451
+ const value = useMemo<AppearanceApi>(
452
+ () => ({
453
+ prefs: prefs ?? DEFAULTS,
454
+ resolved,
455
+ backgroundUrl: imageUrl,
456
+ measured: range,
457
+ isDefault: isDefaultAppearance(prefs),
458
+ saving,
459
+ error,
460
+ clearError: () => setError(null),
461
+ setPrefs,
462
+ uploadBackground,
463
+ removeBackground,
464
+ reset,
465
+ }),
466
+ [prefs, resolved, imageUrl, range, saving, error, setPrefs, uploadBackground, removeBackground, reset],
467
+ );
468
+
469
+ return <AppearanceContext.Provider value={value}>{children}</AppearanceContext.Provider>;
470
+ }
@@ -0,0 +1,107 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useState } from "react";
6
+ import { isHexColor, normalizeHex } from "./color.js";
7
+
8
+ export interface ColorFieldProps {
9
+ /** Unique in the page: the ids of the two inputs derive from it. */
10
+ id: string;
11
+ label: string;
12
+ /** One line under the label saying what the colour is used for. */
13
+ description: string;
14
+ /** The colour the user chose, `#rrggbb`; `undefined` while they haven't. */
15
+ value: string | undefined;
16
+ /** What shows while unset: the colour the app has now. */
17
+ current: string;
18
+ /** Called with a new `#rrggbb` as the person picks or types one - on every change, so the app follows a drag on the picker. */
19
+ onChange: (hex: string) => void;
20
+ /** Called for "Reset": back to the app's own colour. Absent for a field with nothing to reset to. */
21
+ onReset?: () => void;
22
+ }
23
+
24
+ /**
25
+ * A colour to choose: the browser's colour picker beside a hex field (`#rrggbb`, and the short `#rgb` and a missing `#` are understood),
26
+ * and a "Reset" that goes back to the app's own colour. A full `#rrggbb` is applied as it is typed, a short one when the field is left, and
27
+ * something that isn't a colour changes nothing - the field says so and goes back to the current colour when it is left.
28
+ */
29
+ export default function ColorField({ id, label, description, value, current, onChange, onReset }: ColorFieldProps) {
30
+ // What is being typed, while it is: `null` shows the colour itself (after a pick, or once the field is left).
31
+ const [draft, setDraft] = useState<string | null>(null);
32
+ const shown = value ?? current;
33
+ const invalid = draft !== null && normalizeHex(draft) === undefined;
34
+
35
+ return (
36
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
37
+ <div className="min-w-0 flex-1 basis-40">
38
+ <label htmlFor={`${id}-hex`} className="block text-sm font-semibold text-text">
39
+ {label}
40
+ </label>
41
+ <p id={`${id}-description`} className="text-xs text-text-muted">
42
+ {description}
43
+ </p>
44
+ </div>
45
+ <input
46
+ type="color"
47
+ aria-label={`${label} picker`}
48
+ value={shown}
49
+ onChange={(event) => {
50
+ setDraft(null);
51
+ // A colour input only ever holds a `#rrggbb` (the browser sanitises anything else to black).
52
+ onChange(normalizeHex(event.target.value) as string);
53
+ }}
54
+ className="h-9 w-12 shrink-0 cursor-pointer rounded-sm border border-border bg-surface p-0.5"
55
+ />
56
+ <div className="w-28 shrink-0">
57
+ <input
58
+ id={`${id}-hex`}
59
+ type="text"
60
+ inputMode="text"
61
+ autoComplete="off"
62
+ spellCheck={false}
63
+ maxLength={7}
64
+ value={draft ?? shown}
65
+ aria-invalid={invalid || undefined}
66
+ aria-describedby={invalid ? `${id}-error ${id}-description` : `${id}-description`}
67
+ onChange={(event) => {
68
+ setDraft(event.target.value);
69
+ // Only a complete `#rrggbb` (the hash is optional) is applied while typing: `#1a2` on the way to `#1a2b3c` would flash `#11aa22`.
70
+ const typed = event.target.value.trim();
71
+ if (isHexColor(typed.startsWith("#") ? typed : "#" + typed)) {
72
+ onChange(normalizeHex(typed) as string);
73
+ }
74
+ }}
75
+ onBlur={() => {
76
+ // Leaving the field applies a short form (`#abc`) that was typed, and drops anything that isn't a colour.
77
+ const hex = draft === null ? undefined : normalizeHex(draft);
78
+ if (hex && hex !== shown) {
79
+ onChange(hex);
80
+ }
81
+ setDraft(null);
82
+ }}
83
+ className="w-full rounded-sm border border-border bg-surface px-2 py-1.5 font-mono text-sm text-text"
84
+ />
85
+ </div>
86
+ {onReset && (
87
+ <button
88
+ type="button"
89
+ onClick={() => {
90
+ setDraft(null);
91
+ onReset();
92
+ }}
93
+ disabled={value === undefined}
94
+ aria-label={`Reset ${label.toLowerCase()} to the default`}
95
+ className="shrink-0 rounded-sm px-2 py-1.5 text-sm font-semibold text-accent-dark hover:not-disabled:underline disabled:cursor-not-allowed disabled:opacity-50"
96
+ >
97
+ Reset
98
+ </button>
99
+ )}
100
+ {invalid && (
101
+ <p id={`${id}-error`} role="alert" className="basis-full text-xs text-danger">
102
+ Use a colour like #1a2b3c.
103
+ </p>
104
+ )}
105
+ </div>
106
+ );
107
+ }