nexus-shared 2.0.0 → 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/README.md +122 -25
- package/dist/Client.Index.d.ts +11 -0
- package/dist/Client.Index.js +11 -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/Menu.js +24 -20
- package/dist/Components/Documents/SplitButton.js +5 -3
- package/dist/Components/Documents/TabButtons.d.ts +24 -6
- package/dist/Components/Documents/TabButtons.js +23 -4
- package/dist/Components/Forms/ApiForm.d.ts +6 -4
- package/dist/Components/Forms/ApiForm.js +15 -14
- package/dist/Components/Forms/Crud.js +202 -50
- package/dist/Components/Forms/ExcelImport.d.ts +42 -0
- package/dist/Components/Forms/ExcelImport.js +190 -0
- package/dist/Components/Forms/Form.js +5 -1
- package/dist/Components/Inputs/DateTimePicker.js +2 -1
- package/dist/Components/Inputs/GroupForm.js +4 -2
- package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
- package/dist/Components/Inputs/InputRenderer.js +1 -1
- package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
- package/dist/Components/Inputs/RowsInput.d.ts +12 -1
- package/dist/Components/Inputs/RowsInput.js +53 -4
- package/dist/Components/Inputs/TabularForm.d.ts +7 -3
- package/dist/Components/Inputs/TabularForm.js +85 -20
- package/dist/Components/Inputs/TimePicker.js +2 -1
- package/dist/Components/Layouts/ThemeSwitcher.d.ts +2 -2
- package/dist/Components/Layouts/ThemeSwitcher.js +35 -15
- package/dist/Components/Viewers/DataTable.js +88 -34
- package/dist/Components/Viewers/DataTableColumns.d.ts +28 -0
- package/dist/Components/Viewers/DataTableColumns.js +243 -0
- package/dist/Components/Viewers/DataTableParts.d.ts +6 -2
- package/dist/Components/Viewers/DataTableParts.js +21 -9
- package/dist/Helpers/ApiClient.d.ts +22 -0
- package/dist/Helpers/ApiClient.js +4 -1
- package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
- package/dist/Helpers/ApiFormHelpers.js +73 -9
- package/dist/Helpers/ApiModules.d.ts +0 -4
- package/dist/Helpers/ApiModules.js +1 -0
- package/dist/Helpers/ApiResponses.d.ts +14 -4
- package/dist/Helpers/ApiResponses.js +116 -25
- package/dist/Helpers/ApiRoutes.d.ts +0 -10
- package/dist/Helpers/ApiRoutes.js +2 -10
- 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/CrudBackend.d.ts +47 -5
- package/dist/Helpers/CrudBackend.js +99 -4
- package/dist/Helpers/CrudHelpers.d.ts +39 -4
- package/dist/Helpers/CrudHelpers.js +76 -11
- 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/FormStore.d.ts +2 -0
- package/dist/Helpers/FormStore.js +19 -1
- package/dist/Helpers/MessageBuilder.js +0 -2
- package/dist/Helpers/PopoverHelpers.d.ts +31 -6
- package/dist/Helpers/PopoverHelpers.js +116 -12
- package/dist/Helpers/RowsStore.d.ts +7 -1
- package/dist/Helpers/RowsStore.js +22 -0
- package/dist/Helpers/TableColumns.d.ts +7 -0
- package/dist/Helpers/TableColumns.js +41 -13
- package/dist/Helpers/TableHelpers.d.ts +9 -1
- package/dist/Helpers/TableHelpers.js +20 -0
- package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
- package/dist/Interfaces/ApiInterfaces.js +16 -10
- package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
- package/dist/Interfaces/ChatInterfaces.js +1 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +73 -12
- package/dist/Interfaces/ExcelInterfaces.d.ts +215 -0
- package/dist/Interfaces/ExcelInterfaces.js +45 -0
- package/dist/Interfaces/FormInterfaces.d.ts +52 -2
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/TableInterfaces.d.ts +62 -2
- package/dist/Services/BrowserApi.d.ts +18 -1
- package/dist/Services/BrowserApi.js +18 -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 +6 -1
- package/dist/Services/ServerApi.js +3 -0
- package/dist/Shared.Index.d.ts +8 -0
- package/dist/Shared.Index.js +8 -0
- package/package.json +6 -3
- package/src/Styles/Nexus.Button.css +2 -0
- package/src/Styles/Nexus.Chat.css +1489 -0
- package/src/Styles/Nexus.Crud.css +77 -0
- package/src/Styles/Nexus.Excel.css +370 -0
- package/src/Styles/Nexus.Form.css +23 -0
- package/src/Styles/Nexus.Index.css +2 -0
- package/src/Styles/Nexus.Menu.css +12 -2
- package/src/Styles/Nexus.Rows.css +78 -48
- package/src/Styles/Nexus.Tab.Buttons.css +55 -0
- package/src/Styles/Nexus.Table.css +286 -4
- package/src/Styles/Nexus.Theme.Picker.css +75 -2
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type IconNode } from "nexus-icons";
|
|
2
|
+
import { type ComponentPropsWithRef } from "react";
|
|
3
|
+
import { type ChatTimeOptions } from "../../Helpers/ChatHelpers.ts";
|
|
4
|
+
import type { ChatDomainValue, ChatScope, ChatSummary } from "../../Interfaces/ChatInterfaces.ts";
|
|
5
|
+
export interface ChatButtonProps<TDomain extends ChatDomainValue = ChatDomainValue> extends Omit<ComponentPropsWithRef<"button">, "children" | "title" | "aria-label"> {
|
|
6
|
+
/** Which conversation: the domain, the record, and the reader. */
|
|
7
|
+
scope: ChatScope<TDomain>;
|
|
8
|
+
/** The counts, when the row already carries them. Without them the icon asks the page's cache. */
|
|
9
|
+
summary?: ChatSummary;
|
|
10
|
+
/** What the conversation is called, in the tooltip and for a screen reader. Default "Discussion". */
|
|
11
|
+
label?: string;
|
|
12
|
+
/** Default `md` (34px), as the other controls in a row. `sm` is 28px, for a dense table. */
|
|
13
|
+
size?: "sm" | "md";
|
|
14
|
+
/** The largest number the badge writes before "99+". Default 99. */
|
|
15
|
+
maxBadge?: number;
|
|
16
|
+
/** The user's date settings, for "3 h ago" in the tooltip. Default: their own. */
|
|
17
|
+
time?: ChatTimeOptions;
|
|
18
|
+
/** The icon. Default: a speech bubble. */
|
|
19
|
+
icon?: IconNode;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The chat icon for one record: quiet, highlighted, or highlighted with an unread badge. It does not open anything by
|
|
23
|
+
* itself — `Chat` is the icon with the popup behind it, and a page that holds the conversation somewhere else gives
|
|
24
|
+
* this one an `onClick`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function ChatButton<TDomain extends ChatDomainValue = ChatDomainValue>({ scope, summary: given, label, size, maxBadge, time, icon, className, ...rest }: ChatButtonProps<TDomain>): import("react").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { iconMessageCircle, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useMemo } from "react";
|
|
5
|
+
import { chatBadgeText, chatHighlight, chatPreviewText, chatTimeAgo } from "../../Helpers/ChatHelpers.js";
|
|
6
|
+
import { useChatSummary } from "../../Helpers/ChatHooks.js";
|
|
7
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
8
|
+
import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
9
|
+
import { pluralize } from "../../Helpers/MessageBuilder.js";
|
|
10
|
+
/**
|
|
11
|
+
* The tooltip and the screen reader's text: what the icon is saying, in words. "40 min ago" is left out until the page
|
|
12
|
+
* is in a browser — it is counted from the clock, and a server that rendered "42 min ago" a moment earlier would not
|
|
13
|
+
* match what the browser then draws. The zone tells us where we are, as the date-time fields here already have it.
|
|
14
|
+
*/
|
|
15
|
+
function describe(summary, label, time, onBrowser) {
|
|
16
|
+
if (!summary || summary.messageCount === 0)
|
|
17
|
+
return `${label}: no messages yet`;
|
|
18
|
+
const counted = `${summary.messageCount} ${summary.messageCount === 1 ? "message" : pluralize("message")}`;
|
|
19
|
+
const parts = [summary.unreadCount > 0 ? `${label}: ${summary.unreadCount} unread of ${summary.messageCount}` : `${label}: ${counted}`];
|
|
20
|
+
const when = onBrowser ? chatTimeAgo(summary.lastMessageAt, time) : "";
|
|
21
|
+
if (when)
|
|
22
|
+
parts.push(when);
|
|
23
|
+
if (summary.lastMessageText)
|
|
24
|
+
parts.push(`${summary.lastMessageBy ? `${summary.lastMessageBy}: ` : ""}${chatPreviewText(summary.lastMessageText, 80)}`);
|
|
25
|
+
return parts.join(" · ");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The chat icon for one record: quiet, highlighted, or highlighted with an unread badge. It does not open anything by
|
|
29
|
+
* itself — `Chat` is the icon with the popup behind it, and a page that holds the conversation somewhere else gives
|
|
30
|
+
* this one an `onClick`.
|
|
31
|
+
*/
|
|
32
|
+
export function ChatButton({ scope, summary: given, label = "Discussion", size = "md", maxBadge = 99, time, icon = iconMessageCircle, className, ...rest }) {
|
|
33
|
+
const { summary } = useChatSummary(scope, given);
|
|
34
|
+
const preferences = useDatePreferences();
|
|
35
|
+
const zone = useBrowserTimeZone();
|
|
36
|
+
const settings = useMemo(() => ({ ...preferences, ...(zone && { timeZone: zone }), ...time }), [preferences, zone, time]);
|
|
37
|
+
const highlight = chatHighlight(summary);
|
|
38
|
+
const words = describe(summary, label, settings, zone !== null);
|
|
39
|
+
const unread = summary?.unreadCount ?? 0;
|
|
40
|
+
return (_jsxs("button", { type: "button", className: cx("nx-chat-btn", `nx-chat-btn--${size}`, className), "data-chat": highlight, title: words, "aria-label": words, ...rest, children: [_jsx(InlineIcon, { icon: icon }), unread > 0 && (_jsx("span", { className: "nx-chat-btn__badge", "aria-hidden": "true", children: chatBadgeText(unread, maxBadge) }))] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ChatSnapshot, ChatStore } from "../../Helpers/ChatStore.ts";
|
|
2
|
+
import type { ChatMessage, ChatParticipant } from "../../Interfaces/ChatInterfaces.ts";
|
|
3
|
+
import { type MarkdownToolbarItem } from "../Inputs/MarkdownEditor.tsx";
|
|
4
|
+
/** The tools a chat message needs. The full `MARKDOWN_TOOLBAR` belongs to a document, not to a line or two. */
|
|
5
|
+
export declare const CHAT_TOOLBAR: readonly MarkdownToolbarItem[];
|
|
6
|
+
/** `enter`: Enter sends, Shift+Enter starts a line. `modifier`: Ctrl+Enter sends and Enter is only ever a line. */
|
|
7
|
+
export type ChatSendKey = "enter" | "modifier";
|
|
8
|
+
export interface ChatComposerProps {
|
|
9
|
+
store: ChatStore;
|
|
10
|
+
state: ChatSnapshot;
|
|
11
|
+
/** Keeps the unsent text under this key. Default: none, and the text is lost when the composer unmounts. */
|
|
12
|
+
draftKey?: string;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
/** Files can be attached. Default false: only a source that takes them should offer it. */
|
|
15
|
+
files?: boolean;
|
|
16
|
+
/** The message being answered. */
|
|
17
|
+
replyTo?: ChatMessage | null;
|
|
18
|
+
/** The reply was called off. */
|
|
19
|
+
onCancelReply?: () => void;
|
|
20
|
+
/** Which key sends. Default `enter`. */
|
|
21
|
+
sendKey?: ChatSendKey;
|
|
22
|
+
/**
|
|
23
|
+
* The people an `@` offers. Left out, `@` is only a letter. The list opens on `@` at the start of a word and
|
|
24
|
+
* narrows as the name is typed; Enter or Tab puts the name in, and while it is open Enter never sends.
|
|
25
|
+
*/
|
|
26
|
+
mentions?: readonly ChatParticipant[];
|
|
27
|
+
/** Where a mention links to, as Markdown: `person => `/people/${person.id}``. Default: plain `@Name`, no link. */
|
|
28
|
+
mentionHref?: (participant: ChatParticipant) => string | null;
|
|
29
|
+
/** The toolbar, as `MarkdownEditor` takes it. Default `CHAT_TOOLBAR`; `[]` leaves the writing area bare. */
|
|
30
|
+
toolbar?: readonly MarkdownToolbarItem[];
|
|
31
|
+
/** How tall the writing area starts, in pixels. Default 84, about three lines; the handle under it drags, and remembers. */
|
|
32
|
+
height?: number;
|
|
33
|
+
/** Why the user may not write here. */
|
|
34
|
+
readOnlyMessage?: string;
|
|
35
|
+
/** Most characters in one message. Default 4,000. */
|
|
36
|
+
maxLength?: number;
|
|
37
|
+
autoFocus?: boolean;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Writes and sends a message, in Markdown. */
|
|
41
|
+
export declare function ChatComposer({ store, state, draftKey, placeholder, files, replyTo, onCancelReply, sendKey, mentions, mentionHref, toolbar, height, readOnlyMessage, maxLength, autoFocus, className, }: ChatComposerProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { iconPaperclip, iconSend, iconX, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { applyChatMention, chatFileSize, findChatMention, matchChatMentions, readChatMentions } from "../../Helpers/ChatHelpers.js";
|
|
6
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
7
|
+
import { continueMarkdownList } from "../../Helpers/MarkdownEditing.js";
|
|
8
|
+
import { markdownToPlainText } from "../../Helpers/MarkdownHelpers.js";
|
|
9
|
+
import { ChatAvatar } from "./ChatParts.js";
|
|
10
|
+
import { MarkdownEditor } from "../Inputs/MarkdownEditor.js";
|
|
11
|
+
/*
|
|
12
|
+
* The composer. A message is Markdown, so this is the Markdown editor the rest of the app writes with — the same
|
|
13
|
+
* toolbar, the same shortcuts, the same preview — kept short, with Write and Preview only and no room for a split view
|
|
14
|
+
* in a chat panel.
|
|
15
|
+
*
|
|
16
|
+
* Enter sends and Shift+Enter starts a line, as a chat does. The one exception is a list: with the caret in one, Enter
|
|
17
|
+
* belongs to the editor, which carries the list on to the next item, because a half-written list that sends itself is
|
|
18
|
+
* worse than a message that needs one more key. A page that would rather Enter never sent sets `sendKey="modifier"`.
|
|
19
|
+
*
|
|
20
|
+
* What has been typed but not sent is kept under `draftKey`, so closing the panel, opening another record, or reloading
|
|
21
|
+
* the page does not lose it — the same promise the forms make.
|
|
22
|
+
*/
|
|
23
|
+
/** Where unsent text is kept. */
|
|
24
|
+
const DRAFT_PREFIX = "nx-chat-draft:";
|
|
25
|
+
/** The tools a chat message needs. The full `MARKDOWN_TOOLBAR` belongs to a document, not to a line or two. */
|
|
26
|
+
export const CHAT_TOOLBAR = ["bold", "italic", "strikethrough", "|", "link", "code", "codeBlock", "|", "bulletList", "numberedList", "taskList", "quote"];
|
|
27
|
+
const readDraft = (key) => {
|
|
28
|
+
if (!key || typeof window === "undefined")
|
|
29
|
+
return "";
|
|
30
|
+
try {
|
|
31
|
+
return window.localStorage.getItem(DRAFT_PREFIX + key) ?? "";
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// A private window, or site data turned off: the composer simply starts empty.
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const writeDraft = (key, text) => {
|
|
39
|
+
if (!key || typeof window === "undefined")
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
if (text)
|
|
43
|
+
window.localStorage.setItem(DRAFT_PREFIX + key, text);
|
|
44
|
+
else
|
|
45
|
+
window.localStorage.removeItem(DRAFT_PREFIX + key);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Nothing to do: losing a draft is better than failing to send.
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/** Whether the caret sits inside a fenced code block, where Enter is a line and never a send. */
|
|
52
|
+
function insideCodeFence(value, caret) {
|
|
53
|
+
const before = value.slice(0, caret);
|
|
54
|
+
return (before.match(/^```/gm) ?? []).length % 2 === 1;
|
|
55
|
+
}
|
|
56
|
+
/** Writes and sends a message, in Markdown. */
|
|
57
|
+
export function ChatComposer({ store, state, draftKey, placeholder = "Write a message…", files = false, replyTo, onCancelReply, sendKey = "enter", mentions, mentionHref, toolbar = CHAT_TOOLBAR, height = 84, readOnlyMessage = "You can read this conversation, but not write in it.", maxLength = 4000, autoFocus = false, className, }) {
|
|
58
|
+
const boxRef = useRef(null);
|
|
59
|
+
const fileRef = useRef(null);
|
|
60
|
+
// The text lives here so Send, the draft, and clearing after a send all read the same thing. A message is short, so
|
|
61
|
+
// a render per keystroke costs nothing — unlike the long documents the editor is otherwise built for.
|
|
62
|
+
//
|
|
63
|
+
// It starts empty rather than from the saved draft: local storage is the browser's, so reading it while rendering
|
|
64
|
+
// would make the page the server built and the page the browser builds disagree, down to a Send button that is
|
|
65
|
+
// disabled on one and not the other. The draft arrives just after, in the effect below.
|
|
66
|
+
const [text, setText] = useState("");
|
|
67
|
+
const [picked, setPicked] = useState([]);
|
|
68
|
+
// The `@…` being typed, and which of the people offered is highlighted.
|
|
69
|
+
const [mention, setMention] = useState(null);
|
|
70
|
+
const [at, setAt] = useState(0);
|
|
71
|
+
const offered = mention && mentions ? matchChatMentions(mentions, mention.query) : [];
|
|
72
|
+
const picking = offered.length > 0;
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
setText(readDraft(draftKey));
|
|
75
|
+
}, [draftKey]);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (autoFocus)
|
|
78
|
+
boxRef.current?.focus();
|
|
79
|
+
}, [autoFocus]);
|
|
80
|
+
const change = useCallback((value) => {
|
|
81
|
+
setText(value);
|
|
82
|
+
writeDraft(draftKey, value);
|
|
83
|
+
// The caret is the text area's, since the editor hands back only the text.
|
|
84
|
+
const caret = boxRef.current?.selectionStart ?? value.length;
|
|
85
|
+
setMention(mentions ? findChatMention(value, caret) : null);
|
|
86
|
+
setAt(0);
|
|
87
|
+
}, [draftKey, mentions]);
|
|
88
|
+
/** Puts the chosen name in, and leaves the caret after it. */
|
|
89
|
+
const putMention = useCallback((person) => {
|
|
90
|
+
if (!mention)
|
|
91
|
+
return;
|
|
92
|
+
const next = applyChatMention(text, mention, person, mentionHref?.(person));
|
|
93
|
+
setText(next.value);
|
|
94
|
+
writeDraft(draftKey, next.value);
|
|
95
|
+
setMention(null);
|
|
96
|
+
const box = boxRef.current;
|
|
97
|
+
if (box) {
|
|
98
|
+
// After the editor has drawn the new text, or the caret would be put back by the render.
|
|
99
|
+
requestAnimationFrame(() => {
|
|
100
|
+
box.focus();
|
|
101
|
+
box.setSelectionRange(next.caret, next.caret);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}, [mention, text, mentionHref, draftKey]);
|
|
105
|
+
const send = useCallback(() => {
|
|
106
|
+
const written = text.trim();
|
|
107
|
+
if (!written && picked.length === 0)
|
|
108
|
+
return;
|
|
109
|
+
// The text goes before the call does: the message is on screen at once, and a failure keeps it there with Retry.
|
|
110
|
+
const named = mentions ? readChatMentions(written, mentions) : [];
|
|
111
|
+
change("");
|
|
112
|
+
setPicked([]);
|
|
113
|
+
setMention(null);
|
|
114
|
+
onCancelReply?.();
|
|
115
|
+
void store.send(written, { replyToId: replyTo?.id ?? null, ...(picked.length > 0 && { files: picked }), ...(named.length > 0 && { mentions: named }) });
|
|
116
|
+
boxRef.current?.focus();
|
|
117
|
+
}, [text, picked, change, onCancelReply, store, replyTo, mentions]);
|
|
118
|
+
const keyDown = (event) => {
|
|
119
|
+
// While the `@` list is open it owns the arrows, Enter, Tab and Esc: a name is being picked, not a message sent.
|
|
120
|
+
if (picking) {
|
|
121
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
setAt(index => (index + (event.key === "ArrowDown" ? 1 : offered.length - 1)) % offered.length);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
putMention(offered[at] ?? offered[0]);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (event.key === "Escape") {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
setMention(null);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// While an input method is composing, Enter belongs to the composition, not to sending.
|
|
138
|
+
if (event.key !== "Enter" || event.nativeEvent.isComposing) {
|
|
139
|
+
if (event.key === "Escape" && replyTo && onCancelReply) {
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
onCancelReply();
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const withModifier = event.ctrlKey || event.metaKey;
|
|
146
|
+
if (sendKey === "modifier") {
|
|
147
|
+
if (!withModifier)
|
|
148
|
+
return;
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
send();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (event.shiftKey || event.altKey)
|
|
154
|
+
return;
|
|
155
|
+
const box = event.currentTarget;
|
|
156
|
+
// The editor owns Enter inside a list or a code block: it carries the list on, or keeps the block together.
|
|
157
|
+
if (!withModifier && (continueMarkdownList(box.value, box.selectionStart, box.selectionEnd) !== null || insideCodeFence(box.value, box.selectionStart)))
|
|
158
|
+
return;
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
send();
|
|
161
|
+
};
|
|
162
|
+
const addFiles = (event) => {
|
|
163
|
+
const chosen = Array.from(event.target.files ?? []);
|
|
164
|
+
if (chosen.length)
|
|
165
|
+
setPicked(held => [...held, ...chosen]);
|
|
166
|
+
event.target.value = "";
|
|
167
|
+
};
|
|
168
|
+
if (!state.canSend) {
|
|
169
|
+
return (_jsx("div", { className: cx("nx-chat-composer nx-chat-composer--readonly", className), children: _jsx("p", { children: readOnlyMessage }) }));
|
|
170
|
+
}
|
|
171
|
+
const empty = text.trim() === "" && picked.length === 0;
|
|
172
|
+
return (_jsxs("div", { className: cx("nx-chat-composer", className), children: [replyTo && (_jsxs("div", { className: "nx-chat-composer__reply", children: [_jsxs("span", { className: "nx-chat-composer__reply-text", children: [_jsx("span", { className: "nx-chat-composer__reply-author", children: replyTo.authorName || "Unknown" }), _jsx("span", { children: markdownToPlainText(replyTo.body) })] }), _jsx("button", { type: "button", onClick: onCancelReply, "aria-label": "Stop replying", children: _jsx(InlineIcon, { icon: iconX }) })] })), picked.length > 0 && (_jsx("ul", { className: "nx-chat-composer__files", children: picked.map((file, index) => (_jsxs("li", { children: [_jsx("span", { className: "nx-chat-composer__file-name", children: file.name }), _jsx("span", { className: "nx-chat-composer__file-size", children: chatFileSize(file.size) }), _jsx("button", { type: "button", onClick: () => setPicked(held => held.filter((_, at) => at !== index)), "aria-label": `Remove ${file.name}`, children: _jsx(InlineIcon, { icon: iconX }) })] }, `${file.name}-${index}`))) })), picking && (_jsx("ul", { className: "nx-chat-mentions", role: "listbox", "aria-label": "People", children: offered.map((person, index) => (_jsx("li", { children: _jsxs("button", { type: "button", className: cx("nx-chat-mentions__item", index === at && "is-at"), role: "option", "aria-selected": index === at,
|
|
173
|
+
// The editor must not lose the caret before the name goes in.
|
|
174
|
+
onMouseDown: event => event.preventDefault(), onMouseEnter: () => setAt(index), onClick: () => putMention(person), children: [_jsx(ChatAvatar, { name: person.name, src: person.avatar, size: "sm" }), _jsx("span", { className: "nx-chat-mentions__name", children: person.name }), person.role && _jsx("span", { className: "nx-chat-mentions__role", children: person.role })] }) }, String(person.id)))) })), _jsx(MarkdownEditor, { ref: boxRef, className: "nx-chat-composer__editor", "aria-label": "Message", placeholder: placeholder, value: text, onValueChange: change, onKeyDown: keyDown,
|
|
175
|
+
// Moving the caret away from an `@…` closes the list, as clicking elsewhere in the text does.
|
|
176
|
+
onSelect: () => setMention(mentions ? findChatMention(boxRef.current?.value ?? "", boxRef.current?.selectionStart ?? 0) : null), onBlur: () => setMention(null), modes: ["write", "preview"], toolbar: toolbar, height: height, minHeight: 72, maxHeight: 360, heightKey: "nx-chat", showCount: false, maxLength: maxLength }), _jsxs("div", { className: "nx-chat-composer__row", children: [_jsx("span", { className: "nx-chat-composer__hint", children: sendKey === "enter" ? "Enter sends · Shift+Enter for a line" : "Ctrl+Enter sends" }), files && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "nx-chat-composer__attach", onClick: () => fileRef.current?.click(), "aria-label": "Attach a file", title: "Attach a file", children: _jsx(InlineIcon, { icon: iconPaperclip }) }), _jsx("input", { ref: fileRef, type: "file", multiple: true, hidden: true, onChange: addFiles })] })), _jsxs("button", { type: "button", className: "nx-chat-composer__send", onClick: send, disabled: empty, "aria-label": "Send", title: sendKey === "enter" ? "Send (Enter)" : "Send (Ctrl+Enter)", children: [_jsx(InlineIcon, { icon: iconSend }), _jsx("span", { children: "Send" })] })] })] }));
|
|
177
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type ChatTimeOptions } from "../../Helpers/ChatHelpers.ts";
|
|
3
|
+
import type { ChatPlaceHandle } from "../../Helpers/ChatHooks.ts";
|
|
4
|
+
import type { ChatConversation, ChatId } from "../../Interfaces/ChatInterfaces.ts";
|
|
5
|
+
export interface ChatConversationListProps {
|
|
6
|
+
conversations: readonly ChatConversation[];
|
|
7
|
+
/** The one open now. */
|
|
8
|
+
currentId?: ChatId | null;
|
|
9
|
+
userId: ChatId;
|
|
10
|
+
time: ChatTimeOptions;
|
|
11
|
+
loading?: boolean;
|
|
12
|
+
error?: string | null;
|
|
13
|
+
onRetry?: () => void;
|
|
14
|
+
onPick: (conversation: ChatConversation) => void;
|
|
15
|
+
/** Starts a new one. Left out when the source cannot. */
|
|
16
|
+
onNew?: () => void;
|
|
17
|
+
/** Shown when the record holds none. */
|
|
18
|
+
empty?: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
/** Every conversation at a record: which is open, which is waiting, and what was said last. */
|
|
21
|
+
export declare function ChatConversationList({ conversations, currentId, userId, time, loading, error, onRetry, onPick, onNew, empty }: ChatConversationListProps): import("react").JSX.Element;
|
|
22
|
+
export interface ChatNewFormProps {
|
|
23
|
+
place: ChatPlaceHandle;
|
|
24
|
+
/** Searches people. Default: the place's own search. */
|
|
25
|
+
searchParticipants?: ChatPlaceHandle["searchParticipants"];
|
|
26
|
+
/** The conversation that was started, or the one that already existed. */
|
|
27
|
+
onStarted: (conversation: ChatConversation) => void;
|
|
28
|
+
onCancel: () => void;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Starts a conversation. The **type is not asked for**: one person makes a one-to-one, more than one makes a group,
|
|
32
|
+
* and naming it makes a group of one person too. The form says which it will be, so nothing is a surprise, and the
|
|
33
|
+
* backend has the last word — asking a one-to-one for somebody who already has one gives that one back.
|
|
34
|
+
*/
|
|
35
|
+
export declare function ChatNewForm({ place, searchParticipants, onStarted, onCancel }: ChatNewFormProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { iconMessageCircle, iconPlus, iconUsers, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { chatBadgeText, chatConversationName, chatPreviewText, chatTimeAgo, chatTypeFor } from "../../Helpers/ChatHelpers.js";
|
|
6
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
7
|
+
import { Button } from "../Documents/Button.js";
|
|
8
|
+
import { Dropdown } from "../Inputs/Dropdown.js";
|
|
9
|
+
import { TextBox } from "../Inputs/TextBox.js";
|
|
10
|
+
import { ChatAvatar, ChatFailure, ChatLoading } from "./ChatParts.js";
|
|
11
|
+
/** Every conversation at a record: which is open, which is waiting, and what was said last. */
|
|
12
|
+
export function ChatConversationList({ conversations, currentId, userId, time, loading, error, onRetry, onPick, onNew, empty }) {
|
|
13
|
+
return (_jsxs("div", { className: "nx-chat-list", children: [error && _jsx(ChatFailure, { message: error, onRetry: onRetry }), loading && conversations.length === 0 && _jsx(ChatLoading, { label: "Reading the conversations\u2026" }), !loading && conversations.length === 0 && !error && (_jsxs("div", { className: "nx-chat-empty", children: [_jsx("p", { children: empty ?? "No conversations here yet." }), onNew && _jsx("p", { className: "nx-chat-empty__hint", children: "Start one below." })] })), conversations.length > 0 && (_jsx("ul", { className: "nx-chat-list__items", children: conversations.map(conversation => {
|
|
14
|
+
const name = chatConversationName(conversation, userId);
|
|
15
|
+
const unread = conversation.summary?.unreadCount ?? 0;
|
|
16
|
+
const when = chatTimeAgo(conversation.summary?.lastMessageAt, time);
|
|
17
|
+
const last = conversation.summary?.lastMessageText;
|
|
18
|
+
const current = currentId !== null && currentId !== undefined && String(conversation.id) === String(currentId);
|
|
19
|
+
return (_jsx("li", { children: _jsxs("button", { type: "button", className: cx("nx-chat-list__item", current && "is-current"), onClick: () => onPick(conversation), "aria-current": current || undefined, children: [conversation.type === "group" ? (_jsx("span", { className: "nx-chat-list__group", "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconUsers }) })) : (_jsx(ChatAvatar, { name: name, src: (conversation.participants ?? []).find(person => String(person.id) !== String(userId))?.avatar })), _jsxs("span", { className: "nx-chat-list__text", children: [_jsxs("span", { className: "nx-chat-list__line", children: [_jsx("span", { className: "nx-chat-list__name", children: name }), when && _jsx("span", { className: "nx-chat-list__when", children: when })] }), _jsxs("span", { className: "nx-chat-list__line", children: [_jsx("span", { className: "nx-chat-list__last", children: last ? `${conversation.summary?.lastMessageBy ? `${conversation.summary.lastMessageBy}: ` : ""}${chatPreviewText(last, 60)}` : "No messages yet" }), unread > 0 && _jsx("span", { className: "nx-chat-list__badge", children: chatBadgeText(unread) })] })] })] }) }, String(conversation.id)));
|
|
20
|
+
}) })), onNew && (_jsx("div", { className: "nx-chat-list__new", children: _jsx(Button, { size: "sm", variant: "secondary", block: true, icon: _jsx(InlineIcon, { icon: iconPlus }), onClick: onNew, children: "New chat" }) }))] }));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Starts a conversation. The **type is not asked for**: one person makes a one-to-one, more than one makes a group,
|
|
24
|
+
* and naming it makes a group of one person too. The form says which it will be, so nothing is a surprise, and the
|
|
25
|
+
* backend has the last word — asking a one-to-one for somebody who already has one gives that one back.
|
|
26
|
+
*/
|
|
27
|
+
export function ChatNewForm({ place, searchParticipants, onStarted, onCancel }) {
|
|
28
|
+
const [chosen, setChosen] = useState([]);
|
|
29
|
+
const [title, setTitle] = useState("");
|
|
30
|
+
const search = searchParticipants ?? place.searchParticipants;
|
|
31
|
+
const type = chatTypeFor(chosen, title);
|
|
32
|
+
async function start() {
|
|
33
|
+
const made = await place.create({ type, userIds: chosen, title: title.trim() || null });
|
|
34
|
+
if (made)
|
|
35
|
+
onStarted(made);
|
|
36
|
+
}
|
|
37
|
+
return (_jsxs("div", { className: "nx-chat-new", children: [_jsxs("div", { className: "nx-chat-new__fields", children: [_jsx(Dropdown, { multiple: true, name: "nx-chat-new-people", label: "With", placeholder: "Search by name", size: "sm", value: chosen, valueKey: "id", labelKey: "name", descriptionKey: "role", loadOptions: search, isOptionInactive: (person) => person.inactive === true, onValueChange: (values) => setChosen(values), hint: chosen.length === 0 ? "One person makes a direct chat; more make a group." : undefined }), (chosen.length > 1 || title !== "") && (_jsx(TextBox, { name: "nx-chat-new-title", label: "Group name", size: "sm", value: title, onValueChange: setTitle, maxLength: 120, placeholder: "Optional", hint: "Without one, the group is called after the people in it." })), _jsxs("p", { className: "nx-chat-new__kind", children: [_jsx(InlineIcon, { icon: type === "group" ? iconUsers : iconMessageCircle }), chosen.length === 0 ? "Pick who to talk to." : type === "group" ? `A group of ${chosen.length + 1}, you included.` : "A direct chat between the two of you."] }), place.error && _jsx(ChatFailure, { message: place.error })] }), _jsxs("div", { className: "nx-chat-new__actions", children: [_jsx(Button, { size: "sm", variant: "ghost", onClick: onCancel, children: "Cancel" }), _jsx(Button, { size: "sm", variant: "primary", loading: place.creating, disabled: chosen.length === 0, onClick: () => void start(), children: "Start chat" })] }), _jsx("p", { className: "nx-chat-new__note", children: "You are in it either way." })] }));
|
|
38
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { ChatConversation, ChatDomainValue, ChatId, ChatParticipant, ChatParticipantPage, ChatParticipantQuery, ChatPlaceSource, ChatScope, ChatSource, ChatSummary } from "../../Interfaces/ChatInterfaces.ts";
|
|
3
|
+
/** Which of the panel's three views the body is showing. */
|
|
4
|
+
export type ChatPanelView = "thread" | "list" | "new";
|
|
5
|
+
export interface ChatPanelProps<TDomain extends ChatDomainValue = ChatDomainValue> {
|
|
6
|
+
/** Which record: the domain, the record, and the reader. */
|
|
7
|
+
scope: ChatScope<TDomain>;
|
|
8
|
+
/**
|
|
9
|
+
* Where the messages of **one** conversation come from. Given, the panel shows that conversation alone, with no
|
|
10
|
+
* switch and no New chat. Left out, the panel asks the place which conversations the record holds.
|
|
11
|
+
*/
|
|
12
|
+
source?: ChatSource;
|
|
13
|
+
/**
|
|
14
|
+
* Where one conversation's messages come from, when the app carries them itself — a socket for each conversation, or
|
|
15
|
+
* a demo with them in memory. Called with whichever conversation is open. Default: the app's API, by conversation id.
|
|
16
|
+
*/
|
|
17
|
+
sourceFor?: (conversation: ChatConversation) => ChatSource | undefined;
|
|
18
|
+
/** The conversations at this record, and how to start another. Default: the app's API, through its `ChatBackend`. */
|
|
19
|
+
place?: ChatPlaceSource;
|
|
20
|
+
/** The conversations the page already had, so the panel makes no call for them. */
|
|
21
|
+
conversations?: readonly ChatConversation[];
|
|
22
|
+
/** Which conversation to open. Default: the one with unread messages, else the one spoken in most recently. */
|
|
23
|
+
conversationId?: ChatId | null;
|
|
24
|
+
/** When another conversation is opened, so a page can remember which. */
|
|
25
|
+
onConversationChange?: (conversation: ChatConversation | null) => void;
|
|
26
|
+
/** What the record is: "Leave request #482". Default: the domain's name and the record's id. */
|
|
27
|
+
title?: ReactNode;
|
|
28
|
+
/** A line under the title, such as who asked and when. */
|
|
29
|
+
description?: ReactNode;
|
|
30
|
+
/** The counts the page already had, so the bar opens filled. */
|
|
31
|
+
summary?: ChatSummary;
|
|
32
|
+
/** The people the page already had, so the panel makes no call for them. */
|
|
33
|
+
participants?: readonly ChatParticipant[];
|
|
34
|
+
/**
|
|
35
|
+
* Searches the people in the conversation and the ones who could join. Default: the source's own search (the
|
|
36
|
+
* backend's participants endpoint). Give one to search somewhere else — a staff list, a project's members.
|
|
37
|
+
*/
|
|
38
|
+
searchParticipants?: (query: ChatParticipantQuery) => Promise<ChatParticipantPage>;
|
|
39
|
+
/** The people column and its button. Default true when anyone is known or can be searched. */
|
|
40
|
+
people?: boolean;
|
|
41
|
+
/** Files can be attached. Default false. */
|
|
42
|
+
files?: boolean;
|
|
43
|
+
/** Messages can be answered. Default true. */
|
|
44
|
+
replies?: boolean;
|
|
45
|
+
/** `@` offers the people in the conversation. Default true. */
|
|
46
|
+
mentions?: boolean;
|
|
47
|
+
/** Where a mention links to, as Markdown: `person => \`/people/${person.id}\``. Default: plain `@Name`, no link. */
|
|
48
|
+
mentionHref?: (participant: ChatParticipant) => string | null;
|
|
49
|
+
/** The bar above the thread. Off inside a popup, whose own title bar says what this is. Default true. */
|
|
50
|
+
header?: boolean;
|
|
51
|
+
/** How tall the panel is. Default `min(60vh, 520px)`; `"100%"` fills what holds it. */
|
|
52
|
+
height?: number | string;
|
|
53
|
+
/** Loads and watches only while this is true. Default true. */
|
|
54
|
+
enabled?: boolean;
|
|
55
|
+
/** Messages per page. Default 30. */
|
|
56
|
+
pageSize?: number;
|
|
57
|
+
/** Milliseconds between checks for new messages. 0 turns it off. Default 15,000. */
|
|
58
|
+
pollInterval?: number;
|
|
59
|
+
/** Every time the record's counts change, so a list's row can follow them. */
|
|
60
|
+
onSummary?: (summary: ChatSummary) => void;
|
|
61
|
+
/** Buttons under the composer. */
|
|
62
|
+
footer?: ReactNode;
|
|
63
|
+
className?: string;
|
|
64
|
+
}
|
|
65
|
+
/** A record's conversations, drawn in full. */
|
|
66
|
+
export declare function ChatPanel<TDomain extends ChatDomainValue = ChatDomainValue>({ scope, source, sourceFor, place: placeSource, conversations: givenConversations, conversationId: givenConversationId, onConversationChange, title, description, summary, participants, searchParticipants, people, files, replies, mentions, mentionHref, header, height, enabled, pageSize, pollInterval, onSummary, footer, className, }: ChatPanelProps<TDomain>): import("react").JSX.Element;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { iconArrowLeft, iconChevronDown, iconPlus, iconUserPlus, iconUsers, iconX, InlineIcon } from "nexus-icons";
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { chatConversationName, chatScopeKey, pickChatConversation } from "../../Helpers/ChatHelpers.js";
|
|
6
|
+
import { chatDomainLabel } from "../../Helpers/ChatBackend.js";
|
|
7
|
+
import { useChat, useChatPlace } from "../../Helpers/ChatHooks.js";
|
|
8
|
+
import { cx } from "../../Helpers/ClassHelpers.js";
|
|
9
|
+
import { useBrowserTimeZone, useDatePreferences } from "../../Helpers/DatePreferences.js";
|
|
10
|
+
import { Button } from "../Documents/Button.js";
|
|
11
|
+
import { Dropdown } from "../Inputs/Dropdown.js";
|
|
12
|
+
import { ChatConversationList, ChatNewForm } from "./ChatConversations.js";
|
|
13
|
+
import { ChatParticipantList } from "./ChatParts.js";
|
|
14
|
+
import { ChatComposer } from "./ChatComposer.js";
|
|
15
|
+
import { ChatThread } from "./ChatThread.js";
|
|
16
|
+
/** What a record is called when the page does not say: the domain's name, when the app named it, and the record. */
|
|
17
|
+
function defaultTitle(scope) {
|
|
18
|
+
const label = chatDomainLabel(scope.domain);
|
|
19
|
+
return label ? `${label} · #${String(scope.parentId)}` : `Discussion · #${String(scope.parentId)}`;
|
|
20
|
+
}
|
|
21
|
+
/** A record's conversations, drawn in full. */
|
|
22
|
+
export function ChatPanel({ scope, source, sourceFor, place: placeSource, conversations: givenConversations, conversationId: givenConversationId, onConversationChange, title, description, summary, participants, searchParticipants, people, files = false, replies = true, mentions = true, mentionHref, header = true, height, enabled = true, pageSize, pollInterval, onSummary, footer, className, }) {
|
|
23
|
+
// A page that handed over one conversation's source is not asking about any others.
|
|
24
|
+
const single = source !== undefined;
|
|
25
|
+
const place = useChatPlace({ scope, place: placeSource, conversations: givenConversations, enabled: enabled && !single });
|
|
26
|
+
const [view, setView] = useState("thread");
|
|
27
|
+
const [chosenId, setChosenId] = useState(givenConversationId ?? null);
|
|
28
|
+
const [replyTo, setReplyTo] = useState(null);
|
|
29
|
+
const [asideOpen, setAsideOpen] = useState(false);
|
|
30
|
+
const [adding, setAdding] = useState([]);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (givenConversationId !== undefined)
|
|
33
|
+
setChosenId(givenConversationId);
|
|
34
|
+
}, [givenConversationId]);
|
|
35
|
+
// Which conversation is open: the page's, else the one picked out of the list — unread first, then most recent.
|
|
36
|
+
const current = useMemo(() => {
|
|
37
|
+
if (single)
|
|
38
|
+
return undefined;
|
|
39
|
+
const held = place.conversations;
|
|
40
|
+
const chosen = chosenId === null ? undefined : held.find(one => String(one.id) === String(chosenId));
|
|
41
|
+
return chosen ?? pickChatConversation(held);
|
|
42
|
+
}, [single, place.conversations, chosenId]);
|
|
43
|
+
// One conversation's messages: the page's own source, else one made for whichever conversation is open.
|
|
44
|
+
const openSource = current && sourceFor ? sourceFor(current) : undefined;
|
|
45
|
+
const { store, state } = useChat({
|
|
46
|
+
scope,
|
|
47
|
+
source: source ?? openSource,
|
|
48
|
+
...(current && { conversationId: current.id }),
|
|
49
|
+
pageSize,
|
|
50
|
+
pollInterval,
|
|
51
|
+
summary,
|
|
52
|
+
participants: current?.participants ?? participants,
|
|
53
|
+
enabled: enabled && view === "thread" && (single || current !== undefined),
|
|
54
|
+
});
|
|
55
|
+
const preferences = useDatePreferences();
|
|
56
|
+
const zone = useBrowserTimeZone();
|
|
57
|
+
const time = useMemo(() => ({ ...preferences, ...(zone && { timeZone: zone }) }), [preferences, zone]);
|
|
58
|
+
const search = searchParticipants ?? store.searchParticipants;
|
|
59
|
+
const canAdd = state.features.addParticipants && (current?.type ?? "group") === "group";
|
|
60
|
+
const showPeople = people ?? (state.participants.length > 0 || state.features.participants || searchParticipants !== undefined);
|
|
61
|
+
const many = !single && place.conversations.length > 1;
|
|
62
|
+
// Unsent text belongs to one conversation, so the key holds the record, the reader, and which conversation it is.
|
|
63
|
+
const draftKey = `${chatScopeKey(scope)}:${String(scope.userId)}${current ? `:${String(current.id)}` : ""}`;
|
|
64
|
+
const name = current ? chatConversationName(current, scope.userId) : null;
|
|
65
|
+
// The record's counts reach the page after the render that changed them, never during it.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
onSummary?.(state.summary);
|
|
68
|
+
}, [onSummary, state.summary]);
|
|
69
|
+
// Once a conversation is open it stays open. Without this the panel would pick again whenever the list changed, and
|
|
70
|
+
// reading this one — which clears its unread count — would hand the panel to whichever conversation is unread next.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (current && chosenId === null)
|
|
73
|
+
setChosenId(current.id);
|
|
74
|
+
}, [current, chosenId]);
|
|
75
|
+
// A conversation's own counts go back into the list, so the switch shows what is waiting in the others. Only once
|
|
76
|
+
// its first page has arrived: a store that has loaded nothing yet holds zeros, which would wipe what the list knows.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (current && state.loaded)
|
|
79
|
+
place.apply(current.id, state.summary);
|
|
80
|
+
}, [current, place, state.loaded, state.summary]);
|
|
81
|
+
function open(conversation) {
|
|
82
|
+
setChosenId(conversation.id);
|
|
83
|
+
setView("thread");
|
|
84
|
+
setAsideOpen(false);
|
|
85
|
+
onConversationChange?.(conversation);
|
|
86
|
+
}
|
|
87
|
+
const style = height === undefined ? undefined : { "--nx-chat-height": typeof height === "number" ? `${height}px` : height };
|
|
88
|
+
const empty = !single && !place.loading && place.conversations.length === 0;
|
|
89
|
+
return (_jsxs("div", { className: cx("nx-chat-panel", asideOpen && "nx-chat-panel--with-aside", className), style: style, children: [header && (_jsxs("header", { className: "nx-chat-panel__bar", children: [view !== "thread" && (_jsx("button", { type: "button", className: "nx-chat-panel__back", onClick: () => setView("thread"), "aria-label": "Back to the conversation", title: "Back to the conversation", children: _jsx(InlineIcon, { icon: iconArrowLeft }) })), _jsxs("div", { className: "nx-chat-panel__heading", children: [view === "list" ? (_jsx("span", { className: "nx-chat-panel__title", children: "Conversations" })) : view === "new" ? (_jsx("span", { className: "nx-chat-panel__title", children: "New chat" })) : many ? (_jsxs("button", { type: "button", className: "nx-chat-panel__switch", onClick: () => setView("list"), "aria-haspopup": "true", children: [_jsx("span", { className: "nx-chat-panel__title", children: name }), _jsx(InlineIcon, { icon: iconChevronDown }), _jsxs("span", { className: "nx-chat-panel__of", children: ["of ", place.conversations.length] })] })) : (_jsx("span", { className: "nx-chat-panel__title", children: name ?? title ?? defaultTitle(scope) })), view === "thread" && description && _jsx("span", { className: "nx-chat-panel__description", children: description })] }), view === "thread" && !single && place.canCreate && !many && (_jsx("button", { type: "button", className: "nx-chat-panel__people", onClick: () => setView("new"), "aria-label": "New chat", title: "New chat", children: _jsx(InlineIcon, { icon: iconPlus }) })), view === "thread" && showPeople && (_jsxs("button", { type: "button", className: cx("nx-chat-panel__people", asideOpen && "is-open"), onClick: () => setAsideOpen(shown => !shown), "aria-pressed": asideOpen, "aria-label": asideOpen ? "Hide the people" : "Show the people", title: asideOpen ? "Hide the people" : "Show the people", children: [_jsx(InlineIcon, { icon: iconUsers }), state.participants.length > 0 && _jsx("span", { children: state.participants.length })] }))] })), _jsxs("div", { className: "nx-chat-panel__body", children: [view === "list" && (_jsx(ChatConversationList, { conversations: place.conversations, currentId: current?.id ?? null, userId: scope.userId, time: time, loading: place.loading, error: place.error, onRetry: () => void place.reload(), onPick: open, onNew: place.canCreate ? () => setView("new") : undefined })), view === "new" && _jsx(ChatNewForm, { place: place, searchParticipants: searchParticipants, onStarted: open, onCancel: () => setView("thread") }), view === "thread" && empty && (_jsx(ChatConversationList, { conversations: [], userId: scope.userId, time: time, loading: place.loading, error: place.error, onRetry: () => void place.reload(), onPick: open, onNew: place.canCreate ? () => setView("new") : undefined, empty: "Nobody has written about this record yet." })), view === "thread" && !empty && (_jsxs(_Fragment, { children: [_jsx(ChatThread, { store: store, state: state, userId: scope.userId, replies: replies, onReply: setReplyTo, marksRead: enabled }), showPeople && asideOpen && (_jsxs("aside", { className: "nx-chat-panel__aside", "aria-label": "People in this conversation", children: [_jsxs("div", { className: "nx-chat-aside__bar", children: [_jsx("span", { children: "People" }), _jsx("button", { type: "button", onClick: () => setAsideOpen(false), "aria-label": "Hide the people", children: _jsx(InlineIcon, { icon: iconX }) })] }), _jsx(ChatParticipantList, { participants: state.participants, userId: scope.userId }), canAdd && (_jsxs("div", { className: "nx-chat-aside__add", children: [_jsx(Dropdown, { multiple: true, name: "nx-chat-add-people", label: "Add people", placeholder: "Search by name", size: "sm", value: adding, valueKey: "id", labelKey: "name", descriptionKey: "role", loadOptions: search, isOptionInactive: (person) => person.inactive === true, onValueChange: (values) => setAdding(values) }), _jsx(Button, { size: "sm", variant: "secondary", icon: _jsx(InlineIcon, { icon: iconUserPlus }), disabled: adding.length === 0, onClick: () => {
|
|
90
|
+
const chosen = adding;
|
|
91
|
+
setAdding([]);
|
|
92
|
+
void store.addParticipants(chosen);
|
|
93
|
+
}, children: "Add" })] }))] }))] }))] }), view === "thread" && !empty && (_jsx(ChatComposer, { store: store, state: state, draftKey: draftKey, files: files, replyTo: replyTo, onCancelReply: () => setReplyTo(null), autoFocus: enabled, mentions: mentions ? state.participants : undefined, mentionHref: mentionHref })), footer && _jsx("div", { className: "nx-chat-panel__footer", children: footer })] }));
|
|
94
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type ChatTimeOptions } from "../../Helpers/ChatHelpers.ts";
|
|
3
|
+
import type { ChatAttachment, ChatId, ChatMessage, ChatParticipant } from "../../Interfaces/ChatInterfaces.ts";
|
|
4
|
+
/** A round photo, or the initials of the name on a tone picked from it — the same look and tones as a dropdown's. */
|
|
5
|
+
export declare function ChatAvatar({ name, src, size }: {
|
|
6
|
+
name: string;
|
|
7
|
+
src?: string | null;
|
|
8
|
+
size?: "sm" | "md";
|
|
9
|
+
}): import("react").JSX.Element;
|
|
10
|
+
/** The day a run of messages was written: "Today", "Yesterday", or the date in the user's calendar. */
|
|
11
|
+
export declare function ChatDayDivider({ label }: {
|
|
12
|
+
label: string;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
14
|
+
/** Where reading stopped last time. It stays where it was put, so it does not slide away as the thread is read. */
|
|
15
|
+
export declare function ChatUnreadLine({ count, label }: {
|
|
16
|
+
count?: number;
|
|
17
|
+
label?: string;
|
|
18
|
+
}): import("react").JSX.Element;
|
|
19
|
+
/**
|
|
20
|
+
* How far a message of this user's has got, in the one place the time sits: a turning spinner while it is on its way,
|
|
21
|
+
* one tick once the server has it, two once it has reached somebody, and two in the accent once it has been read. The
|
|
22
|
+
* mark is the same size in every state, so the line under a message does not shift as it settles.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ChatReadMark({ message, participants, userId }: {
|
|
25
|
+
message: ChatMessage;
|
|
26
|
+
participants: readonly ChatParticipant[];
|
|
27
|
+
userId: ChatId;
|
|
28
|
+
}): import("react").JSX.Element;
|
|
29
|
+
/** Files on a message: the name, the size, and a link when the file has a URL. */
|
|
30
|
+
export declare function ChatAttachments({ attachments }: {
|
|
31
|
+
attachments: readonly ChatAttachment[];
|
|
32
|
+
}): import("react").JSX.Element;
|
|
33
|
+
/** The message a reply answers, above the reply itself. */
|
|
34
|
+
export declare function ChatQuote({ message, onOpen }: {
|
|
35
|
+
message: ChatMessage | undefined;
|
|
36
|
+
onOpen?: (id: ChatId) => void;
|
|
37
|
+
}): import("react").JSX.Element;
|
|
38
|
+
/** A note the system wrote, not a person: no side, no avatar, no actions. */
|
|
39
|
+
export declare function ChatSystemNote({ message, time }: {
|
|
40
|
+
message: ChatMessage;
|
|
41
|
+
time: ChatTimeOptions;
|
|
42
|
+
}): import("react").JSX.Element;
|
|
43
|
+
export interface ChatMessageProps {
|
|
44
|
+
message: ChatMessage;
|
|
45
|
+
/** This user wrote it: it sits on the right. */
|
|
46
|
+
mine: boolean;
|
|
47
|
+
/** The first of a run: it carries the author's name. */
|
|
48
|
+
first: boolean;
|
|
49
|
+
/** The last of a run: it carries the time and the read mark. */
|
|
50
|
+
last: boolean;
|
|
51
|
+
time: ChatTimeOptions;
|
|
52
|
+
participants: readonly ChatParticipant[];
|
|
53
|
+
userId: ChatId;
|
|
54
|
+
/** The message this one answers, when it is held. */
|
|
55
|
+
quoted?: ChatMessage;
|
|
56
|
+
onReply?: (message: ChatMessage) => void;
|
|
57
|
+
onRemove?: (message: ChatMessage) => void;
|
|
58
|
+
onRetry?: (clientId: string) => void;
|
|
59
|
+
onDiscard?: (clientId: string) => void;
|
|
60
|
+
onOpenQuoted?: (id: ChatId) => void;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One message. Its own messages sit on the right in the primary tint, everyone else's on the left on a surface. The
|
|
64
|
+
* actions are one row of icon buttons that appear on hover or keyboard focus — and never move the message, so a long
|
|
65
|
+
* thread does not jump under the pointer.
|
|
66
|
+
*/
|
|
67
|
+
export declare function ChatMessageItem({ message, mine, first, last, time, participants, userId, quoted, onReply, onRemove, onRetry, onDiscard, onOpenQuoted }: ChatMessageProps): import("react").JSX.Element;
|
|
68
|
+
/** Everyone in a conversation, with what each one is here. */
|
|
69
|
+
export declare function ChatParticipantList({ participants, userId, empty }: {
|
|
70
|
+
participants: readonly ChatParticipant[];
|
|
71
|
+
userId: ChatId;
|
|
72
|
+
empty?: string;
|
|
73
|
+
}): import("react").JSX.Element;
|
|
74
|
+
/** A thread with nothing in it yet. */
|
|
75
|
+
export declare function ChatEmpty({ message, hint }: {
|
|
76
|
+
message?: string;
|
|
77
|
+
hint?: ReactNode;
|
|
78
|
+
}): import("react").JSX.Element;
|
|
79
|
+
/** Something that did not load, with a way to try again. */
|
|
80
|
+
export declare function ChatFailure({ message, onRetry }: {
|
|
81
|
+
message: string;
|
|
82
|
+
onRetry?: () => void;
|
|
83
|
+
}): import("react").JSX.Element;
|
|
84
|
+
/** Messages on their way in. */
|
|
85
|
+
export declare function ChatLoading({ label }: {
|
|
86
|
+
label?: string;
|
|
87
|
+
}): import("react").JSX.Element;
|