nexus-shared 1.1.21 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +262 -0
- package/LICENSE +21 -0
- package/README.md +1000 -0
- package/dist/Client.Index.d.ts +55 -0
- package/dist/Client.Index.js +60 -0
- package/dist/Components/Chats/Chat.d.ts +28 -0
- package/dist/Components/Chats/Chat.js +19 -0
- package/dist/Components/Chats/ChatButton.d.ts +26 -0
- package/dist/Components/Chats/ChatButton.js +41 -0
- package/dist/Components/Chats/ChatComposer.d.ts +41 -0
- package/dist/Components/Chats/ChatComposer.js +177 -0
- package/dist/Components/Chats/ChatConversations.d.ts +35 -0
- package/dist/Components/Chats/ChatConversations.js +38 -0
- package/dist/Components/Chats/ChatPanel.d.ts +66 -0
- package/dist/Components/Chats/ChatPanel.js +94 -0
- package/dist/Components/Chats/ChatParts.d.ts +87 -0
- package/dist/Components/Chats/ChatParts.js +100 -0
- package/dist/Components/Chats/ChatThread.d.ts +22 -0
- package/dist/Components/Chats/ChatThread.js +96 -0
- package/dist/Components/Documents/Button.d.ts +29 -0
- package/dist/Components/Documents/Button.js +11 -0
- package/dist/Components/Documents/Menu.d.ts +102 -0
- package/dist/Components/Documents/Menu.js +242 -0
- package/dist/Components/Documents/SplitButton.d.ts +64 -0
- package/dist/Components/Documents/SplitButton.js +129 -0
- package/dist/Components/Documents/TabButtons.d.ts +80 -0
- package/dist/Components/Documents/TabButtons.js +38 -0
- package/dist/Components/Forms/ApiForm.d.ts +23 -0
- package/dist/Components/Forms/ApiForm.js +100 -0
- package/dist/Components/Forms/Crud.d.ts +17 -0
- package/dist/Components/Forms/Crud.js +708 -0
- package/dist/Components/Forms/CrudParts.d.ts +15 -0
- package/dist/Components/Forms/CrudParts.js +36 -0
- package/dist/Components/Forms/ExcelImport.d.ts +42 -0
- package/dist/Components/Forms/ExcelImport.js +190 -0
- package/dist/Components/Forms/Form.d.ts +52 -0
- package/dist/Components/Forms/Form.js +323 -0
- package/dist/Components/Forms/SubmitForm.d.ts +65 -0
- package/dist/Components/Forms/SubmitForm.js +198 -0
- package/dist/Components/Inputs/Calendar.d.ts +69 -0
- package/dist/Components/Inputs/Calendar.js +258 -0
- package/dist/Components/Inputs/CheckBox.d.ts +53 -0
- package/dist/Components/Inputs/CheckBox.js +87 -0
- package/dist/Components/Inputs/CheckBoxGroup.d.ts +20 -0
- package/dist/Components/Inputs/CheckBoxGroup.js +80 -0
- package/dist/Components/Inputs/ChoiceField.d.ts +122 -0
- package/dist/Components/Inputs/ChoiceField.js +169 -0
- package/dist/Components/Inputs/DatePicker.d.ts +54 -0
- package/dist/Components/Inputs/DatePicker.js +246 -0
- package/dist/Components/Inputs/DateRangePicker.d.ts +62 -0
- package/dist/Components/Inputs/DateRangePicker.js +283 -0
- package/dist/Components/Inputs/DateTimePicker.d.ts +75 -0
- package/dist/Components/Inputs/DateTimePicker.js +400 -0
- package/dist/Components/Inputs/Dropdown.d.ts +117 -0
- package/dist/Components/Inputs/Dropdown.js +839 -0
- package/dist/Components/Inputs/DropdownParts.d.ts +84 -0
- package/dist/Components/Inputs/DropdownParts.js +102 -0
- package/dist/Components/Inputs/EditorShell.d.ts +45 -0
- package/dist/Components/Inputs/EditorShell.js +214 -0
- package/dist/Components/Inputs/Field.d.ts +53 -0
- package/dist/Components/Inputs/Field.js +16 -0
- package/dist/Components/Inputs/GroupForm.d.ts +20 -0
- package/dist/Components/Inputs/GroupForm.js +172 -0
- package/dist/Components/Inputs/InputField.d.ts +59 -0
- package/dist/Components/Inputs/InputField.js +169 -0
- package/dist/Components/Inputs/InputRenderer.d.ts +37 -0
- package/dist/Components/Inputs/InputRenderer.js +99 -0
- package/dist/Components/Inputs/MarkdownEditor.d.ts +67 -0
- package/dist/Components/Inputs/MarkdownEditor.js +289 -0
- package/dist/Components/Inputs/NumberBox.d.ts +53 -0
- package/dist/Components/Inputs/NumberBox.js +228 -0
- package/dist/Components/Inputs/RadioGroup.d.ts +20 -0
- package/dist/Components/Inputs/RadioGroup.js +43 -0
- package/dist/Components/Inputs/ReadOnlyNotice.d.ts +36 -0
- package/dist/Components/Inputs/ReadOnlyNotice.js +117 -0
- package/dist/Components/Inputs/RichTextEditor.d.ts +103 -0
- package/dist/Components/Inputs/RichTextEditor.js +1170 -0
- package/dist/Components/Inputs/RichTextParts.d.ts +63 -0
- package/dist/Components/Inputs/RichTextParts.js +157 -0
- package/dist/Components/Inputs/RowsInput.d.ts +115 -0
- package/dist/Components/Inputs/RowsInput.js +308 -0
- package/dist/Components/Inputs/Slider.d.ts +87 -0
- package/dist/Components/Inputs/Slider.js +216 -0
- package/dist/Components/Inputs/TabularForm.d.ts +27 -0
- package/dist/Components/Inputs/TabularForm.js +348 -0
- package/dist/Components/Inputs/TextArea.d.ts +43 -0
- package/dist/Components/Inputs/TextArea.js +31 -0
- package/dist/Components/Inputs/TextBox.d.ts +52 -0
- package/dist/Components/Inputs/TextBox.js +58 -0
- package/dist/Components/Inputs/TextField.d.ts +31 -0
- package/dist/Components/Inputs/TextField.js +35 -0
- package/dist/Components/Inputs/TimePanel.d.ts +34 -0
- package/dist/Components/Inputs/TimePanel.js +288 -0
- package/dist/Components/Inputs/TimePicker.d.ts +54 -0
- package/dist/Components/Inputs/TimePicker.js +240 -0
- package/dist/Components/Inputs/TreeView.d.ts +160 -0
- package/dist/Components/Inputs/TreeView.js +947 -0
- package/dist/Components/Layouts/MessageBox.d.ts +71 -0
- package/dist/Components/Layouts/MessageBox.js +104 -0
- package/dist/Components/Layouts/Popup.d.ts +92 -0
- package/dist/Components/Layouts/Popup.js +376 -0
- package/dist/Components/Layouts/PopupContext.d.ts +2 -0
- package/dist/Components/Layouts/PopupContext.js +4 -0
- package/dist/Components/Layouts/PopupDock.d.ts +22 -0
- package/dist/Components/Layouts/PopupDock.js +121 -0
- package/dist/Components/Layouts/PopupHost.d.ts +56 -0
- package/dist/Components/Layouts/PopupHost.js +163 -0
- package/dist/Components/Layouts/PopupRules.d.ts +30 -0
- package/dist/Components/Layouts/PopupRules.js +63 -0
- package/dist/Components/Layouts/ThemePicker.d.ts +13 -0
- package/dist/Components/Layouts/ThemePicker.js +22 -0
- package/dist/Components/Layouts/ThemeScript.d.ts +14 -0
- package/dist/Components/Layouts/ThemeScript.js +13 -0
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +13 -0
- package/dist/Components/Layouts/ThemeSwitcher.js +49 -0
- package/dist/Components/Layouts/Toaster.d.ts +76 -0
- package/dist/Components/Layouts/Toaster.js +172 -0
- package/dist/Components/Viewers/DataTable.d.ts +25 -0
- package/dist/Components/Viewers/DataTable.js +596 -0
- package/dist/Components/Viewers/DataTableColumns.d.ts +28 -0
- package/dist/Components/Viewers/DataTableColumns.js +243 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +138 -0
- package/dist/Components/Viewers/DataTableParts.js +157 -0
- package/dist/Components/Viewers/MarkdownView.d.ts +26 -0
- package/dist/Components/Viewers/MarkdownView.js +79 -0
- package/dist/Components/Viewers/RichTextView.d.ts +18 -0
- package/dist/Components/Viewers/RichTextView.js +17 -0
- package/dist/Helpers/AnimationHelpers.d.ts +6 -0
- package/dist/Helpers/AnimationHelpers.js +35 -0
- package/dist/Helpers/ApiClient.d.ts +96 -0
- package/dist/Helpers/ApiClient.js +200 -0
- package/dist/Helpers/ApiFormHelpers.d.ts +49 -0
- package/dist/Helpers/ApiFormHelpers.js +120 -0
- package/dist/Helpers/ApiModules.d.ts +131 -0
- package/dist/Helpers/ApiModules.js +195 -0
- package/dist/Helpers/ApiResponses.d.ts +51 -0
- package/dist/Helpers/ApiResponses.js +250 -0
- package/dist/Helpers/ApiRoutes.d.ts +140 -0
- package/dist/Helpers/ApiRoutes.js +183 -0
- package/dist/Helpers/ApiToasts.d.ts +21 -0
- package/dist/Helpers/ApiToasts.js +22 -0
- package/dist/Helpers/ChatBackend.d.ts +134 -0
- package/dist/Helpers/ChatBackend.js +332 -0
- package/dist/Helpers/ChatHelpers.d.ts +189 -0
- package/dist/Helpers/ChatHelpers.js +486 -0
- package/dist/Helpers/ChatHooks.d.ts +81 -0
- package/dist/Helpers/ChatHooks.js +175 -0
- package/dist/Helpers/ChatStore.d.ts +132 -0
- package/dist/Helpers/ChatStore.js +394 -0
- package/dist/Helpers/ChatSummaries.d.ts +39 -0
- package/dist/Helpers/ChatSummaries.js +118 -0
- package/dist/Helpers/ClassHelpers.d.ts +3 -0
- package/dist/Helpers/ClassHelpers.js +4 -0
- package/dist/Helpers/CrudBackend.d.ts +98 -0
- package/dist/Helpers/CrudBackend.js +214 -0
- package/dist/Helpers/CrudHelpers.d.ts +111 -0
- package/dist/Helpers/CrudHelpers.js +267 -0
- package/dist/Helpers/DateHelpers.d.ts +88 -0
- package/dist/Helpers/DateHelpers.js +363 -0
- package/dist/Helpers/DatePreferences.d.ts +14 -0
- package/dist/Helpers/DatePreferences.js +94 -0
- package/dist/Helpers/DateTimeHelpers.d.ts +87 -0
- package/dist/Helpers/DateTimeHelpers.js +286 -0
- package/dist/Helpers/DragHelpers.d.ts +129 -0
- package/dist/Helpers/DragHelpers.js +281 -0
- package/dist/Helpers/DropdownHelpers.d.ts +56 -0
- package/dist/Helpers/DropdownHelpers.js +180 -0
- package/dist/Helpers/ExcelBackend.d.ts +106 -0
- package/dist/Helpers/ExcelBackend.js +290 -0
- package/dist/Helpers/ExcelHelpers.d.ts +98 -0
- package/dist/Helpers/ExcelHelpers.js +302 -0
- package/dist/Helpers/FormDrafts.d.ts +18 -0
- package/dist/Helpers/FormDrafts.js +76 -0
- package/dist/Helpers/FormStore.d.ts +233 -0
- package/dist/Helpers/FormStore.js +752 -0
- package/dist/Helpers/InputParamsHelpers.d.ts +79 -0
- package/dist/Helpers/InputParamsHelpers.js +388 -0
- package/dist/Helpers/MarkdownEditing.d.ts +27 -0
- package/dist/Helpers/MarkdownEditing.js +250 -0
- package/dist/Helpers/MarkdownHelpers.d.ts +82 -0
- package/dist/Helpers/MarkdownHelpers.js +775 -0
- package/dist/Helpers/MessageBuilder.d.ts +74 -0
- package/dist/Helpers/MessageBuilder.js +252 -0
- package/dist/Helpers/NepaliCalendar.d.ts +25 -0
- package/dist/Helpers/NepaliCalendar.js +107 -0
- package/dist/Helpers/NumberHelpers.d.ts +71 -0
- package/dist/Helpers/NumberHelpers.js +202 -0
- package/dist/Helpers/Permissions.d.ts +62 -0
- package/dist/Helpers/Permissions.js +133 -0
- package/dist/Helpers/PopoverHelpers.d.ts +68 -0
- package/dist/Helpers/PopoverHelpers.js +195 -0
- package/dist/Helpers/RichTextHelpers.d.ts +77 -0
- package/dist/Helpers/RichTextHelpers.js +1072 -0
- package/dist/Helpers/RowsStore.d.ts +166 -0
- package/dist/Helpers/RowsStore.js +606 -0
- package/dist/Helpers/TableColumns.d.ts +47 -0
- package/dist/Helpers/TableColumns.js +282 -0
- package/dist/Helpers/TableExport.d.ts +30 -0
- package/dist/Helpers/TableExport.js +97 -0
- package/dist/Helpers/TableHelpers.d.ts +110 -0
- package/dist/Helpers/TableHelpers.js +301 -0
- package/dist/Helpers/TableStore.d.ts +142 -0
- package/dist/Helpers/TableStore.js +530 -0
- package/dist/Helpers/ThemeHelpers.d.ts +15 -0
- package/dist/Helpers/ThemeHelpers.js +16 -0
- package/dist/Helpers/TimeHelpers.d.ts +73 -0
- package/dist/Helpers/TimeHelpers.js +253 -0
- package/dist/Helpers/TreeStore.d.ts +188 -0
- package/dist/Helpers/TreeStore.js +577 -0
- package/dist/Helpers/ValidationHelpers.d.ts +124 -0
- package/dist/Helpers/ValidationHelpers.js +301 -0
- package/dist/Interfaces/ApiInterfaces.d.ts +174 -0
- package/dist/Interfaces/ApiInterfaces.js +30 -0
- package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
- package/{src/sso-client.tsx → dist/Interfaces/ChatInterfaces.js} +1 -1
- package/dist/Interfaces/CrudInterfaces.d.ts +232 -0
- package/dist/Interfaces/CrudInterfaces.js +1 -0
- package/dist/Interfaces/DateInterfaces.d.ts +154 -0
- package/dist/Interfaces/DateInterfaces.js +29 -0
- package/dist/Interfaces/ExcelInterfaces.d.ts +215 -0
- package/dist/Interfaces/ExcelInterfaces.js +45 -0
- package/dist/Interfaces/FormInterfaces.d.ts +402 -0
- package/dist/Interfaces/FormInterfaces.js +1 -0
- package/dist/Interfaces/InputInterfaces.d.ts +141 -0
- package/dist/Interfaces/InputInterfaces.js +1 -0
- package/dist/Interfaces/MessageInterfaces.d.ts +111 -0
- package/dist/Interfaces/MessageInterfaces.js +5 -0
- package/dist/Interfaces/TableInterfaces.d.ts +351 -0
- package/dist/Interfaces/TableInterfaces.js +1 -0
- package/dist/Interfaces/ThemeInterfaces.d.ts +17 -0
- package/dist/Interfaces/ThemeInterfaces.js +17 -0
- package/dist/Interfaces/TypeInterfaces.d.ts +4 -0
- package/dist/Interfaces/TypeInterfaces.js +1 -0
- package/dist/Server.Index.d.ts +2 -0
- package/dist/Server.Index.js +5 -0
- package/dist/Services/ApiProxy.d.ts +32 -0
- package/dist/Services/ApiProxy.js +111 -0
- package/dist/Services/BrowserApi.d.ts +95 -0
- package/dist/Services/BrowserApi.js +129 -0
- package/dist/Services/ChatApi.d.ts +37 -0
- package/dist/Services/ChatApi.js +98 -0
- package/dist/Services/ExcelApi.d.ts +46 -0
- package/dist/Services/ExcelApi.js +196 -0
- package/dist/Services/ServerApi.d.ts +54 -0
- package/dist/Services/ServerApi.js +78 -0
- package/dist/Services/ThemeService.d.ts +17 -0
- package/dist/Services/ThemeService.js +83 -0
- package/dist/Shared.Index.d.ts +51 -0
- package/dist/Shared.Index.js +55 -0
- package/package.json +85 -33
- package/src/Styles/Nexus.Base.css +70 -0
- package/src/Styles/Nexus.Button.css +153 -0
- package/src/Styles/Nexus.Calendar.css +275 -0
- package/src/Styles/Nexus.Chat.css +1489 -0
- package/src/Styles/Nexus.Choice.css +345 -0
- package/src/Styles/Nexus.Crud.css +161 -0
- package/src/Styles/Nexus.DateTime.css +81 -0
- package/src/Styles/Nexus.Dropdown.css +774 -0
- package/src/Styles/Nexus.Editor.css +297 -0
- package/src/Styles/Nexus.Excel.css +370 -0
- package/src/Styles/Nexus.Form.css +321 -0
- package/src/Styles/Nexus.Index.css +36 -0
- package/src/Styles/Nexus.Input.css +637 -0
- package/src/Styles/Nexus.Markdown.css +265 -0
- package/src/Styles/Nexus.Menu.css +259 -0
- package/src/Styles/Nexus.Popup.css +731 -0
- package/src/Styles/Nexus.RichText.css +463 -0
- package/src/Styles/Nexus.Rows.css +596 -0
- package/src/Styles/Nexus.Slider.css +278 -0
- package/src/Styles/Nexus.Tab.Buttons.css +419 -0
- package/src/Styles/Nexus.Table.css +1296 -0
- package/src/Styles/Nexus.Theme.Picker.css +232 -0
- package/src/Styles/Nexus.Themes.css +194 -0
- package/src/Styles/Nexus.Time.css +147 -0
- package/src/Styles/Nexus.Toast.css +265 -0
- package/src/Styles/Nexus.Tokens.css +72 -0
- package/src/Styles/Nexus.Tree.css +435 -0
- package/src/api-services/authentication-service.tsx +0 -23
- package/src/api-services/preference-service.tsx +0 -5
- package/src/api-services/system-service.tsx +0 -29
- package/src/client.ts +0 -31
- package/src/components/documents/button.tsx +0 -133
- package/src/components/documents/icon-box.tsx +0 -93
- package/src/components/documents/page-title.tsx +0 -7
- package/src/components/documents/tab-button.tsx +0 -172
- package/src/components/documents/tag.tsx +0 -30
- package/src/components/index.js +0 -0
- package/src/components/inputs/checkbox-input.tsx +0 -66
- package/src/components/inputs/input-box.tsx +0 -48
- package/src/components/inputs/input-element.tsx +0 -65
- package/src/components/inputs/input-form.tsx +0 -164
- package/src/components/inputs/input.tsx +0 -181
- package/src/components/inputs/number-input.tsx +0 -108
- package/src/components/inputs/radiobox-input.tsx +0 -53
- package/src/components/inputs/textarea-input.tsx +0 -47
- package/src/components/inputs/textbox-input.tsx +0 -45
- package/src/components/layouts/global-dialogbox.tsx +0 -431
- package/src/components/layouts/global-layout.tsx +0 -64
- package/src/components/layouts/layout-helpers.tsx +0 -20
- package/src/components/layouts/panels/user-panel.tsx +0 -112
- package/src/components/layouts/utility-menu.tsx +0 -49
- package/src/components/panels/theme-panel.tsx +0 -46
- package/src/helpers/bitwise-helpers.tsx +0 -11
- package/src/helpers/browser-helpers.tsx +0 -156
- package/src/helpers/datasource-helpers.tsx +0 -99
- package/src/helpers/element-helpers.tsx +0 -123
- package/src/helpers/input-helpers.tsx +0 -74
- package/src/helpers/string-helpers.tsx +0 -28
- package/src/helpers/utility-helpers.tsx +0 -45
- package/src/helpers/validation-helpers.tsx +0 -302
- package/src/index.ts +0 -26
- package/src/interface.ts +0 -19
- package/src/interfaces/auth-token-interfaces.tsx +0 -6
- package/src/interfaces/browser-interfaces.tsx +0 -36
- package/src/interfaces/button-interfaces.tsx +0 -63
- package/src/interfaces/datasource-interfaces.tsx +0 -22
- package/src/interfaces/datatable-interfaces.tsx +0 -25
- package/src/interfaces/dialogbox-interfaces.tsx +0 -5
- package/src/interfaces/exception-interfaces.tsx +0 -99
- package/src/interfaces/http-interfaces.tsx +0 -129
- package/src/interfaces/icon-interfaces.tsx +0 -129
- package/src/interfaces/input-interfaces.tsx +0 -410
- package/src/interfaces/layout-interfaces.tsx +0 -191
- package/src/interfaces/menu-interfaces.tsx +0 -53
- package/src/interfaces/message-interfaces.tsx +0 -32
- package/src/interfaces/permission-interfaces.tsx +0 -9
- package/src/interfaces/storage-interfaces.tsx +0 -5
- package/src/interfaces/system-interfaces.tsx +0 -22
- package/src/interfaces/theme-interfaces.tsx +0 -123
- package/src/interfaces/type-interfaces.tsx +0 -28
- package/src/interfaces/user-interfaces.tsx +0 -47
- package/src/nexus-client.tsx +0 -21
- package/src/nexus.environments.tsx +0 -66
- package/src/proxy-api/proxy-backend.tsx +0 -60
- package/src/proxy-api/proxy-constants.tsx +0 -28
- package/src/proxy-api/proxy-helpers.tsx +0 -91
- package/src/proxy-api-client.tsx +0 -1
- package/src/proxy-api-server.tsx +0 -2
- package/src/services/http-client-services.tsx +0 -75
- package/src/services/http-services.tsx +0 -158
- package/src/services/loader-service.tsx +0 -185
- package/src/services/localstorage-service.tsx +0 -119
- package/src/services/message-services.tsx +0 -383
- package/src/services/theme-service.tsx +0 -163
- package/src/services/user-services.tsx +0 -10
- package/src/sso-config/auth-token-validation.ts +0 -16
- package/src/sso-config/callback-route.tsx +0 -35
- package/src/sso-config/cookie-encryption.ts +0 -51
- package/src/sso-config/cookie-helpers.tsx +0 -102
- package/src/sso-config/forgot-password-route.tsx +0 -36
- package/src/sso-config/oauth-callback-state.ts +0 -61
- package/src/sso-config/pkce-helpers.ts +0 -9
- package/src/sso-config/provider-complete-route.tsx +0 -18
- package/src/sso-config/redirect-context-actions.tsx +0 -45
- package/src/sso-config/redirect-context-helpers.tsx +0 -159
- package/src/sso-config/redirect-context-persist.tsx +0 -18
- package/src/sso-config/redirect-context-route.tsx +0 -45
- package/src/sso-config/refresh-route.tsx +0 -23
- package/src/sso-config/sign-in-route.tsx +0 -74
- package/src/sso-config/sign-out-route.tsx +0 -57
- package/src/sso-config/sign-up-route.tsx +0 -57
- package/src/sso-config/sso-backend-flow.ts +0 -38
- package/src/sso-config/sso-complete-route.tsx +0 -50
- package/src/sso-config/sso-config.ts +0 -59
- package/src/sso-config/sso-interfaces.tsx +0 -99
- package/src/sso-config/sso-response-helpers.tsx +0 -20
- package/src/sso-server.tsx +0 -21
- package/src/styles/nexus.animation.css +0 -269
- package/src/styles/nexus.core.css +0 -119
- package/src/styles/nexus.dialog.css +0 -144
- package/src/styles/nexus.grid-view.css +0 -42
- package/src/styles/nexus.icon.css +0 -51
- package/src/styles/nexus.input.css +0 -207
- package/src/styles/nexus.loader.css +0 -11
- package/src/styles/nexus.logic.css +0 -47
- package/src/styles/nexus.utility.css +0 -347
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { isAbortError } from "./ApiResponses.js";
|
|
2
|
+
import { EMPTY_CHAT_SUMMARY, firstUnreadChatId, mergeChatMessages, sameChatId, summarizeChatMessages } from "./ChatHelpers.js";
|
|
3
|
+
import { nowUtcDateTime } from "./DateTimeHelpers.js";
|
|
4
|
+
/** What a source can do, read from the source itself. */
|
|
5
|
+
export function chatFeaturesOf(source) {
|
|
6
|
+
return {
|
|
7
|
+
participants: typeof source.participants === "function",
|
|
8
|
+
addParticipants: typeof source.addParticipants === "function",
|
|
9
|
+
markRead: typeof source.markRead === "function",
|
|
10
|
+
remove: typeof source.remove === "function",
|
|
11
|
+
watch: typeof source.watch === "function",
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const DEFAULT_PAGE_SIZE = 30;
|
|
15
|
+
const DEFAULT_POLL = 15_000;
|
|
16
|
+
const failureText = (error, fallback) => {
|
|
17
|
+
const message = error instanceof Error ? error.message.trim() : typeof error === "string" ? error.trim() : "";
|
|
18
|
+
return message || fallback;
|
|
19
|
+
};
|
|
20
|
+
/** A call the store itself dropped — the panel closed, the conversation changed — says nothing. */
|
|
21
|
+
const shown = (error, fallback) => (isAbortError(error) ? null : failureText(error, fallback));
|
|
22
|
+
export class ChatStore {
|
|
23
|
+
options;
|
|
24
|
+
listeners = new Set();
|
|
25
|
+
snapshot;
|
|
26
|
+
messages = [];
|
|
27
|
+
pending = new Map();
|
|
28
|
+
revision = 0;
|
|
29
|
+
loaded = false;
|
|
30
|
+
poll = 0;
|
|
31
|
+
unwatch = null;
|
|
32
|
+
running = false;
|
|
33
|
+
// Reading caught up with the conversation: the line already drawn has done its job, and the next message to arrive
|
|
34
|
+
// gets a line of its own rather than leaving the old one stranded above messages that have been read.
|
|
35
|
+
readPast = false;
|
|
36
|
+
aborts = new Set();
|
|
37
|
+
clientSequence = 0;
|
|
38
|
+
constructor(options) {
|
|
39
|
+
this.options = options;
|
|
40
|
+
this.snapshot = {
|
|
41
|
+
messages: [],
|
|
42
|
+
participants: [],
|
|
43
|
+
summary: options.summary ?? EMPTY_CHAT_SUMMARY,
|
|
44
|
+
canSend: true,
|
|
45
|
+
features: chatFeaturesOf(options.source),
|
|
46
|
+
loading: false,
|
|
47
|
+
loaded: false,
|
|
48
|
+
loadingOlder: false,
|
|
49
|
+
hasMore: false,
|
|
50
|
+
error: null,
|
|
51
|
+
sending: 0,
|
|
52
|
+
failed: 0,
|
|
53
|
+
unreadFrom: null,
|
|
54
|
+
revision: 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Reading
|
|
58
|
+
subscribe = (listener) => {
|
|
59
|
+
this.listeners.add(listener);
|
|
60
|
+
return () => this.listeners.delete(listener);
|
|
61
|
+
};
|
|
62
|
+
getSnapshot = () => this.snapshot;
|
|
63
|
+
/** The newest message the server knows about: what the read mark and a refresh count from. */
|
|
64
|
+
newestSaved() {
|
|
65
|
+
for (let index = this.messages.length - 1; index >= 0; index--) {
|
|
66
|
+
const message = this.messages[index];
|
|
67
|
+
if (message.status !== "sending" && message.status !== "failed")
|
|
68
|
+
return message;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
oldest() {
|
|
73
|
+
return this.messages.find(message => message.status !== "sending" && message.status !== "failed");
|
|
74
|
+
}
|
|
75
|
+
// Writing
|
|
76
|
+
set(changes) {
|
|
77
|
+
this.snapshot = { ...this.snapshot, ...changes, revision: ++this.revision };
|
|
78
|
+
for (const listener of this.listeners)
|
|
79
|
+
listener();
|
|
80
|
+
}
|
|
81
|
+
/** Puts the messages and the ones still on their way together, newest last, and tells the snapshot. */
|
|
82
|
+
commit(changes = {}) {
|
|
83
|
+
const waiting = [...this.pending.values()].map(entry => entry.message);
|
|
84
|
+
const sending = waiting.filter(message => message.status === "sending").length;
|
|
85
|
+
this.set({
|
|
86
|
+
...changes,
|
|
87
|
+
messages: mergeChatMessages(this.messages, waiting),
|
|
88
|
+
sending,
|
|
89
|
+
failed: waiting.length - sending,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
announce(summary) {
|
|
93
|
+
this.set({ summary });
|
|
94
|
+
this.options.onSummary?.(summary);
|
|
95
|
+
}
|
|
96
|
+
/** The counts after the messages changed: the server's, with what this browser knows added. */
|
|
97
|
+
recount(given) {
|
|
98
|
+
const held = summarizeChatMessages(this.messages, this.options.userId, null);
|
|
99
|
+
const summary = {
|
|
100
|
+
...this.snapshot.summary,
|
|
101
|
+
...given,
|
|
102
|
+
// The server counts the whole conversation; the browser only the part it holds, so the larger count is the true one.
|
|
103
|
+
messageCount: Math.max(given?.messageCount ?? 0, this.snapshot.summary.messageCount, held.messageCount),
|
|
104
|
+
unreadCount: given?.unreadCount ?? this.snapshot.summary.unreadCount,
|
|
105
|
+
lastMessageAt: held.lastMessageAt ?? given?.lastMessageAt ?? this.snapshot.summary.lastMessageAt ?? null,
|
|
106
|
+
lastMessageText: held.lastMessageText ?? given?.lastMessageText ?? null,
|
|
107
|
+
lastMessageBy: held.lastMessageBy ?? given?.lastMessageBy ?? null,
|
|
108
|
+
};
|
|
109
|
+
this.announce(summary);
|
|
110
|
+
}
|
|
111
|
+
track(run) {
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
this.aborts.add(controller);
|
|
114
|
+
return run(controller.signal).finally(() => this.aborts.delete(controller));
|
|
115
|
+
}
|
|
116
|
+
// Loading
|
|
117
|
+
/** The newest page. Call it when the conversation opens, and again to start over. */
|
|
118
|
+
async load() {
|
|
119
|
+
this.set({ loading: true, error: null });
|
|
120
|
+
try {
|
|
121
|
+
const page = await this.track(signal => this.options.source.history({ take: this.pageSize(), signal }));
|
|
122
|
+
this.messages = mergeChatMessages([], page.messages);
|
|
123
|
+
this.loaded = true;
|
|
124
|
+
this.readPast = false;
|
|
125
|
+
const unread = page.summary?.unreadCount ?? this.snapshot.summary.unreadCount;
|
|
126
|
+
this.commit({
|
|
127
|
+
loading: false,
|
|
128
|
+
loaded: true,
|
|
129
|
+
hasMore: page.hasMore ?? page.messages.length >= this.pageSize(),
|
|
130
|
+
participants: page.participants ?? this.snapshot.participants,
|
|
131
|
+
canSend: page.canSend ?? true,
|
|
132
|
+
// Fixed here, so the line stays put while the conversation is read.
|
|
133
|
+
unreadFrom: firstUnreadChatId(this.messages, this.options.userId, unread),
|
|
134
|
+
});
|
|
135
|
+
this.recount(page.summary);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
this.set({ loading: false, error: shown(error, "The conversation could not be loaded.") });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** One page of older messages, above the ones held. */
|
|
142
|
+
async loadOlder() {
|
|
143
|
+
if (this.snapshot.loadingOlder || !this.snapshot.hasMore)
|
|
144
|
+
return;
|
|
145
|
+
const before = this.oldest()?.id;
|
|
146
|
+
if (before === undefined)
|
|
147
|
+
return;
|
|
148
|
+
this.set({ loadingOlder: true, error: null });
|
|
149
|
+
try {
|
|
150
|
+
const page = await this.track(signal => this.options.source.history({ before, take: this.pageSize(), signal }));
|
|
151
|
+
this.messages = mergeChatMessages(this.messages, page.messages);
|
|
152
|
+
this.commit({ loadingOlder: false, hasMore: page.hasMore ?? page.messages.length >= this.pageSize() });
|
|
153
|
+
this.recount(page.summary);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
this.set({ loadingOlder: false, error: shown(error, "The older messages could not be loaded.") });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Whatever was written since the newest message held. Polling and the Retry button both call it. */
|
|
160
|
+
async refresh() {
|
|
161
|
+
if (!this.loaded)
|
|
162
|
+
return this.load();
|
|
163
|
+
const after = this.newestSaved()?.id;
|
|
164
|
+
try {
|
|
165
|
+
const page = await this.track(signal => this.options.source.history({ after: after ?? null, take: this.pageSize(), signal }));
|
|
166
|
+
if (page.messages.length === 0) {
|
|
167
|
+
if (this.snapshot.error)
|
|
168
|
+
this.set({ error: null });
|
|
169
|
+
if (page.summary)
|
|
170
|
+
this.recount(page.summary);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.take(page.messages, page);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
this.set({ error: shown(error, "New messages could not be loaded.") });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Messages that arrived on their own: a poll, or a source that watches. */
|
|
180
|
+
take = (incoming, page) => {
|
|
181
|
+
const fresh = incoming.filter(message => !sameChatId(message.authorId, this.options.userId) && !message.isSystem);
|
|
182
|
+
// Someone else wrote while the panel was open: the line goes before the first of them, unless a line is already
|
|
183
|
+
// standing in front of messages this reader has not caught up with yet.
|
|
184
|
+
const line = fresh.length > 0 && (this.snapshot.unreadFrom === null || this.readPast);
|
|
185
|
+
if (line)
|
|
186
|
+
this.readPast = false;
|
|
187
|
+
this.messages = mergeChatMessages(this.messages, incoming);
|
|
188
|
+
this.commit({
|
|
189
|
+
error: null,
|
|
190
|
+
...(page?.participants && { participants: page.participants }),
|
|
191
|
+
...(line && { unreadFrom: fresh[0].id }),
|
|
192
|
+
});
|
|
193
|
+
this.recount({
|
|
194
|
+
...page?.summary,
|
|
195
|
+
unreadCount: page?.summary?.unreadCount ?? this.snapshot.summary.unreadCount + fresh.length,
|
|
196
|
+
messageCount: page?.summary?.messageCount ?? this.snapshot.summary.messageCount + incoming.length,
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
pageSize() {
|
|
200
|
+
return this.options.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
201
|
+
}
|
|
202
|
+
// Sending
|
|
203
|
+
/** Sends a message. It is on screen before the call starts; if the call fails it stays, with Retry beside it. */
|
|
204
|
+
async send(body, extras = {}) {
|
|
205
|
+
const written = body.trim();
|
|
206
|
+
if (!written && !extras.files?.length)
|
|
207
|
+
return false;
|
|
208
|
+
const clientId = `nx-chat-${Date.now().toString(36)}-${++this.clientSequence}`;
|
|
209
|
+
const draft = { body: written, clientId, ...(extras.replyToId && { replyToId: extras.replyToId }), ...(extras.files?.length && { files: extras.files }) };
|
|
210
|
+
// The sender's own name and photo, from whoever knows them: the participants, else their last message here.
|
|
211
|
+
const me = this.snapshot.participants.find(person => sameChatId(person.id, this.options.userId));
|
|
212
|
+
const author = this.messages.find(message => sameChatId(message.authorId, this.options.userId));
|
|
213
|
+
this.pending.set(clientId, {
|
|
214
|
+
draft,
|
|
215
|
+
message: {
|
|
216
|
+
id: clientId,
|
|
217
|
+
clientId,
|
|
218
|
+
authorId: this.options.userId,
|
|
219
|
+
authorName: me?.name ?? author?.authorName ?? null,
|
|
220
|
+
authorAvatar: me?.avatar ?? author?.authorAvatar ?? null,
|
|
221
|
+
body: written,
|
|
222
|
+
createdAt: (this.options.now ?? nowUtcDateTime)(),
|
|
223
|
+
status: "sending",
|
|
224
|
+
replyToId: draft.replyToId ?? null,
|
|
225
|
+
...(draft.files?.length && { attachments: draft.files.map(file => ({ name: file.name, size: file.size, contentType: file.type })) }),
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
this.commit();
|
|
229
|
+
return this.deliver(clientId);
|
|
230
|
+
}
|
|
231
|
+
/** Sends a failed message again, with the text it was written with. */
|
|
232
|
+
retry = (clientId) => {
|
|
233
|
+
const entry = this.pending.get(clientId);
|
|
234
|
+
if (!entry || entry.message.status === "sending")
|
|
235
|
+
return Promise.resolve(false);
|
|
236
|
+
this.pending.set(clientId, { ...entry, message: { ...entry.message, status: "sending", error: null } });
|
|
237
|
+
this.commit();
|
|
238
|
+
return this.deliver(clientId);
|
|
239
|
+
};
|
|
240
|
+
/** Drops a failed message. Nothing was saved, so nothing is deleted. */
|
|
241
|
+
discard = (clientId) => {
|
|
242
|
+
if (!this.pending.delete(clientId))
|
|
243
|
+
return;
|
|
244
|
+
this.messages = this.messages.filter(message => message.clientId !== clientId);
|
|
245
|
+
this.commit();
|
|
246
|
+
};
|
|
247
|
+
/** The text of a failed message, so the composer can put it back for editing. */
|
|
248
|
+
draftOf(clientId) {
|
|
249
|
+
return this.pending.get(clientId)?.draft;
|
|
250
|
+
}
|
|
251
|
+
async deliver(clientId) {
|
|
252
|
+
const entry = this.pending.get(clientId);
|
|
253
|
+
if (!entry)
|
|
254
|
+
return false;
|
|
255
|
+
try {
|
|
256
|
+
const saved = await this.track(signal => this.options.source.send(entry.draft, signal));
|
|
257
|
+
this.pending.delete(clientId);
|
|
258
|
+
this.messages = mergeChatMessages(this.messages, [{ ...saved, clientId, status: "sent" }]);
|
|
259
|
+
this.commit();
|
|
260
|
+
this.recount();
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
const failed = this.pending.get(clientId);
|
|
265
|
+
// Dropped while it was on its way: nothing to mark.
|
|
266
|
+
if (failed) {
|
|
267
|
+
this.pending.set(clientId, { ...failed, message: { ...failed.message, status: "failed", error: shown(error, "Not sent.") ?? "Not sent." } });
|
|
268
|
+
this.commit();
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Removes a message the user may remove. */
|
|
274
|
+
async remove(messageId) {
|
|
275
|
+
const source = this.options.source;
|
|
276
|
+
if (!source.remove)
|
|
277
|
+
return false;
|
|
278
|
+
const held = this.messages;
|
|
279
|
+
this.messages = this.messages.filter(message => !sameChatId(message.id, messageId));
|
|
280
|
+
this.commit();
|
|
281
|
+
try {
|
|
282
|
+
await this.track(signal => source.remove(messageId, signal));
|
|
283
|
+
this.recount({ ...this.snapshot.summary, messageCount: Math.max(0, this.snapshot.summary.messageCount - 1) });
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
this.messages = held;
|
|
288
|
+
this.commit({ error: shown(error, "The message could not be removed.") });
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Reading marks
|
|
293
|
+
/** Says the user has read everything held. Clears the count; the line stays until the conversation is opened again. */
|
|
294
|
+
async markRead() {
|
|
295
|
+
const newest = this.newestSaved();
|
|
296
|
+
if (!newest || this.snapshot.summary.unreadCount === 0)
|
|
297
|
+
return;
|
|
298
|
+
this.announce({ ...this.snapshot.summary, unreadCount: 0 });
|
|
299
|
+
this.readPast = true;
|
|
300
|
+
const mark = this.options.source.markRead;
|
|
301
|
+
// A source without a read mark still clears the count here, so the icon in the list behind the panel stops shouting.
|
|
302
|
+
if (!mark)
|
|
303
|
+
return;
|
|
304
|
+
try {
|
|
305
|
+
await this.track(signal => mark(newest.id, signal));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// A read mark that did not reach the server is not worth a message: the next load counts again.
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** Takes the "New messages" line away, once it has been seen. */
|
|
312
|
+
clearUnreadLine = () => {
|
|
313
|
+
if (this.snapshot.unreadFrom !== null)
|
|
314
|
+
this.set({ unreadFrom: null });
|
|
315
|
+
};
|
|
316
|
+
// Participants
|
|
317
|
+
/** Searches the people in the conversation and the ones who could join. Empty when the source has no search. */
|
|
318
|
+
searchParticipants = async (query) => {
|
|
319
|
+
if (!this.options.source.participants)
|
|
320
|
+
return { items: [], hasMore: false, total: 0 };
|
|
321
|
+
return this.options.source.participants(query);
|
|
322
|
+
};
|
|
323
|
+
/** Adds people, and keeps the ones the source answers with. */
|
|
324
|
+
async addParticipants(ids) {
|
|
325
|
+
const source = this.options.source;
|
|
326
|
+
if (!source.addParticipants || ids.length === 0)
|
|
327
|
+
return false;
|
|
328
|
+
try {
|
|
329
|
+
const added = await this.track(signal => source.addParticipants(ids, signal));
|
|
330
|
+
const known = new Set(this.snapshot.participants.map(person => String(person.id)));
|
|
331
|
+
this.set({ participants: [...this.snapshot.participants, ...added.filter(person => !known.has(String(person.id)))] });
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
this.set({ error: shown(error, "The people could not be added.") });
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/** Participants the page already has, so the panel needs no call. */
|
|
340
|
+
setParticipants = (participants) => {
|
|
341
|
+
this.set({ participants });
|
|
342
|
+
};
|
|
343
|
+
/**
|
|
344
|
+
* Points the store at another source, keeping the messages it holds. A page that builds its source while it renders
|
|
345
|
+
* hands in a new object every time; this takes it without starting the conversation over, and keeps the store's own
|
|
346
|
+
* reading of what the source can do (a search, a read mark, Delete) true.
|
|
347
|
+
*/
|
|
348
|
+
setSource = (source) => {
|
|
349
|
+
if (source === this.options.source)
|
|
350
|
+
return;
|
|
351
|
+
this.options = { ...this.options, source };
|
|
352
|
+
const features = chatFeaturesOf(source);
|
|
353
|
+
const held = this.snapshot.features;
|
|
354
|
+
const same = Object.keys(features).every(key => features[key] === held[key]);
|
|
355
|
+
if (!same)
|
|
356
|
+
this.set({ features });
|
|
357
|
+
};
|
|
358
|
+
// Watching
|
|
359
|
+
/** Starts watching for new messages: the source's own watch, else a poll. */
|
|
360
|
+
start() {
|
|
361
|
+
if (this.running)
|
|
362
|
+
return;
|
|
363
|
+
this.running = true;
|
|
364
|
+
if (this.options.source.watch) {
|
|
365
|
+
this.unwatch = this.options.source.watch(messages => this.take(messages));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const every = this.options.pollInterval ?? DEFAULT_POLL;
|
|
369
|
+
if (every <= 0)
|
|
370
|
+
return;
|
|
371
|
+
this.poll = setInterval(() => {
|
|
372
|
+
if (this.options.isActive?.() === false)
|
|
373
|
+
return;
|
|
374
|
+
void this.refresh();
|
|
375
|
+
}, every);
|
|
376
|
+
}
|
|
377
|
+
/** Stops watching. The messages stay. */
|
|
378
|
+
stop() {
|
|
379
|
+
this.running = false;
|
|
380
|
+
if (this.poll)
|
|
381
|
+
clearInterval(this.poll);
|
|
382
|
+
this.poll = 0;
|
|
383
|
+
this.unwatch?.();
|
|
384
|
+
this.unwatch = null;
|
|
385
|
+
}
|
|
386
|
+
/** Stops everything and drops the calls on their way. The messages and unsent drafts stay, for a panel that reopens. */
|
|
387
|
+
dispose() {
|
|
388
|
+
this.stop();
|
|
389
|
+
for (const controller of this.aborts)
|
|
390
|
+
controller.abort();
|
|
391
|
+
this.aborts.clear();
|
|
392
|
+
this.listeners.clear();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AnyChatScope, ChatDomainValue, ChatId, ChatSummary } from "../Interfaces/ChatInterfaces.ts";
|
|
2
|
+
/** Asks a backend for the counts of many records of one domain. The answer is keyed by parent id as text. */
|
|
3
|
+
export type ChatSummaryLoader = (domain: ChatDomainValue, parentIds: readonly ChatId[], userId: ChatId) => Promise<Map<string, ChatSummary>>;
|
|
4
|
+
/** Whether a record's counts are known, on their way, or could not be read. */
|
|
5
|
+
export type ChatSummaryState = "unknown" | "loading" | "ready" | "failed";
|
|
6
|
+
export interface ChatSummaryCacheOptions {
|
|
7
|
+
load: ChatSummaryLoader;
|
|
8
|
+
/** Milliseconds to wait for more icons before asking, so a table's rows go in one call. Default 20. */
|
|
9
|
+
window?: number;
|
|
10
|
+
/** Most parent ids in one call. Default 200. */
|
|
11
|
+
batchSize?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class ChatSummaryCache {
|
|
14
|
+
private options;
|
|
15
|
+
private summaries;
|
|
16
|
+
private states;
|
|
17
|
+
private listeners;
|
|
18
|
+
private queued;
|
|
19
|
+
private timer;
|
|
20
|
+
constructor(options: ChatSummaryCacheOptions);
|
|
21
|
+
/** The key a record's counts are held under. The reader is part of it: unread is counted per person. */
|
|
22
|
+
private keyOf;
|
|
23
|
+
/** The counts, when they are known. */
|
|
24
|
+
get(scope: AnyChatScope): ChatSummary | undefined;
|
|
25
|
+
stateOf(scope: AnyChatScope): ChatSummaryState;
|
|
26
|
+
/** Asks for a record's counts, unless they are known or already on their way. Several calls collapse into one request. */
|
|
27
|
+
readonly want: (scope: AnyChatScope) => void;
|
|
28
|
+
/** Writes a record's counts, and tells its icon: after a panel read them, or from a list's own rows. */
|
|
29
|
+
readonly set: (scope: AnyChatScope, summary: ChatSummary) => void;
|
|
30
|
+
/** Forgets a record's counts, so the next icon that wants them asks again. */
|
|
31
|
+
readonly forget: (scope: AnyChatScope) => void;
|
|
32
|
+
/** Forgets everything: after signing in as somebody else, or reloading a list. */
|
|
33
|
+
readonly clear: () => void;
|
|
34
|
+
/** Called whenever a record's counts change. */
|
|
35
|
+
readonly subscribe: (scope: AnyChatScope, listener: () => void) => (() => void);
|
|
36
|
+
private tell;
|
|
37
|
+
private flush;
|
|
38
|
+
private ask;
|
|
39
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { chatScopeKey, EMPTY_CHAT_SUMMARY } from "./ChatHelpers.js";
|
|
2
|
+
const DEFAULT_WINDOW = 20;
|
|
3
|
+
const DEFAULT_BATCH = 200;
|
|
4
|
+
export class ChatSummaryCache {
|
|
5
|
+
options;
|
|
6
|
+
summaries = new Map();
|
|
7
|
+
states = new Map();
|
|
8
|
+
listeners = new Map();
|
|
9
|
+
queued = new Map();
|
|
10
|
+
timer = 0;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
}
|
|
14
|
+
/** The key a record's counts are held under. The reader is part of it: unread is counted per person. */
|
|
15
|
+
keyOf(scope) {
|
|
16
|
+
return `${chatScopeKey(scope)}:${String(scope.userId)}`;
|
|
17
|
+
}
|
|
18
|
+
/** The counts, when they are known. */
|
|
19
|
+
get(scope) {
|
|
20
|
+
return this.summaries.get(this.keyOf(scope));
|
|
21
|
+
}
|
|
22
|
+
stateOf(scope) {
|
|
23
|
+
return this.states.get(this.keyOf(scope)) ?? "unknown";
|
|
24
|
+
}
|
|
25
|
+
/** Asks for a record's counts, unless they are known or already on their way. Several calls collapse into one request. */
|
|
26
|
+
want = (scope) => {
|
|
27
|
+
const key = this.keyOf(scope);
|
|
28
|
+
const state = this.states.get(key);
|
|
29
|
+
if (state === "loading" || state === "ready")
|
|
30
|
+
return;
|
|
31
|
+
this.states.set(key, "loading");
|
|
32
|
+
const batch = `${String(scope.domain)}\u0000${String(scope.userId)}`;
|
|
33
|
+
const queued = this.queued.get(batch) ?? { domain: scope.domain, userId: scope.userId, ids: new Map() };
|
|
34
|
+
queued.ids.set(String(scope.parentId), scope.parentId);
|
|
35
|
+
this.queued.set(batch, queued);
|
|
36
|
+
if (this.timer)
|
|
37
|
+
return;
|
|
38
|
+
this.timer = setTimeout(() => {
|
|
39
|
+
this.timer = 0;
|
|
40
|
+
void this.flush();
|
|
41
|
+
}, this.options.window ?? DEFAULT_WINDOW);
|
|
42
|
+
};
|
|
43
|
+
/** Writes a record's counts, and tells its icon: after a panel read them, or from a list's own rows. */
|
|
44
|
+
set = (scope, summary) => {
|
|
45
|
+
const key = this.keyOf(scope);
|
|
46
|
+
const held = this.summaries.get(key);
|
|
47
|
+
if (held && held.messageCount === summary.messageCount && held.unreadCount === summary.unreadCount && held.lastMessageAt === summary.lastMessageAt) {
|
|
48
|
+
this.states.set(key, "ready");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
this.summaries.set(key, summary);
|
|
52
|
+
this.states.set(key, "ready");
|
|
53
|
+
this.tell(key);
|
|
54
|
+
};
|
|
55
|
+
/** Forgets a record's counts, so the next icon that wants them asks again. */
|
|
56
|
+
forget = (scope) => {
|
|
57
|
+
const key = this.keyOf(scope);
|
|
58
|
+
this.summaries.delete(key);
|
|
59
|
+
this.states.delete(key);
|
|
60
|
+
this.tell(key);
|
|
61
|
+
};
|
|
62
|
+
/** Forgets everything: after signing in as somebody else, or reloading a list. */
|
|
63
|
+
clear = () => {
|
|
64
|
+
const keys = [...this.states.keys()];
|
|
65
|
+
this.summaries.clear();
|
|
66
|
+
this.states.clear();
|
|
67
|
+
this.queued.clear();
|
|
68
|
+
for (const key of keys)
|
|
69
|
+
this.tell(key);
|
|
70
|
+
};
|
|
71
|
+
/** Called whenever a record's counts change. */
|
|
72
|
+
subscribe = (scope, listener) => {
|
|
73
|
+
const key = this.keyOf(scope);
|
|
74
|
+
const set = this.listeners.get(key) ?? new Set();
|
|
75
|
+
set.add(listener);
|
|
76
|
+
this.listeners.set(key, set);
|
|
77
|
+
return () => {
|
|
78
|
+
set.delete(listener);
|
|
79
|
+
if (set.size === 0)
|
|
80
|
+
this.listeners.delete(key);
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
tell(key) {
|
|
84
|
+
for (const listener of this.listeners.get(key) ?? [])
|
|
85
|
+
listener();
|
|
86
|
+
}
|
|
87
|
+
async flush() {
|
|
88
|
+
const batches = [...this.queued.values()];
|
|
89
|
+
this.queued.clear();
|
|
90
|
+
const size = this.options.batchSize ?? DEFAULT_BATCH;
|
|
91
|
+
await Promise.all(batches.flatMap(batch => {
|
|
92
|
+
const ids = [...batch.ids.values()];
|
|
93
|
+
const chunks = [];
|
|
94
|
+
for (let at = 0; at < ids.length; at += size)
|
|
95
|
+
chunks.push(ids.slice(at, at + size));
|
|
96
|
+
return chunks.map(chunk => this.ask(batch, chunk));
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
async ask(batch, ids) {
|
|
100
|
+
const keyOf = (parentId) => this.keyOf({ domain: batch.domain, parentId, userId: batch.userId });
|
|
101
|
+
try {
|
|
102
|
+
const answer = await this.options.load(batch.domain, ids, batch.userId);
|
|
103
|
+
for (const parentId of ids) {
|
|
104
|
+
// A record the answer leaves out has no messages: that is the quiet icon, not a failure.
|
|
105
|
+
this.summaries.set(keyOf(parentId), answer.get(String(parentId)) ?? EMPTY_CHAT_SUMMARY);
|
|
106
|
+
this.states.set(keyOf(parentId), "ready");
|
|
107
|
+
this.tell(keyOf(parentId));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// The icon stays quiet and says nothing: counts are not worth a message of their own. The next want asks again.
|
|
112
|
+
for (const parentId of ids) {
|
|
113
|
+
this.states.set(keyOf(parentId), "failed");
|
|
114
|
+
this.tell(keyOf(parentId));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { CrudMassActionName, CrudMode } from "../Interfaces/CrudInterfaces.ts";
|
|
2
|
+
import type { TableKey, TablePage, TableQuery, TableRow } from "../Interfaces/TableInterfaces.ts";
|
|
3
|
+
type Row = Record<string, unknown>;
|
|
4
|
+
export declare abstract class CrudBackend {
|
|
5
|
+
/** What a server table sends for one page, from the query. `sortKey` is the sorted column's server name. */
|
|
6
|
+
abstract pageRequest(query: TableQuery, sortKey: string | undefined): unknown;
|
|
7
|
+
/** Reads the page a pagination endpoint answered. */
|
|
8
|
+
abstract readPage<TRow extends TableRow>(result: unknown): TablePage<TRow>;
|
|
9
|
+
/** Reads a whole list: an array, or the rows of a page. */
|
|
10
|
+
readList<TRow extends TableRow>(result: unknown): TRow[];
|
|
11
|
+
/** What a mass action sends: the selected keys, and for `description` the new text. */
|
|
12
|
+
abstract massRequest(action: CrudMassActionName, keys: TableKey[], values?: Row): unknown;
|
|
13
|
+
abstract isFlagged(row: TableRow): boolean;
|
|
14
|
+
/** The row with its flag set or cleared. */
|
|
15
|
+
abstract withFlag<TRow extends TableRow>(row: TRow, on: boolean): TRow;
|
|
16
|
+
abstract isPinned(row: TableRow): boolean;
|
|
17
|
+
/** The row with its pin set or cleared. */
|
|
18
|
+
abstract withPin<TRow extends TableRow>(row: TRow, on: boolean): TRow;
|
|
19
|
+
/**
|
|
20
|
+
* Changes an action's answer makes to rows, by key: a mass flag that answers each record's new status. Rows without
|
|
21
|
+
* an entry take the change the action stands for (a toggled flag). Default: none.
|
|
22
|
+
*/
|
|
23
|
+
answeredChanges(result: unknown, rowKey: string): Map<string, Row>;
|
|
24
|
+
/**
|
|
25
|
+
* The keys of the records an action changed, when its answer lists them: a mass trash where some records could not
|
|
26
|
+
* move. `null` when the answer does not say, and every record sent counts as changed. Default `null`.
|
|
27
|
+
*/
|
|
28
|
+
answeredKeys(result: unknown, rowKey: string): Set<string> | null;
|
|
29
|
+
/**
|
|
30
|
+
* What an answer says about the one record an action wrote: the record itself, or only the fields that changed.
|
|
31
|
+
* `null` when it says nothing about it - a bare key, an empty body - and then the caller keeps what it has.
|
|
32
|
+
*
|
|
33
|
+
* **This is what spares a page its reload.** A save or a mark whose answer carries the record is written into the
|
|
34
|
+
* table, the pinned band and every list kept in the cache, and nothing is loaded again. Only answers of actions on
|
|
35
|
+
* **one** record are read here; a mass answer goes through `answeredChanges`, record by record.
|
|
36
|
+
*/
|
|
37
|
+
answeredRow<TRow extends TableRow>(result: unknown): Partial<TRow> | null;
|
|
38
|
+
/**
|
|
39
|
+
* The key an answer gives in place of the record: `Create` answering the new record's id. `null` when the answer
|
|
40
|
+
* is the record itself, or says nothing. With a key the new record goes straight into the list, and where there is
|
|
41
|
+
* a `details` endpoint it is read back on its own - one small request, never the whole list again.
|
|
42
|
+
*/
|
|
43
|
+
answeredKey(result: unknown): TableKey | null;
|
|
44
|
+
/**
|
|
45
|
+
* Whether a record belongs in a list, read from the record itself. It is what lets one answer be written into the
|
|
46
|
+
* lists that are **not** on screen: a flagged record joins the flagged list, a trashed one leaves the active list
|
|
47
|
+
* and joins the trash, in the cache as well as in the table.
|
|
48
|
+
*
|
|
49
|
+
* `undefined` when the record does not say, and a list it cannot answer for is dropped from the cache and loads
|
|
50
|
+
* again when it is shown. Default: nothing is known, which is how every list behaved before.
|
|
51
|
+
*/
|
|
52
|
+
listMembership(row: TableRow, mode: CrudMode): boolean | undefined;
|
|
53
|
+
}
|
|
54
|
+
/** The Nexus record status of a flagged record (`ERecordStatus.Flagged`), the bottom of the 25–29 flag band. */
|
|
55
|
+
export declare const NEXUS_FLAGGED_STATUS = 25;
|
|
56
|
+
/** The Nexus record status of an active record (`ERecordStatus.Active`), the bottom of the 0–39 active band. */
|
|
57
|
+
export declare const NEXUS_ACTIVE_STATUS = 0;
|
|
58
|
+
/** The Nexus record status of a record in the trash (`ERecordStatus.Trash`), the bottom of the 40–59 trash band. */
|
|
59
|
+
export declare const NEXUS_TRASH_STATUS = 40;
|
|
60
|
+
/** The Nexus record status of a deleted record (`ERecordStatus.PermanentDeleted`), the bottom of the 60+ delete band. */
|
|
61
|
+
export declare const NEXUS_DELETED_STATUS = 60;
|
|
62
|
+
/**
|
|
63
|
+
* The Nexus backend: pages as `{ searchKeyword, skipCount, maxCount, sorting, sortType }` answered with
|
|
64
|
+
* `{ results, totalCount, filterCount }`, newest first unless a column is sorted; mass actions send the ids
|
|
65
|
+
* (`{ description, accountIds }` for descriptions); a flagged record has `recordStatus` 25, a pinned one `isPinned`.
|
|
66
|
+
*/
|
|
67
|
+
export declare class NexusCrudBackend extends CrudBackend {
|
|
68
|
+
pageRequest(query: TableQuery, sortKey: string | undefined): unknown;
|
|
69
|
+
readPage<TRow extends TableRow>(result: unknown): TablePage<TRow>;
|
|
70
|
+
massRequest(action: CrudMassActionName, keys: TableKey[], values?: Row): unknown;
|
|
71
|
+
isFlagged(row: TableRow): boolean;
|
|
72
|
+
withFlag<TRow extends TableRow>(row: TRow, on: boolean): TRow;
|
|
73
|
+
isPinned(row: TableRow): boolean;
|
|
74
|
+
withPin<TRow extends TableRow>(row: TRow, on: boolean): TRow;
|
|
75
|
+
/** Mass answers list the records they changed, with their new `recordStatus` and `isPinned`. */
|
|
76
|
+
answeredChanges(result: unknown, rowKey: string): Map<string, Row>;
|
|
77
|
+
/**
|
|
78
|
+
* A write answers the record it wrote, or a value that is not one. `Create` and `Update` answer the new record's
|
|
79
|
+
* **id token**, which says nothing the caller does not already hold, so it is read as nothing; a mark answers the
|
|
80
|
+
* record's new **`ERecordStatus`**, which is the one field that changed and is what puts the record in the right
|
|
81
|
+
* list without loading one. A backend that answers the whole record is read as that record.
|
|
82
|
+
*/
|
|
83
|
+
answeredRow<TRow extends TableRow>(result: unknown): Partial<TRow> | null;
|
|
84
|
+
/** `Create` and `Update` answer the record's id: a token when ids are encrypted, the number as text when not. */
|
|
85
|
+
answeredKey(result: unknown): TableKey | null;
|
|
86
|
+
/**
|
|
87
|
+
* The bands the owner settled on: under 40 is live, 25–29 of that is flagged, 40–59 is the trash, and 60 and up is
|
|
88
|
+
* deleted. A row without a `recordStatus` says nothing, and the lists it cannot answer for load again.
|
|
89
|
+
*/
|
|
90
|
+
listMembership(row: TableRow, mode: CrudMode): boolean | undefined;
|
|
91
|
+
/** Mass answers list the records they changed (as records, or as ids); other answers say nothing. */
|
|
92
|
+
answeredKeys(result: unknown, rowKey: string): Set<string> | null;
|
|
93
|
+
}
|
|
94
|
+
/** Registers the app's backend conventions, once, beside its API modules. */
|
|
95
|
+
export declare function defineCrudBackend<T extends CrudBackend>(backend: T): T;
|
|
96
|
+
/** The registered backend conventions, or the Nexus ones. */
|
|
97
|
+
export declare function getCrudBackend(): CrudBackend;
|
|
98
|
+
export {};
|