@rapidmx/web-client 0.10.0 → 0.12.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 (324) hide show
  1. package/README.md +183 -2
  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/mailboxes/new/index.tsx +3 -3
  7. package/apps/admin/quarantine/index.tsx +2 -1
  8. package/apps/admin/signing-certificates/index.tsx +343 -0
  9. package/apps/escrow/_layout.tsx +8 -2
  10. package/apps/shared/appearance/AppearanceForm.tsx +392 -0
  11. package/apps/shared/appearance/AppearanceHead.tsx +49 -0
  12. package/apps/shared/appearance/AppearanceProvider.tsx +470 -0
  13. package/apps/shared/appearance/ColorField.tsx +107 -0
  14. package/apps/shared/appearance/appearanceCache.ts +106 -0
  15. package/apps/shared/appearance/appearanceContext.ts +77 -0
  16. package/apps/shared/appearance/bootScript.ts +35 -0
  17. package/apps/shared/appearance/color.ts +169 -0
  18. package/apps/shared/appearance/photo.ts +88 -0
  19. package/apps/shared/appearance/resolvedTheme.ts +67 -0
  20. package/apps/shared/appearance/theme.ts +355 -0
  21. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +4 -4
  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 +60 -14
  26. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +65 -16
  27. package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
  28. package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
  29. package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
  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 +25 -52
  33. package/apps/shared/components/contacts/layout/ContactsShell.tsx +10 -5
  34. package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
  35. package/apps/shared/components/layout/AppShell.tsx +98 -32
  36. package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
  37. package/apps/shared/components/layout/RailIcon.tsx +100 -0
  38. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
  39. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
  40. package/apps/shared/components/layout/UserMenu.tsx +75 -6
  41. package/apps/shared/components/mail/ConversationList.tsx +4 -2
  42. package/apps/shared/components/mail/ConversationThreadPane.tsx +487 -475
  43. package/apps/shared/components/mail/LazyReadingPane.tsx +14 -16
  44. package/apps/shared/components/mail/MailSelectionBar.tsx +2 -10
  45. package/apps/shared/components/mail/MessageDetailPane.tsx +366 -287
  46. package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
  47. package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
  48. package/apps/shared/components/mail/compose/ComposeContext.tsx +35 -2
  49. package/apps/shared/components/mail/compose/ComposeWindow.tsx +239 -437
  50. package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
  51. package/apps/shared/components/mail/layout/MailShell.tsx +39 -25
  52. package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
  53. package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
  54. package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
  55. package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
  56. package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
  57. package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
  58. package/apps/shared/components/mail/reading/color.ts +245 -0
  59. package/apps/shared/components/mail/reading/frameControl.ts +142 -0
  60. package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
  61. package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
  62. package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
  63. package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
  64. package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
  65. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
  66. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  67. package/apps/shared/components/sharing/PrincipalListField.tsx +82 -0
  68. package/apps/shared/components/sharing/PrincipalPicker.tsx +76 -0
  69. package/apps/shared/components/sharing/PrincipalResolver.tsx +164 -0
  70. package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
  71. package/apps/shared/components/tasks/layout/TasksShell.tsx +9 -5
  72. package/apps/shared/mail/folderCounts.ts +9 -3
  73. package/apps/shared/mail/folderTree.ts +143 -0
  74. package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
  75. package/apps/shared/mail/outbox/composeBridge.ts +69 -0
  76. package/apps/shared/mail/outbox/outboxState.ts +88 -0
  77. package/apps/shared/mail/outbox/pendingSends.ts +146 -0
  78. package/apps/shared/mail/outbox/sendDecision.ts +157 -0
  79. package/apps/shared/mail/outbox/sendJob.ts +427 -0
  80. package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
  81. package/apps/shared/mail/outbox/sendState.ts +35 -0
  82. package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
  83. package/apps/shared/mail/useMailConnection.ts +81 -28
  84. package/apps/shared/mail/useMailLiveUpdates.ts +55 -10
  85. package/apps/shared/mail/useNewMailNotifications.ts +47 -32
  86. package/apps/shared/navigation/AppRouter.tsx +2 -2
  87. package/apps/shared/notifications/NotificationCenter.tsx +215 -0
  88. package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
  89. package/apps/shared/notifications/apiErrors.ts +98 -0
  90. package/apps/shared/notifications/headerOffset.ts +35 -0
  91. package/apps/shared/notifications/pushStatus.ts +56 -0
  92. package/apps/shared/notifications/store.ts +549 -0
  93. package/apps/shared/notifications/systemErrors.ts +63 -0
  94. package/apps/shared/notifications/useNotifications.ts +41 -0
  95. package/apps/shared/signing/enrollmentStorage.ts +33 -0
  96. package/apps/shared/signing/enrollmentTracker.ts +385 -0
  97. package/apps/shared/signing/enrollmentView.ts +251 -0
  98. package/apps/shared/signing/signingInfo.ts +58 -0
  99. package/apps/shared/signing/useNow.ts +19 -0
  100. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
  101. package/apps/shared/styles/app.css +80 -0
  102. package/apps/www/_layout.tsx +8 -2
  103. package/apps/www/_routes.ts +1 -0
  104. package/apps/www/calendar/index.tsx +9 -14
  105. package/apps/www/contacts/[uid].tsx +5 -8
  106. package/apps/www/contacts/index.tsx +587 -584
  107. package/apps/www/index.tsx +38 -27
  108. package/apps/www/messages/[uid].tsx +3 -1
  109. package/apps/www/settings/appearance/index.tsx +21 -0
  110. package/apps/www/settings/auto-reply/index.tsx +3 -7
  111. package/apps/www/settings/encryption/index.tsx +151 -116
  112. package/apps/www/settings/filters/[uid].tsx +3 -1
  113. package/apps/www/settings/filters/new/index.tsx +3 -1
  114. package/apps/www/settings/privacy/index.tsx +3 -8
  115. package/apps/www/settings/read-receipts/index.tsx +3 -7
  116. package/apps/www/settings/sharing/index.tsx +44 -65
  117. package/apps/www/settings/signatures/[uid].tsx +3 -1
  118. package/apps/www/settings/signatures/new/index.tsx +3 -2
  119. package/apps/www/tasks/index.tsx +11 -16
  120. package/dist/apps/admin/_layout.d.ts +5 -1
  121. package/dist/apps/admin/_layout.js +3 -2
  122. package/dist/apps/admin/index.js +3 -2
  123. package/dist/apps/admin/ingest-queue/index.js +2 -1
  124. package/dist/apps/admin/mailboxes/[uid].js +9 -6
  125. package/dist/apps/admin/mailboxes/new/index.js +1 -1
  126. package/dist/apps/admin/quarantine/index.js +2 -1
  127. package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
  128. package/dist/apps/admin/signing-certificates/index.js +161 -0
  129. package/dist/apps/escrow/_layout.d.ts +5 -1
  130. package/dist/apps/escrow/_layout.js +3 -2
  131. package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
  132. package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
  133. package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
  134. package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
  135. package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
  136. package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
  137. package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
  138. package/dist/apps/shared/appearance/ColorField.js +40 -0
  139. package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
  140. package/dist/apps/shared/appearance/appearanceCache.js +80 -0
  141. package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
  142. package/dist/apps/shared/appearance/appearanceContext.js +29 -0
  143. package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
  144. package/dist/apps/shared/appearance/bootScript.js +33 -0
  145. package/dist/apps/shared/appearance/color.d.ts +65 -0
  146. package/dist/apps/shared/appearance/color.js +132 -0
  147. package/dist/apps/shared/appearance/photo.d.ts +17 -0
  148. package/dist/apps/shared/appearance/photo.js +79 -0
  149. package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
  150. package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
  151. package/dist/apps/shared/appearance/theme.d.ts +111 -0
  152. package/dist/apps/shared/appearance/theme.js +251 -0
  153. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +3 -2
  154. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  155. package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
  156. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
  157. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
  158. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
  159. package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
  160. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +25 -5
  161. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +18 -7
  162. package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
  163. package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
  164. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
  165. package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
  166. package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
  167. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  168. package/dist/apps/shared/components/contacts/ContactsToolbar.js +24 -9
  169. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -1
  170. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
  171. package/dist/apps/shared/components/layout/AppShell.d.ts +6 -1
  172. package/dist/apps/shared/components/layout/AppShell.js +59 -13
  173. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
  174. package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
  175. package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
  176. package/dist/apps/shared/components/layout/RailIcon.js +85 -0
  177. package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
  178. package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
  179. package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
  180. package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
  181. package/dist/apps/shared/components/layout/UserMenu.d.ts +7 -1
  182. package/dist/apps/shared/components/layout/UserMenu.js +41 -7
  183. package/dist/apps/shared/components/mail/ConversationList.js +3 -2
  184. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +2 -1
  185. package/dist/apps/shared/components/mail/ConversationThreadPane.js +42 -20
  186. package/dist/apps/shared/components/mail/LazyReadingPane.js +9 -8
  187. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +3 -4
  188. package/dist/apps/shared/components/mail/MailSelectionBar.js +2 -3
  189. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +18 -7
  190. package/dist/apps/shared/components/mail/MessageDetailPane.js +86 -41
  191. package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
  192. package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
  193. package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
  194. package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
  195. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +8 -0
  196. package/dist/apps/shared/components/mail/compose/ComposeContext.js +28 -3
  197. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +2 -1
  198. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +229 -328
  199. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
  200. package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
  201. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +2 -0
  202. package/dist/apps/shared/components/mail/layout/MailShell.js +20 -14
  203. package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
  204. package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
  205. package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
  206. package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
  207. package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
  208. package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
  209. package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
  210. package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
  211. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
  212. package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
  213. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
  214. package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
  215. package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
  216. package/dist/apps/shared/components/mail/reading/color.js +201 -0
  217. package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
  218. package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
  219. package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
  220. package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
  221. package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
  222. package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
  223. package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
  224. package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
  225. package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
  226. package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
  227. package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
  228. package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
  229. package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
  230. package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
  231. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  232. package/dist/apps/shared/components/sharing/PrincipalListField.d.ts +24 -0
  233. package/dist/apps/shared/components/sharing/PrincipalListField.js +23 -0
  234. package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +28 -0
  235. package/dist/apps/shared/components/sharing/PrincipalPicker.js +24 -0
  236. package/dist/apps/shared/components/sharing/PrincipalResolver.d.ts +44 -0
  237. package/dist/apps/shared/components/sharing/PrincipalResolver.js +92 -0
  238. package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
  239. package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -1
  240. package/dist/apps/shared/mail/folderCounts.d.ts +1 -1
  241. package/dist/apps/shared/mail/folderCounts.js +9 -3
  242. package/dist/apps/shared/mail/folderTree.d.ts +55 -0
  243. package/dist/apps/shared/mail/folderTree.js +116 -0
  244. package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
  245. package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
  246. package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
  247. package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
  248. package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
  249. package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
  250. package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
  251. package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
  252. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
  253. package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
  254. package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
  255. package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
  256. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
  257. package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
  258. package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
  259. package/dist/apps/shared/mail/outbox/sendState.js +8 -0
  260. package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
  261. package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
  262. package/dist/apps/shared/mail/useMailConnection.d.ts +9 -8
  263. package/dist/apps/shared/mail/useMailConnection.js +64 -22
  264. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +17 -4
  265. package/dist/apps/shared/mail/useMailLiveUpdates.js +36 -10
  266. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +4 -10
  267. package/dist/apps/shared/mail/useNewMailNotifications.js +43 -23
  268. package/dist/apps/shared/navigation/AppRouter.js +2 -2
  269. package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
  270. package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
  271. package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
  272. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
  273. package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
  274. package/dist/apps/shared/notifications/apiErrors.js +86 -0
  275. package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
  276. package/dist/apps/shared/notifications/headerOffset.js +33 -0
  277. package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
  278. package/dist/apps/shared/notifications/pushStatus.js +54 -0
  279. package/dist/apps/shared/notifications/store.d.ts +136 -0
  280. package/dist/apps/shared/notifications/store.js +416 -0
  281. package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
  282. package/dist/apps/shared/notifications/systemErrors.js +58 -0
  283. package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
  284. package/dist/apps/shared/notifications/useNotifications.js +18 -0
  285. package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
  286. package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
  287. package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
  288. package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
  289. package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
  290. package/dist/apps/shared/signing/enrollmentView.js +214 -0
  291. package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
  292. package/dist/apps/shared/signing/signingInfo.js +48 -0
  293. package/dist/apps/shared/signing/useNow.d.ts +2 -0
  294. package/dist/apps/shared/signing/useNow.js +18 -0
  295. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
  296. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
  297. package/dist/apps/shared/styles/app.css +80 -0
  298. package/dist/apps/www/_layout.d.ts +5 -1
  299. package/dist/apps/www/_layout.js +3 -2
  300. package/dist/apps/www/_routes.js +1 -0
  301. package/dist/apps/www/calendar/index.js +3 -4
  302. package/dist/apps/www/contacts/[uid].js +5 -7
  303. package/dist/apps/www/contacts/index.js +14 -21
  304. package/dist/apps/www/index.js +37 -19
  305. package/dist/apps/www/messages/[uid].js +3 -1
  306. package/dist/apps/www/settings/appearance/index.d.ts +4 -0
  307. package/dist/apps/www/settings/appearance/index.js +13 -0
  308. package/dist/apps/www/settings/auto-reply/index.js +3 -6
  309. package/dist/apps/www/settings/encryption/index.js +103 -95
  310. package/dist/apps/www/settings/filters/[uid].js +3 -1
  311. package/dist/apps/www/settings/filters/new/index.js +3 -1
  312. package/dist/apps/www/settings/privacy/index.js +5 -8
  313. package/dist/apps/www/settings/read-receipts/index.js +3 -6
  314. package/dist/apps/www/settings/sharing/index.js +19 -33
  315. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  316. package/dist/apps/www/settings/signatures/new/index.js +3 -2
  317. package/dist/apps/www/tasks/index.js +8 -14
  318. package/package.json +2 -2
  319. package/apps/shared/components/mail/NewMailToasts.tsx +0 -141
  320. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
  321. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +0 -19
  322. package/dist/apps/shared/components/mail/NewMailToasts.js +0 -55
  323. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
  324. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
@@ -12,6 +12,8 @@ import { getBranding } from "@rapidmx/react-shared/branding/brandingApi.js";
12
12
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
13
13
  import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
14
14
  import UserMenu from "../../layout/UserMenu.js";
15
+ import RailIcon from "../../layout/RailIcon.js";
16
+ import AppearanceProvider from "../../../appearance/AppearanceProvider.js";
15
17
  import { signOutOfConsole } from "../../admin/signOut.js";
16
18
 
17
19
  export type EscrowSection = "matters" | "auditLog";
@@ -104,13 +106,13 @@ export default function EscrowShell({ active, userUid, authServerUrl, children }
104
106
  } else {
105
107
  const activeItem = NAV_ITEMS.find((item) => item.id === active);
106
108
  content = (
107
- <div className="min-h-screen flex flex-col bg-surface-alt">
109
+ <div className="rr-frame-bg min-h-screen flex flex-col">
108
110
  <div className="flex-1 flex min-h-0">
109
111
  <nav
110
112
  aria-label="Escrow sections"
111
- className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1"
113
+ className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center pb-3 gap-1"
112
114
  >
113
- <img src={iconSrc} width="96" height="96" alt="" className="mb-3" />
115
+ <RailIcon src={iconSrc} />
114
116
  {NAV_ITEMS.map(({ id, href, label, icon: Icon }) => (
115
117
  <a
116
118
  key={id}
@@ -131,11 +133,11 @@ export default function EscrowShell({ active, userUid, authServerUrl, children }
131
133
  </nav>
132
134
  <BottomTabBar apps={NAV_ITEMS} active={active} />
133
135
  <div className="flex-1 flex flex-col min-w-0">
134
- <header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
136
+ <header className="rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
135
137
  <span className="font-display font-bold text-lg uppercase tracking-wide">{activeItem?.label}</span>
136
138
  <UserMenu userUid={userUid} authServerUrl={authServerUrl} onSignOut={handleSignOut} />
137
139
  </header>
138
- <div className="flex-1 pb-14 md:pb-0">
140
+ <div id="app-content" className="flex-1 pb-14 md:pb-0">
139
141
  <main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
140
142
  </div>
141
143
  </div>
@@ -144,5 +146,10 @@ export default function EscrowShell({ active, userUid, authServerUrl, children }
144
146
  );
145
147
  }
146
148
 
147
- return <>{content}</>;
149
+ // The user's own colours and background (`AppearanceProvider`), from what the last webmail page cached in this browser: the console never asks the server.
150
+ return (
151
+ <AppearanceProvider userUid={userUid} lookUp={false}>
152
+ {content}
153
+ </AppearanceProvider>
154
+ );
148
155
  }
@@ -20,11 +20,15 @@ import { useIdleKeyTimeout } from "@rapidmx/react-shared/crypto/useIdleKeyTimeou
20
20
  import ComposeProvider from "../mail/compose/ComposeContext.js";
21
21
  import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
22
22
  import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
23
- import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
23
+ import { FrameBrandingFooter, FrameBrandingHeader, useBrandingHtml } from "./BrandingChrome.js";
24
+ import RailIcon from "./RailIcon.js";
25
+ import AppearanceProvider from "../../appearance/AppearanceProvider.js";
26
+ import { clearAppearanceCache } from "../../appearance/appearanceCache.js";
27
+ import type { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
24
28
  import UserMenu from "./UserMenu.js";
25
29
  import { UnlockPromptProvider } from "./UnlockPromptProvider.js";
26
30
  import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localIndexRpcClient.js";
27
- import { authApiFetch } from "@rapidmx/react-shared/util/api.js";
31
+ import { authApiFetch, setApiUnauthorizedObserver } from "@rapidmx/react-shared/util/api.js";
28
32
  import { destroyUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
29
33
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
30
34
  import { mergePluginNavItems, PluginNav, PluginNavProps } from "../../plugins/pluginNav.js";
@@ -37,7 +41,13 @@ import ShortcutsDialog from "../../keyboard/ShortcutsDialog.js";
37
41
  import { inboxUnreadTotal } from "../../mail/folderCounts.js";
38
42
  import { MailConnectionContext, useMailConnection } from "../../mail/useMailConnection.js";
39
43
  import { useUnreadTitle } from "../../mail/useUnreadTitle.js";
40
- import NewMailToasts from "../mail/NewMailToasts.js";
44
+ import UnlockBridge from "../../mail/outbox/UnlockBridge.js";
45
+ import NotificationCenter from "../../notifications/NotificationCenter.js";
46
+ import { useHeaderHeightRef } from "../../notifications/headerOffset.js";
47
+ import NotificationHistoryDialog from "../../notifications/NotificationHistoryDialog.js";
48
+ import { notifySessionExpired, setSignInUrl } from "../../notifications/apiErrors.js";
49
+ import { useUnseenErrorCount } from "../../notifications/useNotifications.js";
50
+ import { useSigningEnrollmentWatcher } from "../../signing/useSigningEnrollmentWatcher.js";
41
51
 
42
52
  /** How long sign-out waits for auth-server's logout before navigating anyway. */
43
53
  export const LOGOUT_TIMEOUT_MS = 3_000;
@@ -93,6 +103,10 @@ export interface AppShellProps extends PluginNavProps {
93
103
  /** The role names the server treats as trusted (its `trusted_roles` config, injected via the route's `fetchProps`) -
94
104
  * what that lookup looks for. Absent means the server's own default, `["admin"]`. */
95
105
  trustedRoles?: string[];
106
+ /** The deployment's branding as the server rendered the page (its `branding` prop). Used until the frame's own fetch answers, so a custom header is there from the first paint - and the frame knows to drop its own title bar - instead of appearing (and moving everything) once the request returns. */
107
+ branding?: Branding;
108
+ /** The signed-in user's stored appearance preferences as the server rendered the page (its `appearance` prop), so the first paint already wears the theme and background - see `AppearanceProvider`. */
109
+ appearance?: unknown;
96
110
  }
97
111
 
98
112
  export interface AppDef {
@@ -152,6 +166,8 @@ export function AppChrome({
152
166
  impersonationBaseUrl,
153
167
  trusted,
154
168
  trustedRoles,
169
+ branding: initialBranding,
170
+ appearance,
155
171
  pluginNav,
156
172
  routeKey,
157
173
  busy,
@@ -161,8 +177,20 @@ export function AppChrome({
161
177
  const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
162
178
  // The keyboard shortcuts dialog: opened by `?`/Ctrl+/ (`GlobalShortcuts`) and by the account menu's item.
163
179
  const [shortcutsOpen, setShortcutsOpen] = useState(false);
180
+ // The pop-up history ("Recent notifications" in the account menu). The pop-ups themselves are drawn by `NotificationCenter`, below.
181
+ const [historyOpen, setHistoryOpen] = useState(false);
182
+ // Only the count is read, so the whole frame does not render again for every pop-up that comes and goes.
183
+ const unseenErrors = useUnseenErrorCount();
184
+ // The title bar's height for the pop-up stack, which sticks just below the header (a branding header publishes its own - `FrameBrandingHeader`).
185
+ const headerRef = useHeaderHeightRef();
164
186
  const signingOutRef = useRef(false);
165
- const { branding, iconSrc } = useBranding();
187
+ const { branding: fetchedBranding, iconSrc: fetchedIconSrc } = useBranding();
188
+ // The server's copy until the fetch answers, so the frame's shape (a custom header replaces the title bar) never changes after the first paint.
189
+ const branding = fetchedBranding ?? initialBranding ?? null;
190
+ const iconSrc = fetchedBranding ? fetchedIconSrc : initialBranding?.iconUrl || initialBranding?.logoUrl || fetchedIconSrc;
191
+ // The custom header and footer, sanitized and with their `{USER_MENU}` / `{APP_TITLE}` placeholders (`undefined` until parsed, `null` when there is none).
192
+ const header = useBrandingHtml(branding?.headerHtml);
193
+ const footer = useBrandingHtml(branding?.footerHtml);
166
194
  // The mailboxes, their folders and the one push connection live here, in the frame that stays mounted as the router swaps pages - so
167
195
  // new-mail pop-ups, the folder counters and the tab title's unread count work in Calendar, Contacts, Tasks and Settings too, and moving
168
196
  // between apps never opens a second socket (the server allows ten per user). `MailShell` reads them from `MailConnectionContext`. Outside
@@ -170,8 +198,20 @@ export function AppChrome({
170
198
  const inFrame = useInAppFrame();
171
199
  const navigate = useNavigate();
172
200
  const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
201
+ // A signing certificate the user asked for is announced when it is issued (or fails), on whichever page they are - see the hook.
202
+ useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
173
203
 
174
204
  useRedirectIfUnauthenticated(userUid, authServerUrl);
205
+ // Any request this app makes that the server answers with a 401 - the session ended - raises one "Your session expired" pop-up with a
206
+ // Sign in action (see `notifySessionExpired()`), whichever request noticed first, a background refresh included.
207
+ useEffect(() => {
208
+ setSignInUrl(authServerUrl);
209
+ if (!userUid) {
210
+ return;
211
+ }
212
+ setApiUnauthorizedObserver(() => void notifySessionExpired());
213
+ return () => setApiUnauthorizedObserver(undefined);
214
+ }, [userUid, authServerUrl]);
175
215
  // Mounted here, not scoped to Mail/Settings (the only shells that actually read unlocked keys),
176
216
  // specifically so activity in *any* app resets the idle clock - see that hook's own doc comment.
177
217
  useIdleKeyTimeout();
@@ -213,6 +253,7 @@ export function AppChrome({
213
253
  markSigningOut();
214
254
  destroyUnlockedKeys();
215
255
  clearPinnedSignerCache();
256
+ clearAppearanceCache();
216
257
  // Bounded by its own timeout and never rejects - awaited so navigating doesn't kill the Worker mid-delete.
217
258
  void destroyAllLocalIndexes().then(() => {
218
259
  window.location.href = authServerUrl ?? "/";
@@ -230,6 +271,7 @@ export function AppChrome({
230
271
  destroyUnlockedKeys();
231
272
  // Trusted signer pins read from contacts don't outlive the session either.
232
273
  clearPinnedSignerCache();
274
+ clearAppearanceCache();
233
275
  // Open compose windows save edits still waiting on their autosave debounce while the session is still
234
276
  // valid - logout invalidates it. Bounded the same way as logout itself, and never rejects.
235
277
  await flushComposeDrafts(LOGOUT_TIMEOUT_MS);
@@ -290,21 +332,55 @@ export function AppChrome({
290
332
  const apps = appRailItems(pluginNav);
291
333
  // The header title: "settings" has no rail item, everything else is labelled by its own rail item.
292
334
  const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
335
+ // A custom header (`Branding.headerHtml`) replaces the app's own title bar and the icon at the top of the rail: it is the top of the app, and the
336
+ // account menu moves into it. While it is still being parsed it already counts, so the frame's shape doesn't change under the user.
337
+ const customHeader = header !== null;
338
+ // Where the one account menu lives: the header's `{USER_MENU}`, else the footer's, else a small cell at the header's right end - never lost.
339
+ const menuInHeader = !!header?.hasUserMenu;
340
+ const menuInFooter = customHeader && !!header && !menuInHeader && !!footer?.hasUserMenu;
341
+ const renderUserMenu = (placement: "down" | "up") => (
342
+ <UserMenu
343
+ userUid={userUid}
344
+ authServerUrl={authServerUrl}
345
+ onSignOut={handleSignOut}
346
+ // An impersonating administrator acts as the impersonated user, so the console link is hidden then.
347
+ showAdminLink={!!trusted && !impersonating}
348
+ detectAdmin={!trusted && !impersonating}
349
+ trustedRoles={trustedRoles}
350
+ showSettingsLink
351
+ showNotificationSettings
352
+ onShowShortcuts={() => setShortcutsOpen(true)}
353
+ onShowNotifications={() => setHistoryOpen(true)}
354
+ unseenErrors={unseenErrors}
355
+ placement={placement}
356
+ />
357
+ );
293
358
 
294
359
  return (
295
360
  // Mounted here, not scoped to Mail/Settings, for the same reason as useIdleKeyTimeout() above -
296
361
  // ComposeWindow's sign/encrypt toggles and MessageDetailPane's encrypted-message view (both Mail)
297
362
  // are today's only useUnlockPrompt() callers, but this needs to be available to any app shell.
363
+ // Asks the server for the user's appearance only in the persistent frame (every core page); a page that renders its own chrome (a plugin's) uses the
364
+ // `appearance` it was given and what this browser cached.
365
+ <AppearanceProvider userUid={userUid} initial={appearance} lookUp={inFrame}>
298
366
  <ShortcutProvider>
299
367
  <MailConnectionContext.Provider value={inFrame ? mail : null}>
300
368
  <GlobalShortcuts authServerUrl={authServerUrl} onToggleHelp={() => setShortcutsOpen((open) => !open)} />
301
369
  <ShortcutsDialog open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
302
370
  <UnlockPromptProvider>
371
+ <UnlockBridge />
303
372
  {/* An impersonating admin acts with the impersonated user's access, so their own trusted role
304
373
  mustn't skip the per-mailbox checks. */}
305
374
  <ComposeProvider userUid={userUid} trusted={!!trusted && !impersonating}>
306
- <div className="min-h-screen flex flex-col bg-surface-alt">
307
- {!hideChrome && <BrandingHeader branding={branding} />}
375
+ <div className="rr-frame-bg min-h-screen flex flex-col">
376
+ {!hideChrome && customHeader && (
377
+ <FrameBrandingHeader
378
+ parsed={header}
379
+ userMenu={menuInHeader ? renderUserMenu("down") : undefined}
380
+ fallbackMenu={header && !menuInHeader && !menuInFooter ? renderUserMenu("down") : undefined}
381
+ appTitle={activeLabel}
382
+ />
383
+ )}
308
384
  {impersonating && !hideChrome && (
309
385
  <div className="h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4">
310
386
  <span>
@@ -324,9 +400,15 @@ export function AppChrome({
324
400
  {!hideChrome && (
325
401
  <nav
326
402
  aria-label="Apps"
327
- className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1"
403
+ className={[
404
+ "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center gap-1",
405
+ // Under a custom header the rail starts with the app icons; without one its own icon is flush with the top of the window.
406
+ customHeader ? "py-3" : "pb-3",
407
+ ].join(" ")}
328
408
  >
329
- <img src={iconSrc} width="96" height="96" alt="" className="mb-3" />
409
+ {!customHeader && (
410
+ <RailIcon src={iconSrc} />
411
+ )}
330
412
  {apps.map(({ id, href, label, icon: Icon }) => (
331
413
  <a
332
414
  key={id}
@@ -348,23 +430,14 @@ export function AppChrome({
348
430
  )}
349
431
  {!hideChrome && <BottomTabBar apps={apps} active={active} />}
350
432
  <div className="flex-1 flex flex-col min-w-0">
351
- {!hideChrome && (
352
- <header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
433
+ {!hideChrome && !customHeader && (
434
+ <header ref={headerRef} className="rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
353
435
  <span className="font-display font-bold text-lg uppercase tracking-wide">{activeLabel}</span>
354
- <UserMenu
355
- userUid={userUid}
356
- authServerUrl={authServerUrl}
357
- onSignOut={handleSignOut}
358
- // An impersonating administrator acts as the impersonated user, so the console link is hidden then.
359
- showAdminLink={!!trusted && !impersonating}
360
- detectAdmin={!trusted && !impersonating}
361
- trustedRoles={trustedRoles}
362
- showSettingsLink
363
- showNotificationSettings
364
- onShowShortcuts={() => setShortcutsOpen(true)}
365
- />
436
+ {renderUserMenu("down")}
366
437
  </header>
367
438
  )}
439
+ {/* The one pop-up stack for the whole app: right under the header row, so it never covers the account menu. */}
440
+ <NotificationCenter />
368
441
  <div
369
442
  ref={contentRef}
370
443
  id="app-content"
@@ -381,21 +454,14 @@ export function AppChrome({
381
454
  {announcement}
382
455
  </div>
383
456
  )}
384
- {!hideChrome && <BrandingFooter branding={branding} />}
457
+ {!hideChrome && <FrameBrandingFooter parsed={footer} userMenu={menuInFooter ? renderUserMenu("up") : undefined} appTitle={activeLabel} />}
385
458
  </div>
386
459
  </ComposeProvider>
387
460
  </UnlockPromptProvider>
388
- {inFrame && (
389
- <NewMailToasts
390
- toasts={mail.notifications.toasts}
391
- onDismiss={mail.notifications.dismiss}
392
- offerDesktop={mail.notifications.offerDesktop}
393
- onEnableDesktop={() => void mail.notifications.enableDesktop()}
394
- onDeclineDesktop={mail.notifications.declineDesktop}
395
- />
396
- )}
461
+ <NotificationHistoryDialog open={historyOpen} onClose={() => setHistoryOpen(false)} />
397
462
  </MailConnectionContext.Provider>
398
463
  </ShortcutProvider>
464
+ </AppearanceProvider>
399
465
  );
400
466
  }
401
467
 
@@ -2,9 +2,11 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React from "react";
5
+ import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
6
+ import { createPortal } from "react-dom";
6
7
  import DOMPurify from "dompurify";
7
- import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
8
+ import type { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
9
+ import { useHeaderHeightRef } from "../../notifications/headerOffset.js";
8
10
 
9
11
  /** Elements that could run script, restyle the whole page, collect input, or embed another page - stripped on
10
12
  * top of DOMPurify's defaults (which already drop `<script>`, `on*` handlers, and `javascript:` URLs). */
@@ -30,6 +32,19 @@ const FORBIDDEN_TAGS = [
30
32
  ];
31
33
  const FORBIDDEN_ATTRS = ["action", "formaction", "srcdoc"];
32
34
 
35
+ /** The attribute that marks the elements React fills in (`{USER_MENU}`, `{APP_TITLE}`). Stripped from the author's own HTML, so only
36
+ * this module's placeholders can carry it. */
37
+ export const SLOT_ATTRIBUTE = "data-rr-slot";
38
+
39
+ /** The variables a branding header or footer may contain, written in the HTML's text. */
40
+ export const USER_MENU_VARIABLE = "{USER_MENU}";
41
+ export const APP_TITLE_VARIABLE = "{APP_TITLE}";
42
+
43
+ const VARIABLE = /\{(USER_MENU|APP_TITLE)\}/g;
44
+
45
+ /** Elements whose text is not markup a placeholder element could live in (raw text and RCDATA), and the ones a menu must not sit inside. */
46
+ const RAW_TEXT_PARENTS = new Set(["TITLE", "TEXTAREA", "OPTION", "STYLE", "SCRIPT", "NOSCRIPT", "XMP", "IFRAME", "NOEMBED", "NOFRAMES", "PLAINTEXT"]);
47
+
33
48
  /**
34
49
  * Sanitizes admin-configured branding HTML before it's rendered. The server sanitizes on save too; this also
35
50
  * covers anything stored before that. Returns `""` where there's no DOM to sanitize with (server-side
@@ -42,23 +57,195 @@ export function sanitizeBrandingHtml(html: string): string {
42
57
  return DOMPurify.sanitize(html, { FORBID_TAGS: FORBIDDEN_TAGS, FORBID_ATTR: FORBIDDEN_ATTRS });
43
58
  }
44
59
 
45
- function BrandingHtml({ html }: { html: string | undefined }) {
46
- const clean = html ? sanitizeBrandingHtml(html) : "";
47
- if (!clean) {
60
+ /** Branding HTML ready to render: sanitized, with its variables replaced by placeholder elements for React to fill. */
61
+ export interface ParsedBrandingHtml {
62
+ html: string;
63
+ /** It contains a `{USER_MENU}` where the account menu can be rendered. */
64
+ hasUserMenu: boolean;
65
+ }
66
+
67
+ function slotElement(name: "user-menu" | "app-title"): HTMLSpanElement {
68
+ const span = document.createElement("span");
69
+ span.setAttribute(SLOT_ATTRIBUTE, name);
70
+ return span;
71
+ }
72
+
73
+ /**
74
+ * Sanitizes `html` (the same rules as `sanitizeBrandingHtml()`) and swaps the variables in its **text** for placeholder elements:
75
+ * `{USER_MENU}` becomes the slot the account menu is rendered into, `{APP_TITLE}` a slot holding the current app's name.
76
+ *
77
+ * - Only text is searched, after sanitizing - a variable in an attribute (`title="{USER_MENU}"`), a comment, or a `<script>` (which
78
+ * is gone) is never touched, and the author cannot write a placeholder themselves (`data-rr-slot` is stripped).
79
+ * - `{USER_MENU}` is replaced **once**, at its first place in the document; a second (or third) one is removed, so there is never
80
+ * more than one account menu (two would give two sets of duplicate ids and focus targets). One inside a link, or in text that
81
+ * can't hold an element (`<title>`, `<textarea>`), is removed too: a menu inside an `<a>` would navigate when clicked.
82
+ * - `{APP_TITLE}` is replaced everywhere it appears. Inside `<title>`-like raw text it stays as written.
83
+ * - Nothing is matched loosely: the names are upper-case, in braces, with no spaces.
84
+ *
85
+ * `null` when there is nothing to show (no DOM to sanitize with - server-side rendering - or the HTML is empty once sanitized).
86
+ */
87
+ export function parseBrandingHtml(html: string): ParsedBrandingHtml | null {
88
+ if (!DOMPurify.isSupported) {
89
+ return null;
90
+ }
91
+ const fragment = DOMPurify.sanitize(html, {
92
+ FORBID_TAGS: FORBIDDEN_TAGS,
93
+ FORBID_ATTR: [...FORBIDDEN_ATTRS, SLOT_ATTRIBUTE],
94
+ RETURN_DOM_FRAGMENT: true,
95
+ });
96
+ const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_TEXT);
97
+ const texts: Text[] = [];
98
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
99
+ texts.push(node as Text);
100
+ }
101
+ let hasUserMenu = false;
102
+ for (const text of texts) {
103
+ const parent = text.parentElement;
104
+ if (parent && RAW_TEXT_PARENTS.has(parent.tagName)) {
105
+ continue;
106
+ }
107
+ const inLink = !!parent?.closest("a");
108
+ // `split` with a capturing group alternates text and variable names: [text, name, text, name, ..., text].
109
+ const pieces = text.data.split(VARIABLE);
110
+ if (pieces.length === 1) {
111
+ continue;
112
+ }
113
+ const replacement = document.createDocumentFragment();
114
+ pieces.forEach((piece, index) => {
115
+ if (index % 2 === 0) {
116
+ if (piece) {
117
+ replacement.appendChild(document.createTextNode(piece));
118
+ }
119
+ } else if (piece === "APP_TITLE") {
120
+ replacement.appendChild(slotElement("app-title"));
121
+ } else if (!hasUserMenu && !inLink) {
122
+ hasUserMenu = true;
123
+ replacement.appendChild(slotElement("user-menu"));
124
+ }
125
+ });
126
+ text.replaceWith(replacement);
127
+ }
128
+ const holder = document.createElement("div");
129
+ holder.appendChild(fragment);
130
+ const result = holder.innerHTML;
131
+ return result.trim() ? { html: result, hasUserMenu } : null;
132
+ }
133
+
134
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
135
+
136
+ /**
137
+ * `parseBrandingHtml()` of `html`, done once the component is on the page (never while rendering: the server has no DOM to sanitize
138
+ * with, and rendering different HTML there than the hydrating client would leave the page without it). `null` when there is no HTML,
139
+ * or nothing is left of it; `undefined` while it has not been parsed yet - the frame reserves the header's place meanwhile.
140
+ */
141
+ export function useBrandingHtml(html: string | undefined): ParsedBrandingHtml | null | undefined {
142
+ const source = html?.trim() ? html : undefined;
143
+ const [parsed, setParsed] = useState<{ source: string; value: ParsedBrandingHtml | null } | undefined>(undefined);
144
+ useIsomorphicLayoutEffect(() => {
145
+ setParsed(source === undefined ? undefined : { source, value: parseBrandingHtml(source) });
146
+ }, [source]);
147
+ if (source === undefined) {
148
+ return null;
149
+ }
150
+ return parsed?.source === source ? parsed.value : undefined;
151
+ }
152
+
153
+ interface BrandingHtmlProps {
154
+ parsed: ParsedBrandingHtml | null | undefined;
155
+ /** Rendered into the `{USER_MENU}` slot - only where `hostMenu` says this HTML is where the menu lives. */
156
+ userMenu?: ReactNode;
157
+ hostMenu?: boolean;
158
+ /** The current app's name, rendered into every `{APP_TITLE}` slot. */
159
+ appTitle?: string;
160
+ className?: string;
161
+ }
162
+
163
+ function BrandingHtml({ parsed, userMenu, hostMenu, appTitle, className }: BrandingHtmlProps) {
164
+ const ref = useRef<HTMLDivElement>(null);
165
+ const [targets, setTargets] = useState<{ menu: Element | null; titles: Element[] }>({ menu: null, titles: [] });
166
+ const html = parsed?.html ?? "";
167
+ // The placeholders exist once React has set the HTML: find them here, before the browser paints, and portal into them.
168
+ useIsomorphicLayoutEffect(() => {
169
+ const root = ref.current;
170
+ setTargets({
171
+ menu: root?.querySelector(`[${SLOT_ATTRIBUTE}="user-menu"]`) ?? null,
172
+ titles: root ? Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTE}="app-title"]`)) : [],
173
+ });
174
+ }, [html]);
175
+ if (parsed === null) {
176
+ return null;
177
+ }
178
+ return (
179
+ <>
180
+ <div ref={ref} className={className} dangerouslySetInnerHTML={{ __html: html }} />
181
+ {hostMenu && targets.menu && createPortal(userMenu, targets.menu)}
182
+ {targets.titles.map((element, index) => createPortal(appTitle, element, `app-title-${index}`))}
183
+ </>
184
+ );
185
+ }
186
+
187
+ export interface FrameBrandingHeaderProps {
188
+ /** `useBrandingHtml(branding.headerHtml)`. `null` renders nothing. */
189
+ parsed: ParsedBrandingHtml | null | undefined;
190
+ /** The account menu, rendered into the header's `{USER_MENU}` (when it has one). */
191
+ userMenu?: ReactNode;
192
+ /** Rendered at the right end of the header when it has no `{USER_MENU}` and the footer doesn't host the menu either - the account menu is never lost. */
193
+ fallbackMenu?: ReactNode;
194
+ appTitle?: string;
195
+ }
196
+
197
+ /**
198
+ * The frame's version of the admin-configured `Branding.headerHtml` (see `useBranding()`), sanitized, in a `<header>` that stays at the top of the window as the
199
+ * page scrolls. When it is there the app has no title bar of its own (`AppChrome`): the header is the top of the app, and the account
200
+ * menu lives in it - where the HTML writes `{USER_MENU}`, else in a small cell at its right end. The webmail chrome (`AppShell`) shows
201
+ * it; the admin console has no custom header (its header is the console's own) and the escrow console renders no branding HTML at all.
202
+ */
203
+ export function FrameBrandingHeader({ parsed, userMenu, fallbackMenu, appTitle }: FrameBrandingHeaderProps) {
204
+ // Publishes the header's height (`--rr-header-h`) for the pop-up stack, which sticks just below it.
205
+ const headerRef = useHeaderHeightRef();
206
+ if (parsed === null) {
48
207
  return null;
49
208
  }
50
- return <div dangerouslySetInnerHTML={{ __html: clean }} />;
209
+ return (
210
+ <header ref={headerRef} className="sticky top-0 z-30 shrink-0 flex items-stretch bg-surface-alt" data-branding-header="">
211
+ <BrandingHtml parsed={parsed} userMenu={userMenu} hostMenu={parsed?.hasUserMenu} appTitle={appTitle} className="flex-1 min-w-0" />
212
+ {parsed && !parsed.hasUserMenu && fallbackMenu ? (
213
+ <div className="shrink-0 flex items-center px-3 bg-surface border-l border-border" data-branding-menu-cell="">
214
+ {fallbackMenu}
215
+ </div>
216
+ ) : null}
217
+ </header>
218
+ );
219
+ }
220
+
221
+ export interface FrameBrandingFooterProps {
222
+ /** `useBrandingHtml(branding.footerHtml)`. */
223
+ parsed: ParsedBrandingHtml | null | undefined;
224
+ /** Rendered into the footer's `{USER_MENU}` - passed only when the header has none. */
225
+ userMenu?: ReactNode;
226
+ appTitle?: string;
227
+ }
228
+
229
+ /** The frame's version of the admin-configured `Branding.footerHtml`, sanitized. Its variables work as in the header; a `{USER_MENU}` here only takes the menu when the header has none. */
230
+ export function FrameBrandingFooter({ parsed, userMenu, appTitle }: FrameBrandingFooterProps) {
231
+ return <BrandingHtml parsed={parsed} userMenu={userMenu} hostMenu={userMenu !== undefined} appTitle={appTitle} />;
232
+ }
233
+
234
+ export interface BrandingChromeProps {
235
+ branding: Branding | null;
236
+ /** What `{APP_TITLE}` is replaced with - the page's own name. Nothing when absent. */
237
+ appTitle?: string;
51
238
  }
52
239
 
53
240
  /**
54
- * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized. The webmail
55
- * chrome (`AppShell`) shows both; the admin console shows only the footer - its header is the console's own - and the
56
- * escrow console deliberately renders no branding HTML at all.
241
+ * The admin-configured `Branding.headerHtml`, sanitized, for pages that don't use a shell, such as public pages (the booking pages). There is no
242
+ * account menu there, so a `{USER_MENU}` in the HTML renders nothing. Renders nothing without branding or HTML.
57
243
  */
58
- export function BrandingHeader({ branding }: { branding: Branding | null }) {
59
- return <BrandingHtml html={branding?.headerHtml} />;
244
+ export function BrandingHeader({ branding, appTitle }: BrandingChromeProps) {
245
+ return <BrandingHtml parsed={useBrandingHtml(branding?.headerHtml)} appTitle={appTitle} />;
60
246
  }
61
247
 
62
- export function BrandingFooter({ branding }: { branding: Branding | null }) {
63
- return <BrandingHtml html={branding?.footerHtml} />;
248
+ /** The admin-configured `Branding.footerHtml`, sanitized, for pages that don't use a shell - see `BrandingHeader`. */
249
+ export function BrandingFooter({ branding, appTitle }: BrandingChromeProps) {
250
+ return <BrandingHtml parsed={useBrandingHtml(branding?.footerHtml)} appTitle={appTitle} />;
64
251
  }
@@ -0,0 +1,100 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useState } from "react";
6
+
7
+ /** The side, in CSS pixels, of the square the icon is fitted into and measured in: the rail is 4rem wide and the title bar 4rem tall, each less its 1px border. */
8
+ const SIZE = 63;
9
+
10
+ /** Pixels this transparent or less count as empty. */
11
+ const EMPTY_ALPHA = 24;
12
+
13
+ /** The number of rows at the top of `size` x `size` RGBA pixel data that are empty - the icon's own margin above its first visible pixel. */
14
+ export function emptyRowsAtTop(rgba: ArrayLike<number>, size: number): number {
15
+ for (let y = 0; y < size; y++) {
16
+ for (let x = 0; x < size; x++) {
17
+ if (rgba[(y * size + x) * 4 + 3] > EMPTY_ALPHA) {
18
+ return y;
19
+ }
20
+ }
21
+ }
22
+ return 0;
23
+ }
24
+
25
+ /** Whether this environment can decode an image and read it back - not a server, and not jsdom, where there is nothing to measure. */
26
+ export function canMeasureImages(): boolean {
27
+ try {
28
+ return typeof new Image().decode === "function";
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * How many pixels of empty margin an icon has above its first visible pixel when fitted (`object-fit: contain`) into a `SIZE` square.
36
+ * Drawn on a canvas and read back, so it works for any picture the browser can decode from the same origin (or with CORS); `0` where
37
+ * that isn't possible (no canvas, a picture that won't load, one from another origin that taints the canvas).
38
+ */
39
+ export async function measureTopMargin(src: string): Promise<number> {
40
+ try {
41
+ const image = new Image();
42
+ image.src = src;
43
+ await image.decode();
44
+ const canvas = document.createElement("canvas");
45
+ canvas.width = SIZE;
46
+ canvas.height = SIZE;
47
+ const context = canvas.getContext("2d", { willReadFrequently: true });
48
+ if (!context || !image.naturalWidth || !image.naturalHeight) {
49
+ return 0;
50
+ }
51
+ const scale = Math.min(SIZE / image.naturalWidth, SIZE / image.naturalHeight);
52
+ const width = image.naturalWidth * scale;
53
+ const height = image.naturalHeight * scale;
54
+ context.drawImage(image, (SIZE - width) / 2, (SIZE - height) / 2, width, height);
55
+ return emptyRowsAtTop(context.getImageData(0, 0, SIZE, SIZE).data, SIZE);
56
+ } catch {
57
+ return 0;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The deployment's icon at the top of an app rail (`AppShell`, `AdminShell`, `EscrowShell`): a block as wide as the rail and as tall as the
63
+ * title bar beside it (4rem), flush with the top and left edges of the window - no padding above it - with the bar's own bottom border
64
+ * continued under it, so the two read as one block. The picture is fitted inside the block, never cropped, and raised by whatever empty
65
+ * margin the file itself has above its artwork, so the first visible pixel is at the very top of the window (a logo exported with 6% of
66
+ * air around it would otherwise still leave a gap). The icon is shown once measured (a few milliseconds after it loads).
67
+ */
68
+ export default function RailIcon({ src }: { src: string }) {
69
+ const [margin, setMargin] = useState<number | null>(null);
70
+ useEffect(() => {
71
+ let cancelled = false;
72
+ if (!canMeasureImages()) {
73
+ // Nothing to measure with: show the icon as it is (in the same effect pass, so nothing waits and nothing updates later).
74
+ setMargin(0);
75
+ return;
76
+ }
77
+ setMargin(null);
78
+ void measureTopMargin(src).then((measured) => {
79
+ if (!cancelled) {
80
+ setMargin(measured);
81
+ }
82
+ });
83
+ return () => {
84
+ cancelled = true;
85
+ };
86
+ }, [src]);
87
+
88
+ return (
89
+ <div className="h-16 w-full shrink-0 overflow-hidden border-b border-border mb-2" data-rail-icon="">
90
+ <img
91
+ src={src}
92
+ width="64"
93
+ height="64"
94
+ alt=""
95
+ className="block h-full w-full object-contain"
96
+ style={{ transform: margin ? `translateY(-${margin}px)` : undefined, opacity: margin === null ? 0 : 1 }}
97
+ />
98
+ </div>
99
+ );
100
+ }