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,245 @@
1
+ import { chatBadgeText } from "./ChatHelpers.js";
2
+ /*
3
+ * How much mail waits, for the bell on every page: one poller per mailbox, outside React, shared by every bell that
4
+ * draws it. It reads `source.unread()` once when the first bell appears, then every minute while the page is in front,
5
+ * at once when the window takes the focus again or the tab is shown again, and never while the tab is hidden. However
6
+ * many bells a page draws, it asks once. When the count goes up after the page has read it once, it words it for a
7
+ * screen reader, so the bell says "3 unread messages" once - not on every read, and not as the page loads.
8
+ */
9
+ /** How often the bell reads, in milliseconds, while the page is in front: a minute (the domain messages spec). */
10
+ export const MESSAGE_UNREAD_INTERVAL = 60_000;
11
+ /** A wake within this long of the last read reads nothing more: a tab shown again also takes the focus. */
12
+ export const MESSAGE_UNREAD_WAKE_GAP = 2_000;
13
+ /** The browser's: `Date.now`, `setTimeout`, `document.visibilityState`, `focus` and `visibilitychange`. */
14
+ export function browserUnreadEnvironment() {
15
+ return {
16
+ now: () => Date.now(),
17
+ later: (run, ms) => {
18
+ const timer = setTimeout(run, ms);
19
+ return () => clearTimeout(timer);
20
+ },
21
+ visible: () => typeof document === "undefined" || document.visibilityState !== "hidden",
22
+ watch: run => {
23
+ if (typeof window === "undefined")
24
+ return () => { };
25
+ window.addEventListener("focus", run);
26
+ document.addEventListener("visibilitychange", run);
27
+ return () => {
28
+ window.removeEventListener("focus", run);
29
+ document.removeEventListener("visibilitychange", run);
30
+ };
31
+ },
32
+ };
33
+ }
34
+ /** "No unread messages", "1 unread message", "3 unread messages", "More than 99 unread messages". */
35
+ export function mailUnreadWords(count, max = 99) {
36
+ if (count <= 0)
37
+ return "No unread messages";
38
+ if (count > max)
39
+ return `More than ${max} unread messages`;
40
+ return count === 1 ? "1 unread message" : `${count} unread messages`;
41
+ }
42
+ /** The badge: "3", "99+" from 100 on (a backend counts to 100 and stops). Empty for none. */
43
+ export function mailUnreadBadge(count, max = 99) {
44
+ return count > 0 ? chatBadgeText(count, max) : "";
45
+ }
46
+ /** A thread's address in the inbox: `href` with `thread=<id>` added to its query, before any `#`. */
47
+ export function mailThreadHref(href, id) {
48
+ const hash = href.indexOf("#");
49
+ const path = hash < 0 ? href : href.slice(0, hash);
50
+ const rest = hash < 0 ? "" : href.slice(hash);
51
+ return `${path}${path.includes("?") ? "&" : "?"}thread=${encodeURIComponent(String(id))}${rest}`;
52
+ }
53
+ const IDLE = { status: "idle", count: 0, newest: [], failed: false };
54
+ /** One mailbox's unread count for the whole page. Every bell of the page on the same source shares it. */
55
+ export class MessageUnreadPoller {
56
+ source;
57
+ env;
58
+ snapshot = IDLE;
59
+ listeners = new Set();
60
+ members = new Map();
61
+ controller = null;
62
+ cancelTimer = null;
63
+ stopWatching = null;
64
+ startedAt = Number.NEGATIVE_INFINITY;
65
+ endedAt = Number.NEGATIVE_INFINITY;
66
+ known = false;
67
+ seq = 0;
68
+ /** A refresh was asked while a read was on its way: one more read follows it. */
69
+ again = false;
70
+ /** Reads started, for a page that shows how many calls it made, and for tests. */
71
+ reads = 0;
72
+ constructor(source, env = browserUnreadEnvironment()) {
73
+ this.source = source;
74
+ this.env = env;
75
+ }
76
+ getSnapshot = () => this.snapshot;
77
+ /** Hears every change. Joining is separate: a bell that only shows a count need not make the page read. */
78
+ subscribe = (listener) => {
79
+ this.listeners.add(listener);
80
+ return () => this.listeners.delete(listener);
81
+ };
82
+ set(next) {
83
+ this.snapshot = { ...this.snapshot, ...next };
84
+ for (const listener of this.listeners)
85
+ listener();
86
+ }
87
+ /** How often the members want a read: the shortest any of them asked for. */
88
+ get interval() {
89
+ return this.members.size ? Math.min(...this.members.values()) : MESSAGE_UNREAD_INTERVAL;
90
+ }
91
+ /** A bell drawn: the first starts the reads. Reads at once unless an answer younger than the interval is held. */
92
+ join(interval = MESSAGE_UNREAD_INTERVAL) {
93
+ const key = Symbol("bell");
94
+ const first = this.members.size === 0;
95
+ this.members.set(key, Math.max(1000, interval));
96
+ if (first)
97
+ this.stopWatching = this.env.watch(() => this.wake());
98
+ if (this.env.now() - this.endedAt >= this.interval)
99
+ this.read();
100
+ else
101
+ this.schedule();
102
+ return {
103
+ leave: () => {
104
+ if (!this.members.delete(key))
105
+ return;
106
+ if (this.members.size === 0)
107
+ this.stop();
108
+ else
109
+ this.schedule();
110
+ },
111
+ speaks: () => this.members.keys().next().value === key,
112
+ };
113
+ }
114
+ /**
115
+ * Reads now: a page that just read a thread, archived some, or a bell opened. With a read on its way - which may have
116
+ * counted before the change - one more read follows it, however many refreshes are asked meanwhile; never two at once.
117
+ * While the page is hidden nothing is read: the read asked for waits for the page to be shown.
118
+ */
119
+ refresh() {
120
+ if (this.members.size === 0)
121
+ return;
122
+ if (this.controller || !this.env.visible()) {
123
+ this.again = true;
124
+ return;
125
+ }
126
+ this.read();
127
+ }
128
+ /** A read ended: the one asked for meanwhile, now, while the page is in front; else the next one, a minute on. */
129
+ settle() {
130
+ if (this.again && this.members.size > 0 && this.env.visible()) {
131
+ this.again = false;
132
+ this.read();
133
+ return;
134
+ }
135
+ this.schedule();
136
+ }
137
+ /** The window took the focus, or the tab was shown or hidden. */
138
+ wake() {
139
+ if (!this.env.visible()) {
140
+ this.cancelTimer?.();
141
+ this.cancelTimer = null;
142
+ return;
143
+ }
144
+ if (this.controller)
145
+ return;
146
+ // Shown again with a read asked for while hidden: at once, however recent the last read.
147
+ if (this.again) {
148
+ this.again = false;
149
+ this.read();
150
+ return;
151
+ }
152
+ if (this.env.now() - this.startedAt < MESSAGE_UNREAD_WAKE_GAP) {
153
+ this.schedule();
154
+ return;
155
+ }
156
+ this.read();
157
+ }
158
+ read() {
159
+ if (this.controller || !this.env.visible())
160
+ return;
161
+ this.cancelTimer?.();
162
+ this.cancelTimer = null;
163
+ const controller = new AbortController();
164
+ this.controller = controller;
165
+ this.startedAt = this.env.now();
166
+ this.reads++;
167
+ if (!this.known)
168
+ this.set({ status: "loading" });
169
+ let answer;
170
+ try {
171
+ answer = Promise.resolve(this.source.unread(controller.signal));
172
+ }
173
+ catch (error) {
174
+ answer = Promise.reject(error);
175
+ }
176
+ answer.then(unread => {
177
+ if (this.controller !== controller)
178
+ return;
179
+ this.controller = null;
180
+ this.endedAt = this.env.now();
181
+ const count = Math.max(0, Math.floor(Number(unread.count) || 0));
182
+ const before = this.snapshot.count;
183
+ const rose = this.known && count > before;
184
+ this.known = true;
185
+ this.set({
186
+ status: "ready",
187
+ count,
188
+ newest: unread.newest ?? [],
189
+ failed: false,
190
+ error: undefined,
191
+ checkedAt: this.endedAt,
192
+ ...(rose && { spoken: { text: mailUnreadWords(count), seq: ++this.seq } }),
193
+ });
194
+ this.settle();
195
+ }, (error) => {
196
+ if (this.controller !== controller)
197
+ return;
198
+ this.controller = null;
199
+ this.endedAt = this.env.now();
200
+ // The count read before stays on the bell; the next minute tries again.
201
+ this.set({ status: this.known ? "ready" : "failed", failed: true, error });
202
+ this.settle();
203
+ });
204
+ }
205
+ /** The next read, an interval after the last one ended: only while a bell is drawn and the page is in front. */
206
+ schedule() {
207
+ this.cancelTimer?.();
208
+ this.cancelTimer = null;
209
+ if (this.members.size === 0 || this.controller || !this.env.visible())
210
+ return;
211
+ const wait = Math.max(0, this.interval - (this.env.now() - this.endedAt));
212
+ this.cancelTimer = this.env.later(() => {
213
+ this.cancelTimer = null;
214
+ this.read();
215
+ }, wait);
216
+ }
217
+ /** The last bell went: no timer, no watching, the read on its way called off. What was read stays for the next bell. */
218
+ stop() {
219
+ this.again = false;
220
+ this.cancelTimer?.();
221
+ this.cancelTimer = null;
222
+ this.stopWatching?.();
223
+ this.stopWatching = null;
224
+ if (this.controller) {
225
+ this.controller.abort();
226
+ this.controller = null;
227
+ if (!this.known)
228
+ this.set({ status: "idle" });
229
+ }
230
+ }
231
+ }
232
+ const pollers = new WeakMap();
233
+ /** The page's poller for a mailbox: one per source object, however many bells draw it. */
234
+ export function messageUnreadPoller(source) {
235
+ let poller = pollers.get(source);
236
+ if (!poller) {
237
+ poller = new MessageUnreadPoller(source);
238
+ pollers.set(source, poller);
239
+ }
240
+ return poller;
241
+ }
242
+ /** Reads the count again now, for every bell on `source`: after the page read a thread or archived some. */
243
+ export function refreshMessageUnread(source) {
244
+ pollers.get(source)?.refresh();
245
+ }
@@ -0,0 +1,71 @@
1
+ import type { ApiClient } from "./ApiClient.ts";
2
+ import { FileAdapter } from "./FileAdapter.ts";
3
+ import { type ApiModuleValue, type ApiResult } from "../Interfaces/ApiInterfaces.ts";
4
+ import type { FileCallOptions, FileChange, FileCopy, FileOwner, FileUpload, OwnerFiles, StoredFile } from "../Interfaces/FileInterfaces.ts";
5
+ /**
6
+ * The owner module's refusal, passed on by Drive, of a change to files that may now only be added to (`API_X084`, the
7
+ * core's `NexusFileTickets.WritableAsync`): a posted goods receipt's documents renamed, marked or removed.
8
+ */
9
+ export declare const FILES_ADD_ONLY = "API_X084";
10
+ /**
11
+ * Why an owner's files are read only when its module refuses a write ticket with a 404 but a read ticket finds it: the
12
+ * owner is not in use now (pending, ended, in the trash or deleted). Its files stay where they are.
13
+ */
14
+ export declare const OWNER_NOT_IN_USE_REASON = "This record is not in use now, so its files can be seen but not changed.";
15
+ /** What the adapter sends its calls with: the app's `api` (nexus-shared/client), so its token and headers go with them. */
16
+ export type FileApiClient = Pick<ApiClient<{
17
+ toast?: false;
18
+ }>, "send">;
19
+ export interface NexusDriveAdapterOptions {
20
+ /** The client the calls go through: `api` from nexus-shared/client. */
21
+ client: FileApiClient;
22
+ /** Reads the editor's pointer file. Default the browser's `fetch`. */
23
+ fetch?: typeof fetch;
24
+ /** The app's Drive module. Default `NexusModule.Drive`. */
25
+ drive?: ApiModuleValue;
26
+ /** The pointer file that names the current editor. Default `nexus-image-editor.txt` (Drive's `copy-image-editor.mjs`). */
27
+ editorPointer?: string;
28
+ }
29
+ /**
30
+ * The Nexus Drive (`Nexus.Drives`, `App/Api/File/...`) behind the owner modules' tickets (`App/Api/Files/...`).
31
+ * Register another with `defineFileAdapter` to keep files elsewhere.
32
+ */
33
+ export declare class NexusDriveAdapter extends FileAdapter {
34
+ readonly drive: ApiModuleValue;
35
+ readonly editorPointer: string;
36
+ private readonly tickets;
37
+ private readonly client;
38
+ private readonly fetcher;
39
+ constructor(options: NexusDriveAdapterOptions);
40
+ private driveEndpoint;
41
+ /** A write ticket for the owner, kept while it is good. */
42
+ private writeTicket;
43
+ /** Calls Drive with the owner's write ticket, asking for a fresh one once when Drive says it ran out. */
44
+ private withTicket;
45
+ /** A read ticket for several owners of one category. */
46
+ private readTicket;
47
+ list(owner: FileOwner<ApiModuleValue>, options?: FileCallOptions): Promise<ApiResult<OwnerFiles>>;
48
+ primaries(module: ApiModuleValue, category: string, ownerIds: readonly string[], options?: FileCallOptions): Promise<ApiResult<(StoredFile | null)[]>>;
49
+ upload(owner: FileOwner<ApiModuleValue>, upload: FileUpload, options?: FileCallOptions): Promise<ApiResult<StoredFile>>;
50
+ setPrimary(owner: FileOwner<ApiModuleValue>, fileId: string | null, options?: FileCallOptions): Promise<ApiResult<unknown>>;
51
+ rename(owner: FileOwner<ApiModuleValue>, fileId: string, name: string, options?: FileCallOptions): Promise<ApiResult<unknown>>;
52
+ /**
53
+ * Drive's Delete answers the file record: `pendingRemoval: true` when it stays for the review (a picture live on a
54
+ * reviewed gallery; a second Delete changes nothing), else it is gone (`false`, or nothing from an older Drive).
55
+ */
56
+ remove(owner: FileOwner<ApiModuleValue>, fileId: string, options?: FileCallOptions): Promise<ApiResult<unknown>>;
57
+ /**
58
+ * `PUT File/Keep/{id}` with `{ ticket, id }` (the same id: Drive refuses a body id unlike the route's): the held removal
59
+ * cancelled, the file answered as it is now (`pendingRemoval: false`); a file not waiting is answered unchanged.
60
+ */
61
+ keep(owner: FileOwner<ApiModuleValue>, fileId: string, options?: FileCallOptions): Promise<ApiResult<StoredFile | null>>;
62
+ /** `API_X084`: the files may now only be added to. */
63
+ changesLeft(answer: Pick<ApiResult, "messageCode" | "errorCode" | "status">): readonly FileChange[] | null;
64
+ fileUrl(file: StoredFile, copy: FileCopy): string;
65
+ /** Reads Drive's pointer file (`Assets/File/nexus-image-editor.txt`), which names the versioned module beside it. */
66
+ editorModuleUrl(signal?: AbortSignal): Promise<string>;
67
+ }
68
+ /** Reads one of Drive's `FileResponseContract`s; null when it is not one. */
69
+ export declare function readStoredFile(value: unknown): StoredFile | null;
70
+ /** Reads Drive's `FileListResponseContract`. */
71
+ export declare function readOwnerFiles(value: unknown): OwnerFiles;
@@ -0,0 +1,236 @@
1
+ import { readFetchResponse } from "./ApiResponses.js";
2
+ import { getApiModules } from "./ApiModules.js";
3
+ import { FileAdapter, fileOwnerKey, pictureLimits, removalHeld } from "./FileAdapter.js";
4
+ import { NexusModule } from "../Interfaces/ApiInterfaces.js";
5
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6
+ const text = (value) => (typeof value === "string" && value.trim() ? value : null);
7
+ const count = (value, fallback = 0) => (typeof value === "number" && Number.isFinite(value) ? value : fallback);
8
+ /** Drive's `EFileKinds` flags, and the MIME type of each. */
9
+ const NEXUS_FILE_KINDS = [
10
+ [1, "image/png"],
11
+ [2, "image/jpeg"],
12
+ [4, "image/webp"],
13
+ [8, "application/pdf"],
14
+ ];
15
+ /** The Drive refusal that says a ticket ran out (DRV_X011): asked again once, with a fresh ticket. */
16
+ const TICKET_RAN_OUT = "DRV_X011";
17
+ /**
18
+ * The owner module's refusal, passed on by Drive, of a change to files that may now only be added to (`API_X084`, the
19
+ * core's `NexusFileTickets.WritableAsync`): a posted goods receipt's documents renamed, marked or removed.
20
+ */
21
+ export const FILES_ADD_ONLY = "API_X084";
22
+ /**
23
+ * Why an owner's files are read only when its module refuses a write ticket with a 404 but a read ticket finds it: the
24
+ * owner is not in use now (pending, ended, in the trash or deleted). Its files stay where they are.
25
+ */
26
+ export const OWNER_NOT_IN_USE_REASON = "This record is not in use now, so its files can be seen but not changed.";
27
+ /** A ticket is used until this long before it runs out, so a slow upload never carries one that expires on the way. */
28
+ const TICKET_MARGIN_MS = 60_000;
29
+ /**
30
+ * The Nexus Drive (`Nexus.Drives`, `App/Api/File/...`) behind the owner modules' tickets (`App/Api/Files/...`).
31
+ * Register another with `defineFileAdapter` to keep files elsewhere.
32
+ */
33
+ export class NexusDriveAdapter extends FileAdapter {
34
+ drive;
35
+ editorPointer;
36
+ tickets = new Map();
37
+ client;
38
+ fetcher;
39
+ constructor(options) {
40
+ super();
41
+ this.client = options.client;
42
+ this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
43
+ this.drive = options.drive ?? NexusModule.Drive;
44
+ this.editorPointer = options.editorPointer ?? "nexus-image-editor.txt";
45
+ }
46
+ driveEndpoint(method, path, extras = {}) {
47
+ return { module: this.drive, path, method, ...extras };
48
+ }
49
+ /** A write ticket for the owner, kept while it is good. */
50
+ async writeTicket(owner, signal, fresh = false) {
51
+ const key = fileOwnerKey(owner);
52
+ const kept = this.tickets.get(key);
53
+ if (!fresh && kept && kept.until > Date.now())
54
+ return { ok: true, result: kept.ticket };
55
+ const answer = await this.client.send({ module: owner.module, path: "Files/Ticket/{category}/{ownerId}", method: "POST" }, {
56
+ params: { category: owner.category, ownerId: owner.ownerId },
57
+ signal,
58
+ toast: false,
59
+ });
60
+ const ticket = isRecord(answer.result) ? text(answer.result.ticket) : null;
61
+ if (!answer.ok || !ticket) {
62
+ this.tickets.delete(key);
63
+ return { ...answer, ok: false, result: undefined };
64
+ }
65
+ const expires = isRecord(answer.result) ? Date.parse(String(answer.result.expiresAt ?? "")) : NaN;
66
+ // Kept only when the backend's end is ahead of this clock by more than the margin; a clock that is ahead asks each time.
67
+ if (Number.isFinite(expires) && expires - TICKET_MARGIN_MS > Date.now())
68
+ this.tickets.set(key, { ticket, until: expires - TICKET_MARGIN_MS });
69
+ return { ...answer, result: ticket };
70
+ }
71
+ /** Calls Drive with the owner's write ticket, asking for a fresh one once when Drive says it ran out. */
72
+ async withTicket(owner, options, call) {
73
+ for (const fresh of [false, true]) {
74
+ const ticket = await this.writeTicket(owner, options.signal, fresh);
75
+ if (!ticket.ok)
76
+ return ticket;
77
+ const answer = await call(ticket.result);
78
+ if (answer.ok || answer.messageCode !== TICKET_RAN_OUT || fresh)
79
+ return answer;
80
+ }
81
+ return { ok: false };
82
+ }
83
+ /** A read ticket for several owners of one category. */
84
+ async readTicket(module, category, ownerIds, signal) {
85
+ const answer = await this.client.send({ module, path: "Files/Owners/{category}", method: "POST" }, {
86
+ params: { category },
87
+ body: { ownerIds },
88
+ signal,
89
+ toast: false,
90
+ });
91
+ const ticket = isRecord(answer.result) ? text(answer.result.ticket) : null;
92
+ return answer.ok && ticket ? { ...answer, result: ticket } : { ...answer, ok: false, result: undefined };
93
+ }
94
+ async list(owner, options = {}) {
95
+ const read = (ticket) => this.client.send(this.driveEndpoint("POST", "File/List"), { body: { ticket }, signal: options.signal, toast: false });
96
+ let answer = await this.withTicket(owner, options, read);
97
+ // No write ticket for this person (a key they do not hold, an owner they may only see): their files, read only, and
98
+ // the refusal's words as the reason. A 404 is an owner not in use - pending, ended, in the trash or deleted - that a
99
+ // read ticket still finds: "The record was not found." under its files would be untrue, so it says what it is.
100
+ let refused;
101
+ if (!answer.ok && (answer.status === 403 || answer.status === 404) && owner.ownerId !== "me" && !answer.aborted) {
102
+ refused = answer.status === 404 ? OWNER_NOT_IN_USE_REASON : answer.message;
103
+ const ticket = await this.readTicket(owner.module, owner.category, [owner.ownerId], options.signal);
104
+ if (ticket.ok)
105
+ answer = await read(ticket.result);
106
+ }
107
+ if (!answer.ok)
108
+ return answer;
109
+ const files = readOwnerFiles(answer.result);
110
+ if (refused && !files.category.canWrite)
111
+ files.readOnlyReason = refused;
112
+ return { ...answer, result: files };
113
+ }
114
+ async primaries(module, category, ownerIds, options = {}) {
115
+ if (ownerIds.length === 0)
116
+ return { ok: true, result: [] };
117
+ const ticket = await this.readTicket(module, category, ownerIds, options.signal);
118
+ if (!ticket.ok)
119
+ return { ...ticket, result: undefined };
120
+ const answer = await this.client.send(this.driveEndpoint("POST", "File/Primaries"), { body: { ticket: ticket.result }, signal: options.signal, toast: false });
121
+ if (!answer.ok)
122
+ return answer;
123
+ const files = ownerIds.map(() => null);
124
+ for (const item of Array.isArray(answer.result) ? answer.result : []) {
125
+ if (!isRecord(item))
126
+ continue;
127
+ const index = count(item.ownerIndex, -1);
128
+ if (index >= 0 && index < files.length)
129
+ files[index] = readStoredFile(item.file);
130
+ }
131
+ return { ...answer, result: files };
132
+ }
133
+ upload(owner, upload, options = {}) {
134
+ return this.withTicket(owner, options, async (ticket) => {
135
+ const form = new FormData();
136
+ form.append("ticket", ticket);
137
+ form.append("file", upload.file, upload.name);
138
+ if (upload.thumbnail)
139
+ form.append("thumbnail", upload.thumbnail, `thumbnail-${upload.name}`);
140
+ const answer = await this.client.send(this.driveEndpoint("POST", "File/Upload", { bodyType: "form-data" }), { body: form, signal: options.signal, toast: false, timeout: 120_000 });
141
+ const file = answer.ok ? readStoredFile(answer.result) : null;
142
+ return answer.ok && file ? { ...answer, result: file } : { ...answer, ok: answer.ok && file !== null, result: undefined };
143
+ });
144
+ }
145
+ setPrimary(owner, fileId, options = {}) {
146
+ return this.withTicket(owner, options, ticket => this.client.send(this.driveEndpoint("PUT", "File/SetPrimary"), { body: { ticket, id: fileId }, signal: options.signal, toast: false }));
147
+ }
148
+ rename(owner, fileId, name, options = {}) {
149
+ return this.withTicket(owner, options, ticket => this.client.send(this.driveEndpoint("PUT", "File/Rename"), { body: { ticket, id: fileId, fileDisplayName: name }, signal: options.signal, toast: false }));
150
+ }
151
+ /**
152
+ * Drive's Delete answers the file record: `pendingRemoval: true` when it stays for the review (a picture live on a
153
+ * reviewed gallery; a second Delete changes nothing), else it is gone (`false`, or nothing from an older Drive).
154
+ */
155
+ async remove(owner, fileId, options = {}) {
156
+ const answer = await this.withTicket(owner, options, ticket => this.client.send(this.driveEndpoint("DELETE", "File/Delete"), { body: { ticket, id: fileId }, signal: options.signal, toast: false }));
157
+ if (!answer.ok || !removalHeld(answer.result))
158
+ return answer;
159
+ return { ...answer, result: readStoredFile(answer.result) ?? { pendingRemoval: true } };
160
+ }
161
+ /**
162
+ * `PUT File/Keep/{id}` with `{ ticket, id }` (the same id: Drive refuses a body id unlike the route's): the held removal
163
+ * cancelled, the file answered as it is now (`pendingRemoval: false`); a file not waiting is answered unchanged.
164
+ */
165
+ keep(owner, fileId, options = {}) {
166
+ return this.withTicket(owner, options, async (ticket) => {
167
+ const answer = await this.client.send(this.driveEndpoint("PUT", "File/Keep/{id}"), { params: { id: fileId }, body: { ticket, id: fileId }, signal: options.signal, toast: false });
168
+ return { ...answer, result: answer.ok ? readStoredFile(answer.result) : undefined };
169
+ });
170
+ }
171
+ /** `API_X084`: the files may now only be added to. */
172
+ changesLeft(answer) {
173
+ return answer.messageCode === FILES_ADD_ONLY ? ["add"] : null;
174
+ }
175
+ fileUrl(file, copy) {
176
+ return getApiModules().browserApiUrl(this.driveEndpoint("GET", "File/View/{id}/{size}"), { params: { id: file.id, size: copy === "thumbnail" ? "Thumbnail" : "Original" } });
177
+ }
178
+ /** Reads Drive's pointer file (`Assets/File/nexus-image-editor.txt`), which names the versioned module beside it. */
179
+ async editorModuleUrl(signal) {
180
+ const asset = (file) => getApiModules().browserApiUrl(this.driveEndpoint("GET", "Assets/File/{file}"), { params: { file } });
181
+ const response = await this.fetcher(asset(this.editorPointer), { signal, credentials: "same-origin", cache: "no-cache" });
182
+ if (!response.ok) {
183
+ const answer = await readFetchResponse(response);
184
+ throw new Error(answer.message ?? "The image editor could not be loaded.");
185
+ }
186
+ const name = (await response.text()).trim();
187
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.m?js$/.test(name))
188
+ throw new Error("The image editor's address on the server is not valid.");
189
+ return new URL(asset(name), globalThis.location?.href ?? "http://localhost/").href;
190
+ }
191
+ }
192
+ /** Reads one of Drive's `FileResponseContract`s; null when it is not one. */
193
+ export function readStoredFile(value) {
194
+ if (!isRecord(value))
195
+ return null;
196
+ const id = text(value.id);
197
+ if (!id)
198
+ return null;
199
+ return {
200
+ id,
201
+ name: text(value.fileDisplayName) ?? "File",
202
+ contentType: text(value.contentType) ?? "application/octet-stream",
203
+ size: count(value.fileSize),
204
+ width: typeof value.width === "number" ? value.width : null,
205
+ height: typeof value.height === "number" ? value.height : null,
206
+ hasThumbnail: value.hasThumbnail === true,
207
+ isPrimary: value.isPrimary === true,
208
+ createdAt: text(value.creationTime),
209
+ // Only on a file whose Delete waits for a review (Drive's `removalTime` with it): every other file's record is as before.
210
+ ...(value.pendingRemoval === true ? { pendingRemoval: true, ...(text(value.removalTime) ? { removalAt: text(value.removalTime) } : {}) } : {}),
211
+ };
212
+ }
213
+ /** Reads Drive's `FileListResponseContract`. */
214
+ export function readOwnerFiles(value) {
215
+ const answer = isRecord(value) ? value : {};
216
+ const kinds = count(answer.fileKinds);
217
+ const maxBytes = count(answer.maxFileBytes);
218
+ // A picture's limits, Drive's settings (`Nexus:Drive:Pictures`); an older Drive says none, and the defaults apply, so
219
+ // every picture is still made small before it is sent.
220
+ const pictures = pictureLimits({ maxBytes, pictureMaxSide: count(answer.pictureMaxSide), pictureQuality: count(answer.pictureQuality), pictureMaxBytes: count(answer.pictureMaxBytes) });
221
+ const category = {
222
+ code: text(answer.categoryCode) ?? "",
223
+ name: text(answer.displayName) ?? "Files",
224
+ types: NEXUS_FILE_KINDS.filter(([flag]) => (kinds & flag) !== 0).map(([, type]) => type),
225
+ maxBytes,
226
+ maxCount: count(answer.maxFileCount),
227
+ thumbnailSize: count(answer.thumbnailSize),
228
+ canWrite: answer.canWrite === true,
229
+ pictureMaxSide: pictures.maxSide,
230
+ pictureQuality: pictures.quality,
231
+ pictureMaxBytes: pictures.maxBytes,
232
+ ...(answer.holdsRemovals === true ? { holdsRemovals: true } : {}),
233
+ };
234
+ const files = (Array.isArray(answer.files) ? answer.files : []).map(readStoredFile).filter((file) => file !== null);
235
+ return { category, files };
236
+ }