nexus-shared 3.2.0 → 4.1.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 (271) hide show
  1. package/CHANGELOG.md +2359 -0
  2. package/README.md +1911 -1123
  3. package/dist/Client.Index.d.ts +27 -0
  4. package/dist/Client.Index.js +27 -0
  5. package/dist/Components/Chats/ChatComposer.d.ts +7 -1
  6. package/dist/Components/Chats/ChatComposer.js +9 -6
  7. package/dist/Components/Chats/ChatParts.d.ts +15 -2
  8. package/dist/Components/Chats/ChatParts.js +15 -4
  9. package/dist/Components/Chats/ChatPopup.d.ts +46 -0
  10. package/dist/Components/Chats/ChatPopup.js +34 -0
  11. package/dist/Components/Chats/ChatThread.d.ts +9 -1
  12. package/dist/Components/Chats/ChatThread.js +54 -6
  13. package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
  14. package/dist/Components/Dashboards/DashboardBoard.js +594 -0
  15. package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
  16. package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
  17. package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
  18. package/dist/Components/Dashboards/WidgetFrame.js +45 -0
  19. package/dist/Components/Documents/Button.d.ts +6 -1
  20. package/dist/Components/Documents/Button.js +2 -2
  21. package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
  22. package/dist/Components/Documents/DocumentFrame.js +83 -0
  23. package/dist/Components/Documents/Menu.d.ts +4 -0
  24. package/dist/Components/Documents/Menu.js +40 -24
  25. package/dist/Components/Documents/PrintDocument.d.ts +95 -0
  26. package/dist/Components/Documents/PrintDocument.js +62 -0
  27. package/dist/Components/Documents/TabButtons.d.ts +6 -0
  28. package/dist/Components/Documents/TabButtons.js +5 -4
  29. package/dist/Components/Documents/Tabs.d.ts +59 -0
  30. package/dist/Components/Documents/Tabs.js +70 -0
  31. package/dist/Components/Domains/DomainCard.d.ts +98 -0
  32. package/dist/Components/Domains/DomainCard.js +182 -0
  33. package/dist/Components/Forms/ApiForm.js +24 -3
  34. package/dist/Components/Forms/Crud.d.ts +2 -2
  35. package/dist/Components/Forms/Crud.js +628 -247
  36. package/dist/Components/Forms/CrudActions.d.ts +137 -0
  37. package/dist/Components/Forms/CrudActions.js +214 -0
  38. package/dist/Components/Forms/CrudHistory.d.ts +6 -1
  39. package/dist/Components/Forms/CrudHistory.js +14 -6
  40. package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
  41. package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
  42. package/dist/Components/Forms/CrudParts.d.ts +37 -2
  43. package/dist/Components/Forms/CrudParts.js +123 -7
  44. package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
  45. package/dist/Components/Forms/CrudRowForms.js +174 -0
  46. package/dist/Components/Forms/CrudScopes.d.ts +28 -0
  47. package/dist/Components/Forms/CrudScopes.js +76 -0
  48. package/dist/Components/Forms/CrudTree.d.ts +90 -0
  49. package/dist/Components/Forms/CrudTree.js +363 -0
  50. package/dist/Components/Forms/CrudWorkspace.d.ts +73 -0
  51. package/dist/Components/Forms/CrudWorkspace.js +177 -0
  52. package/dist/Components/Forms/ExcelImport.js +3 -3
  53. package/dist/Components/Forms/Form.d.ts +6 -5
  54. package/dist/Components/Forms/Form.js +134 -21
  55. package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
  56. package/dist/Components/Forms/FormLeaveGuard.js +65 -0
  57. package/dist/Components/Forms/FormQuestion.d.ts +6 -0
  58. package/dist/Components/Forms/FormQuestion.js +28 -0
  59. package/dist/Components/Forms/RecordFlows.d.ts +50 -0
  60. package/dist/Components/Forms/RecordFlows.js +282 -0
  61. package/dist/Components/Forms/SubmitForm.d.ts +37 -2
  62. package/dist/Components/Forms/SubmitForm.js +182 -10
  63. package/dist/Components/Inputs/CheckBox.js +1 -1
  64. package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
  65. package/dist/Components/Inputs/ChoiceField.js +28 -2
  66. package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
  67. package/dist/Components/Inputs/ComputedInput.js +179 -0
  68. package/dist/Components/Inputs/Dropdown.d.ts +13 -0
  69. package/dist/Components/Inputs/Dropdown.js +8 -6
  70. package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
  71. package/dist/Components/Inputs/DropdownParts.js +16 -4
  72. package/dist/Components/Inputs/Field.d.ts +7 -1
  73. package/dist/Components/Inputs/Field.js +2 -2
  74. package/dist/Components/Inputs/GroupForm.d.ts +6 -6
  75. package/dist/Components/Inputs/GroupForm.js +12 -12
  76. package/dist/Components/Inputs/InputRenderer.d.ts +13 -6
  77. package/dist/Components/Inputs/InputRenderer.js +37 -32
  78. package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
  79. package/dist/Components/Inputs/MarkdownEditor.js +32 -6
  80. package/dist/Components/Inputs/PictureInput.d.ts +41 -0
  81. package/dist/Components/Inputs/PictureInput.js +75 -0
  82. package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
  83. package/dist/Components/Inputs/RadioGroup.js +8 -3
  84. package/dist/Components/Inputs/RowsInput.d.ts +15 -2
  85. package/dist/Components/Inputs/RowsInput.js +96 -8
  86. package/dist/Components/Inputs/TabularForm.d.ts +5 -5
  87. package/dist/Components/Inputs/TabularForm.js +45 -32
  88. package/dist/Components/Inputs/TextArea.d.ts +6 -0
  89. package/dist/Components/Inputs/TextArea.js +11 -2
  90. package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
  91. package/dist/Components/Inputs/TextBoxTypes.js +17 -0
  92. package/dist/Components/Inputs/TreeView.d.ts +76 -2
  93. package/dist/Components/Inputs/TreeView.js +227 -16
  94. package/dist/Components/Layouts/MessageBox.d.ts +15 -2
  95. package/dist/Components/Layouts/MessageBox.js +56 -7
  96. package/dist/Components/Layouts/Popup.d.ts +34 -7
  97. package/dist/Components/Layouts/Popup.js +143 -13
  98. package/dist/Components/Layouts/PopupDock.d.ts +3 -1
  99. package/dist/Components/Layouts/PopupDock.js +2 -1
  100. package/dist/Components/Layouts/PopupHost.js +5 -4
  101. package/dist/Components/Messages/MailClient.d.ts +50 -0
  102. package/dist/Components/Messages/MailClient.js +410 -0
  103. package/dist/Components/Messages/MailColumns.d.ts +21 -0
  104. package/dist/Components/Messages/MailColumns.js +92 -0
  105. package/dist/Components/Messages/MailCompose.d.ts +46 -0
  106. package/dist/Components/Messages/MailCompose.js +154 -0
  107. package/dist/Components/Messages/MailReader.d.ts +43 -0
  108. package/dist/Components/Messages/MailReader.js +207 -0
  109. package/dist/Components/Messages/MailThread.d.ts +71 -0
  110. package/dist/Components/Messages/MailThread.js +185 -0
  111. package/dist/Components/Messages/MessageBell.d.ts +41 -0
  112. package/dist/Components/Messages/MessageBell.js +76 -0
  113. package/dist/Components/Viewers/DataTable.js +153 -49
  114. package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
  115. package/dist/Components/Viewers/DataTableColumns.js +2 -1
  116. package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
  117. package/dist/Components/Viewers/DataTableFilters.js +17 -2
  118. package/dist/Components/Viewers/DataTableParts.d.ts +23 -5
  119. package/dist/Components/Viewers/DataTableParts.js +26 -14
  120. package/dist/Components/Viewers/OwnerFiles.d.ts +227 -0
  121. package/dist/Components/Viewers/OwnerFiles.js +700 -0
  122. package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
  123. package/dist/Components/Viewers/QrCodeImage.js +15 -0
  124. package/dist/Components/Viewers/RecordCards.d.ts +16 -0
  125. package/dist/Components/Viewers/RecordCards.js +509 -0
  126. package/dist/Helpers/ApiModules.js +4 -0
  127. package/dist/Helpers/AuditEntries.d.ts +50 -0
  128. package/dist/Helpers/AuditEntries.js +96 -0
  129. package/dist/Helpers/BusyHooks.d.ts +6 -0
  130. package/dist/Helpers/BusyHooks.js +39 -0
  131. package/dist/Helpers/ChatBackend.d.ts +8 -1
  132. package/dist/Helpers/ChatBackend.js +18 -0
  133. package/dist/Helpers/ChatHelpers.d.ts +2 -0
  134. package/dist/Helpers/ChatHelpers.js +4 -1
  135. package/dist/Helpers/CrudBackend.d.ts +20 -2
  136. package/dist/Helpers/CrudBackend.js +46 -2
  137. package/dist/Helpers/CrudColumns.d.ts +91 -6
  138. package/dist/Helpers/CrudColumns.js +148 -7
  139. package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
  140. package/dist/Helpers/CrudDetailsTabs.js +126 -0
  141. package/dist/Helpers/CrudHelpers.d.ts +116 -2
  142. package/dist/Helpers/CrudHelpers.js +256 -0
  143. package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
  144. package/dist/Helpers/CrudNarrowPick.js +157 -0
  145. package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
  146. package/dist/Helpers/CrudPickerPlan.js +46 -0
  147. package/dist/Helpers/CrudRowForms.d.ts +32 -0
  148. package/dist/Helpers/CrudRowForms.js +76 -0
  149. package/dist/Helpers/CrudScopes.d.ts +32 -0
  150. package/dist/Helpers/CrudScopes.js +79 -0
  151. package/dist/Helpers/DashboardHelpers.d.ts +115 -0
  152. package/dist/Helpers/DashboardHelpers.js +305 -0
  153. package/dist/Helpers/DomainCards.d.ts +140 -0
  154. package/dist/Helpers/DomainCards.js +370 -0
  155. package/dist/Helpers/DropdownHelpers.d.ts +3 -1
  156. package/dist/Helpers/DropdownHelpers.js +6 -2
  157. package/dist/Helpers/ExcelHelpers.d.ts +1 -1
  158. package/dist/Helpers/ExcelHelpers.js +12 -11
  159. package/dist/Helpers/FileAdapter.d.ts +158 -0
  160. package/dist/Helpers/FileAdapter.js +173 -0
  161. package/dist/Helpers/FilePrimaries.d.ts +48 -0
  162. package/dist/Helpers/FilePrimaries.js +138 -0
  163. package/dist/Helpers/FormConfirm.d.ts +32 -0
  164. package/dist/Helpers/FormConfirm.js +40 -0
  165. package/dist/Helpers/FormStore.d.ts +100 -5
  166. package/dist/Helpers/FormStore.js +368 -25
  167. package/dist/Helpers/InputConditions.d.ts +56 -0
  168. package/dist/Helpers/InputConditions.js +192 -0
  169. package/dist/Helpers/InputMemory.d.ts +106 -0
  170. package/dist/Helpers/InputMemory.js +395 -0
  171. package/dist/Helpers/InputNarrowing.d.ts +50 -0
  172. package/dist/Helpers/InputNarrowing.js +148 -0
  173. package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
  174. package/dist/Helpers/InputParamsHelpers.js +157 -74
  175. package/dist/Helpers/MailClientHelpers.d.ts +96 -0
  176. package/dist/Helpers/MailClientHelpers.js +211 -0
  177. package/dist/Helpers/MessageBackend.d.ts +152 -0
  178. package/dist/Helpers/MessageBackend.js +329 -0
  179. package/dist/Helpers/MessageHelpers.d.ts +160 -0
  180. package/dist/Helpers/MessageHelpers.js +573 -0
  181. package/dist/Helpers/MessageUnread.d.ts +95 -0
  182. package/dist/Helpers/MessageUnread.js +245 -0
  183. package/dist/Helpers/NexusDriveAdapter.d.ts +71 -0
  184. package/dist/Helpers/NexusDriveAdapter.js +236 -0
  185. package/dist/Helpers/NumberHelpers.d.ts +156 -0
  186. package/dist/Helpers/NumberHelpers.js +242 -0
  187. package/dist/Helpers/PagedPicker.d.ts +42 -0
  188. package/dist/Helpers/PagedPicker.js +85 -0
  189. package/dist/Helpers/Permissions.js +2 -1
  190. package/dist/Helpers/QrCode.d.ts +26 -0
  191. package/dist/Helpers/QrCode.js +463 -0
  192. package/dist/Helpers/RecordCards.d.ts +205 -0
  193. package/dist/Helpers/RecordCards.js +378 -0
  194. package/dist/Helpers/RequestCache.d.ts +14 -2
  195. package/dist/Helpers/RequestCache.js +22 -3
  196. package/dist/Helpers/RowsStore.d.ts +61 -2
  197. package/dist/Helpers/RowsStore.js +203 -16
  198. package/dist/Helpers/ServerPreview.d.ts +69 -0
  199. package/dist/Helpers/ServerPreview.js +99 -0
  200. package/dist/Helpers/TableColumns.js +4 -3
  201. package/dist/Helpers/TableExport.d.ts +7 -1
  202. package/dist/Helpers/TableExport.js +9 -3
  203. package/dist/Helpers/TableFilters.d.ts +1 -1
  204. package/dist/Helpers/TableFilters.js +24 -23
  205. package/dist/Helpers/TableHelpers.d.ts +38 -4
  206. package/dist/Helpers/TableHelpers.js +78 -10
  207. package/dist/Helpers/TableJoins.d.ts +13 -0
  208. package/dist/Helpers/TableJoins.js +34 -0
  209. package/dist/Helpers/TableStore.d.ts +28 -4
  210. package/dist/Helpers/TableStore.js +73 -11
  211. package/dist/Helpers/ToolbarFit.d.ts +23 -0
  212. package/dist/Helpers/ToolbarFit.js +30 -0
  213. package/dist/Helpers/TreeStore.d.ts +156 -6
  214. package/dist/Helpers/TreeStore.js +547 -18
  215. package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
  216. package/dist/Interfaces/ApiInterfaces.js +10 -1
  217. package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
  218. package/dist/Interfaces/CrudInterfaces.d.ts +539 -6
  219. package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
  220. package/dist/Interfaces/DashboardInterfaces.js +1 -0
  221. package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
  222. package/dist/Interfaces/DomainInterfaces.js +1 -0
  223. package/dist/Interfaces/FileInterfaces.d.ts +183 -0
  224. package/dist/Interfaces/FileInterfaces.js +6 -0
  225. package/dist/Interfaces/FormInterfaces.d.ts +460 -32
  226. package/dist/Interfaces/FormInterfaces.js +79 -1
  227. package/dist/Interfaces/InputInterfaces.d.ts +22 -3
  228. package/dist/Interfaces/MailInterfaces.d.ts +192 -0
  229. package/dist/Interfaces/MailInterfaces.js +1 -0
  230. package/dist/Interfaces/RecordInterfaces.d.ts +367 -0
  231. package/dist/Interfaces/RecordInterfaces.js +1 -0
  232. package/dist/Interfaces/TableInterfaces.d.ts +102 -3
  233. package/dist/Services/ExcelApi.js +5 -0
  234. package/dist/Services/FileApi.d.ts +13 -0
  235. package/dist/Services/FileApi.js +29 -0
  236. package/dist/Services/ImageEditorLoader.d.ts +14 -0
  237. package/dist/Services/ImageEditorLoader.js +51 -0
  238. package/dist/Services/JoinSources.d.ts +79 -6
  239. package/dist/Services/JoinSources.js +132 -21
  240. package/dist/Services/MessageApi.d.ts +18 -0
  241. package/dist/Services/MessageApi.js +64 -0
  242. package/dist/Services/ModuleImport.d.ts +7 -0
  243. package/dist/Services/ModuleImport.js +22 -0
  244. package/dist/Shared.Index.d.ts +28 -0
  245. package/dist/Shared.Index.js +28 -0
  246. package/package.json +13 -2
  247. package/src/Styles/Nexus.Avatar.css +6 -5
  248. package/src/Styles/Nexus.Button.css +39 -4
  249. package/src/Styles/Nexus.Chat.css +114 -22
  250. package/src/Styles/Nexus.Choice.css +16 -0
  251. package/src/Styles/Nexus.Crud.css +486 -21
  252. package/src/Styles/Nexus.Dashboard.css +763 -0
  253. package/src/Styles/Nexus.Domain.css +384 -0
  254. package/src/Styles/Nexus.Dropdown.css +8 -0
  255. package/src/Styles/Nexus.Editor.css +20 -0
  256. package/src/Styles/Nexus.Files.css +437 -0
  257. package/src/Styles/Nexus.Form.css +5 -2
  258. package/src/Styles/Nexus.Index.css +7 -0
  259. package/src/Styles/Nexus.Input.css +73 -0
  260. package/src/Styles/Nexus.Mail.css +1062 -0
  261. package/src/Styles/Nexus.Menu.css +10 -1
  262. package/src/Styles/Nexus.Popup.css +135 -1
  263. package/src/Styles/Nexus.Print.css +288 -0
  264. package/src/Styles/Nexus.QrCode.css +12 -0
  265. package/src/Styles/Nexus.Record.css +430 -0
  266. package/src/Styles/Nexus.Tab.Buttons.css +87 -22
  267. package/src/Styles/Nexus.Table.css +1458 -1387
  268. package/src/Styles/Nexus.Theme.Picker.css +1 -0
  269. package/src/Styles/Nexus.Themes.css +12 -6
  270. package/src/Styles/Nexus.Tokens.css +8 -3
  271. package/src/Styles/Nexus.Tree.css +41 -1
@@ -0,0 +1,573 @@
1
+ import { ApiError } from "./ApiResponses.js";
2
+ import { sameChatId } from "./ChatHelpers.js";
3
+ import { nowUtcDateTime } from "./DateTimeHelpers.js";
4
+ import { markdownToPlainText } from "./MarkdownHelpers.js";
5
+ import { holdPickerItems, pagedPicker } from "./PagedPicker.js";
6
+ /*
7
+ * The words and small rules a mail thread is drawn with - a role's name, a list of people folded to three names and a
8
+ * count, the note a person copied unseen reads before replying - and a mailbox kept in memory (`localMessageSource`)
9
+ * that keeps the rules a backend keeps: who sees whom, a reply that shows a blind copy, archive per person, unread per
10
+ * person, one thread per retried send.
11
+ */
12
+ /** A role's name on screen. */
13
+ export const MAIL_ROLE_LABELS = { from: "From", to: "To", cc: "Cc", bcc: "Bcc" };
14
+ /** A list's name on screen. */
15
+ export const MAIL_LIST_LABELS = { inbox: "Inbox", unread: "Unread", sent: "Sent", archived: "Archived" };
16
+ /** What a person copied unseen reads over the reply box: their reply makes them a Cc, seen by everyone. */
17
+ export const MAIL_BCC_NOTE = "You were sent a blind copy. Replying shows you to everyone.";
18
+ /** The most people on one thread together, the sender aside. */
19
+ export const MAIL_PEOPLE_MAX = 50;
20
+ /** The longest subject. */
21
+ export const MAIL_SUBJECT_MAX = 100;
22
+ /** The longest message. */
23
+ export const MAIL_BODY_MAX = 4000;
24
+ /**
25
+ * The most unread lines `unread()` counts: a backend counts to here and stops (the Message module's `Mail/Unread`), so
26
+ * a bell writes "99+" from 100 on and says "more than 99".
27
+ */
28
+ export const MAIL_UNREAD_MAX = 100;
29
+ /** Joins names as a sentence does: "A", "A and B", "A, B and C". */
30
+ function sentence(names) {
31
+ if (names.length <= 1)
32
+ return names[0] ?? "";
33
+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
34
+ }
35
+ /** Folds a list of people to its first `max` names and a count of the rest: a To line with 50 people stays one line. */
36
+ export function mailPeopleLine(people, max = 3) {
37
+ const limit = Math.max(1, max);
38
+ // Two more is "+2" and not one name fewer: a count stands for at least two people, never for one.
39
+ const shown = people.length <= limit + 1 ? people : people.slice(0, limit);
40
+ const more = people.length - shown.length;
41
+ const names = shown.map(person => person.name);
42
+ return { shown, more, text: more > 0 ? `${names.join(", ")} +${more}` : names.join(", "), full: sentence(people.map(person => person.name)) };
43
+ }
44
+ /** The note a reader of a thread sees over the reply box, or `null`: a person copied unseen who may reply. */
45
+ export function mailBccNote(head) {
46
+ return head && head.myRole === "bcc" && head.canSend ? MAIL_BCC_NOTE : null;
47
+ }
48
+ /** An id for a new message, made once per form so a retried send writes it once. */
49
+ export function newMailClientId() {
50
+ const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
51
+ return `mail-${random}`;
52
+ }
53
+ /**
54
+ * The people a new message can be sent to, as a dropdown's params: the source's search, paged by the server, every
55
+ * person named once seen. One for every To, Cc and Bcc box of a page.
56
+ *
57
+ * @example
58
+ * const people = mailPeoplePicker(mail);
59
+ * { type: EInputTypes.Dropdown, name: "to", label: "To", multiple: true, required: true, ...people }
60
+ */
61
+ export function mailPeoplePicker(source) {
62
+ return pagedPicker(request => source.people(request), { valueKey: "id", labelKey: "name", isInactive: person => person.isActive === false });
63
+ }
64
+ /**
65
+ * A New message form's opening values, from the source's defaults (the reader's supervisor in To, or the people they
66
+ * chose): a CRUD's `form.createDefaults`. The people are handed to the picker, so the boxes name them at once. Give the
67
+ * form a `clientId` of its own in `newValues` too: a form whose defaults could not be read opens on those alone.
68
+ *
69
+ * @example form={{ newValues: () => ({ clientId: newMailClientId() }), createDefaults: mailComposeDefaults(mail, people) }}
70
+ */
71
+ export function mailComposeDefaults(source, picker) {
72
+ return async (signal) => {
73
+ const defaults = await source.defaults(signal);
74
+ if (picker)
75
+ holdPickerItems(picker, [...defaults.to, ...defaults.cc, ...defaults.bcc]);
76
+ const ids = (people) => people.filter(person => person.isActive !== false).map(person => person.id);
77
+ return { to: ids(defaults.to), cc: ids(defaults.cc), bcc: ids(defaults.bcc), clientId: newMailClientId() };
78
+ };
79
+ }
80
+ /**
81
+ * A data table's loader over one list of a mailbox, paged by the source: `<DataTable paging="server" load={...} />`.
82
+ * `about` narrows it to the threads about one record.
83
+ */
84
+ export function mailInboxLoader(source, list, about) {
85
+ return (query, signal) => source.inbox({ list, search: query.search, skip: (query.page - 1) * query.pageSize, take: query.pageSize, signal, ...about });
86
+ }
87
+ /** A line of an inbox row's preview: the Markdown as text, on one line. */
88
+ export function mailPreviewText(preview) {
89
+ return markdownToPlainText(preview ?? "").replace(/\s+/g, " ").trim();
90
+ }
91
+ /** Bcc < Cc < To: a role only ever moves to a more visible one, and From never changes. */
92
+ const VISIBILITY = { bcc: 1, cc: 2, to: 3, from: 4 };
93
+ /** A refusal with the status a server would answer, so a page tells "you cannot open this" (404) from a failure. */
94
+ const refuse = (status, message) => new ApiError({ ok: false, status, message });
95
+ /**
96
+ * A `MessageSource` in memory: a whole mailbox without a backend, for a demo page or a test. It keeps a backend's
97
+ * rules: a person sees the people copied unseen only on a thread they sent, or their own row; a reply by a person
98
+ * copied unseen makes them a Cc; Archive is each person's own and a line by someone else brings the thread back; a line
99
+ * is unread for everyone but its author (a line the system wrote for nobody); a new message or a reply sent again with
100
+ * the same id is written once. `as(userId)` reads the same mailbox as somebody else.
101
+ *
102
+ * The lists: **Inbox** every thread the reader is on and has not archived, **Unread** those with unread lines,
103
+ * **Sent** the ones they started, **Archived** the ones they put aside; newest line first; the search looks in the
104
+ * subject and the record's words.
105
+ */
106
+ export function localMessageSource(options) {
107
+ const now = options.now ?? nowUtcDateTime;
108
+ const people = new Map(options.people.map(person => [String(person.id), person]));
109
+ const failing = new Set();
110
+ let threadSequence = 0;
111
+ let lineSequence = 0;
112
+ const wait = () => (options.delay ? new Promise(resolve => setTimeout(resolve, options.delay)) : Promise.resolve());
113
+ const fail = (part) => {
114
+ if (failing.has(part))
115
+ throw refuse(503, "The server did not answer.");
116
+ };
117
+ const nameOf = (id) => (id === null ? "" : (people.get(id)?.name ?? "Unknown"));
118
+ const personOf = (id) => people.get(id) ?? { id, name: "Unknown", isActive: false };
119
+ // Kept by their text inside; handed out as the people were given, so a number stays a number.
120
+ const idOf = (key) => people.get(key)?.id ?? key;
121
+ const threads = (options.threads ?? []).map(seed => {
122
+ const id = String(seed.id ?? `mail-${++threadSequence}`);
123
+ const roles = new Map([[String(seed.from), "from"]]);
124
+ const add = (ids, role) => {
125
+ for (const one of ids ?? [])
126
+ if (!roles.has(String(one)))
127
+ roles.set(String(one), role);
128
+ };
129
+ add(seed.to, "to");
130
+ add(seed.cc, "cc");
131
+ add(seed.bcc, "bcc");
132
+ const lines = [];
133
+ seed.lines.forEach((line, index) => {
134
+ lines.push({
135
+ id: `${id}-${++lineSequence}`,
136
+ authorId: line.authorId === null ? null : String(line.authorId),
137
+ body: line.body,
138
+ kind: line.kind ?? (index === 0 && !seed.chat ? "mail" : "chat"),
139
+ createdAt: line.createdAt,
140
+ replyToId: line.replyTo === undefined ? null : (lines[line.replyTo]?.id ?? null),
141
+ key: null,
142
+ });
143
+ });
144
+ const readMarks = new Map();
145
+ for (const person of roles.keys()) {
146
+ const given = seed.read?.[person];
147
+ // A person has read their own lines, and whatever the seed says beyond them.
148
+ let own = -1;
149
+ lines.forEach((line, index) => {
150
+ if (line.authorId === person)
151
+ own = index;
152
+ });
153
+ readMarks.set(person, Math.max(own, given ?? -1));
154
+ }
155
+ return {
156
+ id,
157
+ subject: seed.subject,
158
+ chat: seed.chat ?? null,
159
+ starred: new Set((seed.starredBy ?? []).map(String)),
160
+ createdTime: lines[0]?.createdAt ?? now(),
161
+ domain: seed.domain ? { domainType: seed.domain.domainType, id: seed.domain.id ?? null, label: seed.domain.label ?? null } : null,
162
+ closed: seed.closed === true,
163
+ roles,
164
+ lines,
165
+ readMarks,
166
+ archived: new Set((seed.archivedBy ?? []).map(String)),
167
+ key: null,
168
+ };
169
+ });
170
+ const find = (id) => threads.find(thread => thread.id === String(id));
171
+ /** The thread, when this reader is on it: a stranger learns nothing, as from a server (404). */
172
+ const open = (id, reader) => {
173
+ const thread = find(id);
174
+ if (!thread || !thread.roles.has(reader))
175
+ throw refuse(404, "This message is not available to you.");
176
+ return thread;
177
+ };
178
+ /** Whether this reader may see a person on this thread: everyone but the people copied unseen, who only the sender and they themselves see. */
179
+ const sees = (thread, reader, person) => thread.roles.get(person) !== "bcc" || thread.roles.get(reader) === "from" || person === reader;
180
+ const unreadOf = (thread, reader) => {
181
+ const mark = thread.readMarks.get(reader) ?? -1;
182
+ return thread.lines.filter((line, index) => index > mark && line.authorId !== null && line.authorId !== reader).length;
183
+ };
184
+ const peopleIn = (thread, reader, role) => [...thread.roles].filter(([person, held]) => held === role && sees(thread, reader, person)).map(([person]) => personOf(person));
185
+ const lastOf = (thread) => thread.lines[thread.lines.length - 1];
186
+ const kindOf = (thread) => (thread.chat ? "chat" : thread.domain ? "domain" : "mail");
187
+ /** A chat's name as this reader sees it: its own, else its other people. */
188
+ const chatName = (thread, reader) => thread.subject ||
189
+ [...thread.roles.keys()]
190
+ .filter(person => person !== reader)
191
+ .map(nameOf)
192
+ .join(", ");
193
+ const rowOf = (thread, reader) => {
194
+ const last = lastOf(thread);
195
+ const from = [...thread.roles].find(([, role]) => role === "from")?.[0] ?? "";
196
+ // A chat's row is named as the reader sees the chat: the other person, or the group.
197
+ const fromKey = thread.chat === "one-to-one" ? ([...thread.roles.keys()].find(person => person !== reader) ?? from) : from;
198
+ const avatar = people.get(fromKey)?.avatar;
199
+ return {
200
+ id: thread.id,
201
+ subject: thread.chat ? chatName(thread, reader) : thread.subject,
202
+ fromId: idOf(fromKey),
203
+ fromName: thread.chat === "group" ? chatName(thread, reader) : nameOf(fromKey),
204
+ ...(avatar && { fromAvatar: avatar }),
205
+ threadKind: kindOf(thread),
206
+ starred: thread.starred.has(reader),
207
+ myRole: thread.roles.get(reader) ?? "to",
208
+ kind: thread.lines[0]?.kind === "notification" ? "notification" : "mail",
209
+ domainType: thread.domain?.domainType ?? null,
210
+ domainLabel: thread.domain?.label ?? null,
211
+ lastMessageTime: last?.createdAt ?? thread.createdTime,
212
+ lastAuthorName: last ? nameOf(last.authorId) || null : null,
213
+ lastPreview: (last?.body ?? "").slice(0, 160),
214
+ messageCount: thread.lines.length,
215
+ unreadCount: unreadOf(thread, reader),
216
+ archived: thread.archived.has(reader),
217
+ };
218
+ };
219
+ const headOf = (thread, reader) => {
220
+ const from = [...thread.roles].find(([, role]) => role === "from")?.[0] ?? "";
221
+ const role = thread.roles.get(reader) ?? "to";
222
+ return {
223
+ id: thread.id,
224
+ subject: thread.chat ? chatName(thread, reader) : thread.subject,
225
+ threadKind: kindOf(thread),
226
+ from: personOf(from),
227
+ to: peopleIn(thread, reader, "to"),
228
+ cc: peopleIn(thread, reader, "cc"),
229
+ bcc: peopleIn(thread, reader, "bcc"),
230
+ myRole: role,
231
+ archived: thread.archived.has(reader),
232
+ canSend: !thread.closed && people.get(reader)?.isActive !== false,
233
+ createdTime: thread.createdTime,
234
+ messageCount: thread.lines.length,
235
+ unreadCount: unreadOf(thread, reader),
236
+ ...(thread.domain && { domain: thread.domain }),
237
+ };
238
+ };
239
+ const messageOf = (thread, line, reader) => {
240
+ const index = thread.lines.indexOf(line);
241
+ // Who has it and who has read it, never naming a person this reader may not see.
242
+ const others = [...thread.roles.keys()].filter(person => person !== line.authorId && sees(thread, reader, person));
243
+ return {
244
+ id: line.id,
245
+ authorId: line.authorId === null ? "" : idOf(line.authorId),
246
+ authorName: line.authorId === null ? null : nameOf(line.authorId),
247
+ body: line.body,
248
+ createdAt: line.createdAt,
249
+ status: "sent",
250
+ kind: line.kind,
251
+ ...(line.replyToId && { replyToId: line.replyToId }),
252
+ ...(line.authorId === null && { isSystem: true }),
253
+ ...(line.authorId !== null && { deliveredTo: others.map(idOf), readBy: others.filter(person => (thread.readMarks.get(person) ?? -1) >= index).map(idOf) }),
254
+ };
255
+ };
256
+ const summaryOf = (thread, reader) => {
257
+ const last = lastOf(thread);
258
+ return {
259
+ messageCount: thread.lines.length,
260
+ unreadCount: unreadOf(thread, reader),
261
+ lastMessageAt: last?.createdAt ?? null,
262
+ lastMessageText: last ? mailPreviewText(last.body) : null,
263
+ lastMessageBy: last ? nameOf(last.authorId) || null : null,
264
+ };
265
+ };
266
+ const participantsOf = (thread, reader) => [...thread.roles]
267
+ .filter(([person]) => sees(thread, reader, person))
268
+ .map(([person, role]) => ({ id: idOf(person), name: nameOf(person), role: MAIL_ROLE_LABELS[role], ...(people.get(person)?.isActive === false && { inactive: true }) }));
269
+ /** Names a person again on a thread, raising their role and never lowering it; From never changes. */
270
+ const raise = (thread, person, role) => {
271
+ const held = thread.roles.get(person);
272
+ if (!held) {
273
+ thread.roles.set(person, role);
274
+ thread.readMarks.set(person, -1);
275
+ }
276
+ else if (held !== "from" && VISIBILITY[role] > VISIBILITY[held])
277
+ thread.roles.set(person, role);
278
+ };
279
+ /** A line added: its author has read it, and the thread is back in every other person's inbox. */
280
+ const append = (thread, line) => {
281
+ const held = { ...line, id: `${thread.id}-${++lineSequence}` };
282
+ thread.lines.push(held);
283
+ if (held.authorId !== null)
284
+ thread.readMarks.set(held.authorId, thread.lines.length - 1);
285
+ for (const person of thread.roles.keys())
286
+ if (person !== held.authorId)
287
+ thread.archived.delete(person);
288
+ return held;
289
+ };
290
+ const inList = (thread, reader, list) => {
291
+ if (!thread.roles.has(reader))
292
+ return false;
293
+ const archived = thread.archived.has(reader);
294
+ if (list === "archived")
295
+ return archived;
296
+ if (list === "sent")
297
+ return thread.roles.get(reader) === "from";
298
+ if (list === "unread")
299
+ return !archived && unreadOf(thread, reader) > 0;
300
+ return !archived;
301
+ };
302
+ /** The people of a new message, checked as a server checks them: every wrong one at once. */
303
+ const checkPeople = (draft, reader) => {
304
+ const problems = [];
305
+ const chosen = new Map();
306
+ const take = (ids, role) => {
307
+ for (const one of ids ?? []) {
308
+ const key = String(one);
309
+ const person = people.get(key);
310
+ if (!person || person.isActive === false)
311
+ problems.push(`${MAIL_ROLE_LABELS[role]}: ${person?.name ?? key} cannot be written to.`);
312
+ else if (key === reader)
313
+ problems.push(`${MAIL_ROLE_LABELS[role]}: you are the sender.`);
314
+ else if (chosen.has(key))
315
+ problems.push(`${person.name} is named twice: keep them in one of To, Cc and Bcc.`);
316
+ else
317
+ chosen.set(key, role);
318
+ }
319
+ };
320
+ take(draft.to, "to");
321
+ take(draft.cc, "cc");
322
+ take(draft.bcc, "bcc");
323
+ if ((draft.to ?? []).length === 0)
324
+ problems.push("To: name at least one person.");
325
+ if (chosen.size > MAIL_PEOPLE_MAX)
326
+ problems.push(`At most ${MAIL_PEOPLE_MAX} people together.`);
327
+ const subject = draft.subject.trim();
328
+ if (subject.length === 0 || subject.length > MAIL_SUBJECT_MAX)
329
+ problems.push(`Subject: 1 to ${MAIL_SUBJECT_MAX} characters.`);
330
+ const body = draft.body.trim();
331
+ if (body.length === 0 || body.length > MAIL_BODY_MAX)
332
+ problems.push(`Message: 1 to ${MAIL_BODY_MAX} characters.`);
333
+ if (problems.length)
334
+ throw refuse(400, problems.join(" "));
335
+ return chosen;
336
+ };
337
+ function sourceFor(readerId) {
338
+ const reader = String(readerId);
339
+ return {
340
+ async inbox(request) {
341
+ await wait();
342
+ fail("inbox");
343
+ const search = request.search?.trim().toLowerCase() ?? "";
344
+ const mine = threads.filter(thread => thread.roles.has(reader));
345
+ const matches = mine
346
+ .filter(thread => inList(thread, reader, request.list))
347
+ .filter(thread => !search || `${thread.subject} ${thread.domain?.label ?? ""}`.toLowerCase().includes(search))
348
+ .filter(thread => request.threadKind === undefined || kindOf(thread) === request.threadKind)
349
+ .filter(thread => request.domainType === undefined ||
350
+ (thread.domain !== null && String(thread.domain.domainType) === String(request.domainType) && (request.parentId === undefined || String(thread.domain.id) === String(request.parentId))))
351
+ .map(thread => rowOf(thread, reader))
352
+ .sort((a, b) => b.lastMessageTime.localeCompare(a.lastMessageTime));
353
+ return { rows: matches.slice(request.skip, request.skip + request.take), total: mine.length, matches: matches.length };
354
+ },
355
+ async thread(id) {
356
+ await wait();
357
+ fail("thread");
358
+ return headOf(open(id, reader), reader);
359
+ },
360
+ threadSource(id) {
361
+ return {
362
+ async history(request) {
363
+ await wait();
364
+ fail("history");
365
+ const thread = open(id, reader);
366
+ const all = thread.lines;
367
+ const page = (lines, hasMore) => ({
368
+ messages: lines.map(line => messageOf(thread, line, reader)),
369
+ hasMore,
370
+ summary: summaryOf(thread, reader),
371
+ participants: participantsOf(thread, reader),
372
+ canSend: headOf(thread, reader).canSend,
373
+ });
374
+ if (request.after) {
375
+ const from = all.findIndex(line => sameChatId(line.id, request.after));
376
+ return page(from === -1 ? [] : all.slice(from + 1), false);
377
+ }
378
+ const end = request.before ? all.findIndex(line => sameChatId(line.id, request.before)) : all.length;
379
+ const stop = end === -1 ? all.length : end;
380
+ const start = Math.max(0, stop - request.take);
381
+ return page(all.slice(start, stop), start > 0);
382
+ },
383
+ async send(draft) {
384
+ await wait();
385
+ fail("reply");
386
+ const thread = open(id, reader);
387
+ if (!headOf(thread, reader).canSend)
388
+ throw refuse(403, "This conversation is closed.");
389
+ const body = draft.body.trim();
390
+ if (body.length === 0 || body.length > MAIL_BODY_MAX)
391
+ throw refuse(400, `Message: 1 to ${MAIL_BODY_MAX} characters.`);
392
+ const key = `${reader}:${draft.clientId}`;
393
+ const sent = thread.lines.find(line => line.key === key);
394
+ if (sent)
395
+ return messageOf(thread, sent, reader);
396
+ // A reply shows its author to everyone: a person copied unseen becomes a Cc.
397
+ if (thread.roles.get(reader) === "bcc")
398
+ thread.roles.set(reader, "cc");
399
+ const line = append(thread, { authorId: reader, body, kind: draft.kind === "mail" && !thread.chat ? "mail" : "chat", createdAt: now(), replyToId: draft.replyToId === undefined || draft.replyToId === null ? null : String(draft.replyToId), key });
400
+ return messageOf(thread, line, reader);
401
+ },
402
+ async markRead(upTo) {
403
+ await wait();
404
+ const thread = open(id, reader);
405
+ const at = thread.lines.findIndex(line => sameChatId(line.id, upTo));
406
+ if (at > (thread.readMarks.get(reader) ?? -1))
407
+ thread.readMarks.set(reader, at);
408
+ },
409
+ };
410
+ },
411
+ async compose(draft) {
412
+ await wait();
413
+ fail("compose");
414
+ const key = `compose:${reader}:${draft.clientId}`;
415
+ const made = threads.find(thread => thread.key === key);
416
+ if (made)
417
+ return { id: made.id, messageId: made.lines[0].id };
418
+ const chosen = checkPeople(draft, reader);
419
+ const thread = {
420
+ id: `mail-${++threadSequence}`,
421
+ subject: draft.subject.trim(),
422
+ chat: null,
423
+ starred: new Set(),
424
+ createdTime: now(),
425
+ domain: null,
426
+ closed: false,
427
+ roles: new Map([[reader, "from"], ...chosen]),
428
+ lines: [],
429
+ readMarks: new Map([...chosen.keys()].map(person => [person, -1])),
430
+ archived: new Set(),
431
+ key,
432
+ };
433
+ threads.push(thread);
434
+ const line = append(thread, { authorId: reader, body: draft.body.trim(), kind: "mail", createdAt: thread.createdTime, replyToId: null, key: null });
435
+ return { id: thread.id, messageId: line.id };
436
+ },
437
+ async archive(ids) {
438
+ await wait();
439
+ let changed = 0;
440
+ for (const id of ids) {
441
+ const thread = find(id);
442
+ if (thread?.roles.has(reader) && !thread.archived.has(reader)) {
443
+ thread.archived.add(reader);
444
+ changed++;
445
+ }
446
+ }
447
+ return changed;
448
+ },
449
+ async unarchive(ids) {
450
+ await wait();
451
+ let changed = 0;
452
+ for (const id of ids) {
453
+ const thread = find(id);
454
+ if (thread?.archived.delete(reader))
455
+ changed++;
456
+ }
457
+ return changed;
458
+ },
459
+ async folders() {
460
+ await wait();
461
+ fail("folders");
462
+ const mine = threads.filter(thread => thread.roles.has(reader));
463
+ const lists = ["inbox", "unread", "sent", "archived"];
464
+ const folders = lists.map(list => ({ list, unread: mine.filter(thread => inList(thread, reader, list) && unreadOf(thread, reader) > 0).length }));
465
+ const kinds = new Map();
466
+ for (const thread of mine.filter(one => inList(one, reader, "inbox"))) {
467
+ const threadKind = kindOf(thread);
468
+ const domainType = threadKind === "domain" ? (thread.domain?.domainType ?? null) : null;
469
+ const key = `${threadKind}:${String(domainType)}`;
470
+ const held = kinds.get(key) ?? { threadKind, domainType, unread: 0, total: 0 };
471
+ held.total++;
472
+ if (unreadOf(thread, reader) > 0)
473
+ held.unread++;
474
+ kinds.set(key, held);
475
+ }
476
+ return { folders, kinds: [...kinds.values()] };
477
+ },
478
+ async markRead(ids) {
479
+ await wait();
480
+ fail("marks");
481
+ let changed = 0;
482
+ for (const id of ids) {
483
+ const thread = find(id);
484
+ if (!thread?.roles.has(reader) || unreadOf(thread, reader) === 0)
485
+ continue;
486
+ thread.readMarks.set(reader, thread.lines.length - 1);
487
+ changed++;
488
+ }
489
+ return changed;
490
+ },
491
+ async markUnread(ids) {
492
+ await wait();
493
+ fail("marks");
494
+ let changed = 0;
495
+ for (const id of ids) {
496
+ const thread = find(id);
497
+ if (!thread?.roles.has(reader) || unreadOf(thread, reader) > 0)
498
+ continue;
499
+ // The newest line somebody else wrote is unread again; a thread of the reader's own lines alone stays read.
500
+ let at = -1;
501
+ thread.lines.forEach((line, index) => {
502
+ if (line.authorId !== null && line.authorId !== reader)
503
+ at = index;
504
+ });
505
+ if (at === -1)
506
+ continue;
507
+ thread.readMarks.set(reader, at - 1);
508
+ changed++;
509
+ }
510
+ return changed;
511
+ },
512
+ async star(ids, starred) {
513
+ await wait();
514
+ fail("marks");
515
+ let changed = 0;
516
+ for (const id of ids) {
517
+ const thread = find(id);
518
+ if (!thread?.roles.has(reader) || thread.starred.has(reader) === starred)
519
+ continue;
520
+ if (starred)
521
+ thread.starred.add(reader);
522
+ else
523
+ thread.starred.delete(reader);
524
+ changed++;
525
+ }
526
+ return changed;
527
+ },
528
+ async unread() {
529
+ await wait();
530
+ fail("unread");
531
+ const rows = threads
532
+ .filter(thread => inList(thread, reader, "unread"))
533
+ .map(thread => rowOf(thread, reader))
534
+ .sort((a, b) => b.lastMessageTime.localeCompare(a.lastMessageTime));
535
+ // Counted as a backend counts: to MAIL_UNREAD_MAX, and no further.
536
+ return { count: Math.min(rows.reduce((total, row) => total + row.unreadCount, 0), MAIL_UNREAD_MAX), newest: rows.slice(0, 8) };
537
+ },
538
+ async people(query) {
539
+ await wait();
540
+ fail("people");
541
+ const search = query.search.trim().toLowerCase();
542
+ const found = options.people.filter(person => String(person.id) !== reader && person.isActive !== false && (!search || person.name.toLowerCase().includes(search)));
543
+ return { items: found.slice(query.skip, query.skip + query.pageSize), total: found.length, hasMore: query.skip + query.pageSize < found.length };
544
+ },
545
+ async defaults() {
546
+ await wait();
547
+ fail("defaults");
548
+ const own = options.defaults?.[reader] ?? {};
549
+ const list = (ids) => (ids ?? []).map(id => personOf(String(id)));
550
+ return { to: list(own.to), cc: list(own.cc), bcc: list(own.bcc) };
551
+ },
552
+ };
553
+ }
554
+ return {
555
+ ...sourceFor(options.userId),
556
+ as: sourceFor,
557
+ post(threadId, given) {
558
+ const thread = find(threadId);
559
+ if (!thread)
560
+ throw refuse(404, "No such thread.");
561
+ for (const person of given.to ?? [])
562
+ raise(thread, String(person), "to");
563
+ for (const person of given.cc ?? [])
564
+ raise(thread, String(person), "cc");
565
+ const authorId = given.authorId === null ? null : String(given.authorId);
566
+ if (authorId !== null && !thread.roles.has(authorId))
567
+ raise(thread, authorId, "cc");
568
+ const line = append(thread, { authorId, body: given.body, kind: given.kind ?? "notification", createdAt: now(), replyToId: null, key: null });
569
+ return messageOf(thread, line, authorId ?? "");
570
+ },
571
+ failing,
572
+ };
573
+ }
@@ -0,0 +1,95 @@
1
+ import type { MailInboxRow, MessageSource } from "../Interfaces/MailInterfaces.ts";
2
+ /** How often the bell reads, in milliseconds, while the page is in front: a minute (the domain messages spec). */
3
+ export declare const MESSAGE_UNREAD_INTERVAL = 60000;
4
+ /** A wake within this long of the last read reads nothing more: a tab shown again also takes the focus. */
5
+ export declare const MESSAGE_UNREAD_WAKE_GAP = 2000;
6
+ /** What the bell knows. */
7
+ export interface MessageUnreadSnapshot {
8
+ /** `idle`: never read. `loading`: the first read is on its way. `ready`: a count is known. `failed`: the first read failed. */
9
+ status: "idle" | "loading" | "ready" | "failed";
10
+ /** Unread lines, as the source counts them (a backend stops at `MAIL_UNREAD_MAX`). */
11
+ count: number;
12
+ /** The newest threads holding them, newest first. */
13
+ newest: readonly MailInboxRow[];
14
+ /** The last read failed: the count is the one read before it. */
15
+ failed: boolean;
16
+ error?: unknown;
17
+ /** When the last answer landed (milliseconds since 1970). */
18
+ checkedAt?: number;
19
+ /** Words for a screen reader, set when the count went up: `seq` grows with each, so the same words can be said again. */
20
+ spoken?: {
21
+ text: string;
22
+ seq: number;
23
+ };
24
+ }
25
+ /** The world the poller reads: the clock, a timer, whether the page is in front, and when it comes back. */
26
+ export interface MessageUnreadEnvironment {
27
+ now(): number;
28
+ /** Runs `run` once after `ms`; answers how to call it off. */
29
+ later(run: () => void, ms: number): () => void;
30
+ /** Whether the page is in front: a hidden tab reads nothing. */
31
+ visible(): boolean;
32
+ /** Calls `run` when the window takes the focus or the tab is shown or hidden; answers how to stop. */
33
+ watch(run: () => void): () => void;
34
+ }
35
+ /** The browser's: `Date.now`, `setTimeout`, `document.visibilityState`, `focus` and `visibilitychange`. */
36
+ export declare function browserUnreadEnvironment(): MessageUnreadEnvironment;
37
+ /** "No unread messages", "1 unread message", "3 unread messages", "More than 99 unread messages". */
38
+ export declare function mailUnreadWords(count: number, max?: number): string;
39
+ /** The badge: "3", "99+" from 100 on (a backend counts to 100 and stops). Empty for none. */
40
+ export declare function mailUnreadBadge(count: number, max?: number): string;
41
+ /** A thread's address in the inbox: `href` with `thread=<id>` added to its query, before any `#`. */
42
+ export declare function mailThreadHref(href: string, id: MailInboxRow["id"]): string;
43
+ /** One bell's place in the poller: how often it wants a read, and how it leaves. */
44
+ export interface MessageUnreadMembership {
45
+ leave(): void;
46
+ /** Whether this bell is the one that speaks: the first still drawn, so two bells never say the same thing twice. */
47
+ speaks(): boolean;
48
+ }
49
+ /** One mailbox's unread count for the whole page. Every bell of the page on the same source shares it. */
50
+ export declare class MessageUnreadPoller {
51
+ private source;
52
+ private env;
53
+ private snapshot;
54
+ private listeners;
55
+ private members;
56
+ private controller;
57
+ private cancelTimer;
58
+ private stopWatching;
59
+ private startedAt;
60
+ private endedAt;
61
+ private known;
62
+ private seq;
63
+ /** A refresh was asked while a read was on its way: one more read follows it. */
64
+ private again;
65
+ /** Reads started, for a page that shows how many calls it made, and for tests. */
66
+ reads: number;
67
+ constructor(source: Pick<MessageSource, "unread">, env?: MessageUnreadEnvironment);
68
+ readonly getSnapshot: () => MessageUnreadSnapshot;
69
+ /** Hears every change. Joining is separate: a bell that only shows a count need not make the page read. */
70
+ readonly subscribe: (listener: () => void) => (() => void);
71
+ private set;
72
+ /** How often the members want a read: the shortest any of them asked for. */
73
+ private get interval();
74
+ /** A bell drawn: the first starts the reads. Reads at once unless an answer younger than the interval is held. */
75
+ join(interval?: number): MessageUnreadMembership;
76
+ /**
77
+ * Reads now: a page that just read a thread, archived some, or a bell opened. With a read on its way - which may have
78
+ * counted before the change - one more read follows it, however many refreshes are asked meanwhile; never two at once.
79
+ * While the page is hidden nothing is read: the read asked for waits for the page to be shown.
80
+ */
81
+ refresh(): void;
82
+ /** A read ended: the one asked for meanwhile, now, while the page is in front; else the next one, a minute on. */
83
+ private settle;
84
+ /** The window took the focus, or the tab was shown or hidden. */
85
+ private wake;
86
+ private read;
87
+ /** The next read, an interval after the last one ended: only while a bell is drawn and the page is in front. */
88
+ private schedule;
89
+ /** The last bell went: no timer, no watching, the read on its way called off. What was read stays for the next bell. */
90
+ private stop;
91
+ }
92
+ /** The page's poller for a mailbox: one per source object, however many bells draw it. */
93
+ export declare function messageUnreadPoller(source: Pick<MessageSource, "unread">): MessageUnreadPoller;
94
+ /** Reads the count again now, for every bell on `source`: after the page read a thread or archived some. */
95
+ export declare function refreshMessageUnread(source: Pick<MessageSource, "unread">): void;