@katechat/ui 1.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.
Files changed (47) hide show
  1. package/.prettierrc +9 -0
  2. package/esbuild.js +56 -0
  3. package/jest.config.js +24 -0
  4. package/package.json +75 -0
  5. package/postcss.config.cjs +14 -0
  6. package/src/__mocks__/fileMock.js +1 -0
  7. package/src/__mocks__/styleMock.js +1 -0
  8. package/src/components/chat/ChatMessagesContainer.module.scss +77 -0
  9. package/src/components/chat/ChatMessagesContainer.tsx +151 -0
  10. package/src/components/chat/ChatMessagesList.tsx +216 -0
  11. package/src/components/chat/index.ts +4 -0
  12. package/src/components/chat/input/ChatInput.module.scss +113 -0
  13. package/src/components/chat/input/ChatInput.tsx +259 -0
  14. package/src/components/chat/input/index.ts +1 -0
  15. package/src/components/chat/message/ChatMessage.Carousel.module.scss +7 -0
  16. package/src/components/chat/message/ChatMessage.module.scss +378 -0
  17. package/src/components/chat/message/ChatMessage.tsx +271 -0
  18. package/src/components/chat/message/ChatMessagePreview.tsx +22 -0
  19. package/src/components/chat/message/LinkedChatMessage.tsx +64 -0
  20. package/src/components/chat/message/MessageStatus.tsx +38 -0
  21. package/src/components/chat/message/controls/CopyMessageButton.tsx +32 -0
  22. package/src/components/chat/message/index.ts +4 -0
  23. package/src/components/icons/ProviderIcon.tsx +49 -0
  24. package/src/components/icons/index.ts +1 -0
  25. package/src/components/index.ts +3 -0
  26. package/src/components/modal/ImagePopup.tsx +97 -0
  27. package/src/components/modal/index.ts +1 -0
  28. package/src/controls/FileDropzone/FileDropzone.module.scss +15 -0
  29. package/src/controls/FileDropzone/FileDropzone.tsx +120 -0
  30. package/src/controls/index.ts +1 -0
  31. package/src/core/ai.ts +1 -0
  32. package/src/core/index.ts +4 -0
  33. package/src/core/message.ts +59 -0
  34. package/src/core/model.ts +23 -0
  35. package/src/core/user.ts +8 -0
  36. package/src/hooks/index.ts +2 -0
  37. package/src/hooks/useIntersectionObserver.ts +24 -0
  38. package/src/hooks/useTheme.tsx +66 -0
  39. package/src/index.ts +5 -0
  40. package/src/lib/__tests__/markdown.parser.test.ts +289 -0
  41. package/src/lib/__tests__/markdown.parser.testUtils.ts +31 -0
  42. package/src/lib/__tests__/markdown.parser_sanitizeUrl.test.ts +130 -0
  43. package/src/lib/assert.ts +14 -0
  44. package/src/lib/markdown.parser.ts +189 -0
  45. package/src/setupTests.ts +1 -0
  46. package/src/types/scss.d.ts +4 -0
  47. package/tsconfig.json +26 -0
package/.prettierrc ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "semi": true,
3
+ "singleQuote": false,
4
+ "tabWidth": 2,
5
+ "trailingComma": "es5",
6
+ "printWidth": 120,
7
+ "bracketSpacing": true,
8
+ "arrowParens": "avoid"
9
+ }
package/esbuild.js ADDED
@@ -0,0 +1,56 @@
1
+ import esbuild from "esbuild";
2
+ import { clean } from "esbuild-plugin-clean";
3
+ import { sassPlugin, postcssModules } from "esbuild-sass-plugin";
4
+ import fs from "fs";
5
+
6
+ // Create directory if it doesn't exist
7
+ if (!fs.existsSync("./dist")) {
8
+ fs.mkdirSync("./dist");
9
+ }
10
+ // Production build configuration
11
+ esbuild
12
+ .build({
13
+ entryPoints: ["./src/index.ts"],
14
+ outdir: "./dist",
15
+ bundle: true,
16
+ minify: true,
17
+ format: "iife",
18
+ splitting: false,
19
+ loader: {
20
+ ".js": "jsx",
21
+ ".svg": "dataurl",
22
+ ".png": "dataurl",
23
+ ".jpg": "dataurl",
24
+ ".gif": "dataurl",
25
+ ".woff": "file",
26
+ ".woff2": "file",
27
+ ".ttf": "file",
28
+ ".eot": "file",
29
+ },
30
+ plugins: [
31
+ clean({ patterns: ["./dist/*.*"] }),
32
+ sassPlugin({
33
+ filter: /\.module\.scss$/,
34
+ transform: postcssModules({}),
35
+ }),
36
+ sassPlugin({
37
+ filter: /\.scss$/,
38
+ }),
39
+ ],
40
+ define: {
41
+ "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "production"),
42
+ },
43
+ metafile: true,
44
+ })
45
+ .then(result => {
46
+ console.log("⚡ Build complete! Bundle created in ./dist");
47
+ console.log(`📦 Config: APP_API_URL: ${process.env.APP_API_URL}`);
48
+ const outputSize = Object.entries(result.metafile.outputs).reduce((total, [name, data]) => {
49
+ return total + data.bytes;
50
+ }, 0);
51
+ console.log(`📦 Total bundle size: ${(outputSize / 1024 / 1024).toFixed(2)}MB`);
52
+ })
53
+ .catch(e => {
54
+ console.error("❌ Build failed:", e);
55
+ process.exit(1);
56
+ });
package/jest.config.js ADDED
@@ -0,0 +1,24 @@
1
+ /** @type {import('jest').Config} */
2
+ module.exports = {
3
+ preset: "ts-jest",
4
+ testEnvironment: "jsdom",
5
+ setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
6
+ moduleNameMapper: {
7
+ "^@/(.*)$": "<rootDir>/src/$1",
8
+ "\\.(css|less|scss|sass)$": "<rootDir>/src/__mocks__/styleMock.js",
9
+ "\\.(jpg|jpeg|png|gif|webp|svg)$": "<rootDir>/src/__mocks__/fileMock.js",
10
+ },
11
+ transform: {
12
+ "^.+\\.[tj]sx?$": [
13
+ "ts-jest",
14
+ {
15
+ tsconfig: {
16
+ jsx: "react-jsx",
17
+ },
18
+ },
19
+ ],
20
+ },
21
+ testMatch: ["**/__tests__/**/*.test.(ts|tsx)"],
22
+ coverageDirectory: "coverage",
23
+ collectCoverageFrom: ["src/**/*.{ts,tsx}", "!src/**/*.d.ts"],
24
+ };
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@katechat/ui",
3
+ "version": "1.0.0",
4
+ "description": "KateChat - AI Chat Interface modules collection",
5
+ "main": "src/index.ts",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "scripts": {
10
+ "build": "node --experimental-default-type=module esbuild.js",
11
+ "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,scss}\"",
12
+ "typecheck": "tsc --noEmit",
13
+ "test": "jest --passWithNoTests --coverage"
14
+ },
15
+ "dependencies": {
16
+ "highlight.js": "^11.11.1",
17
+ "i18next": "^23.16.8",
18
+ "lodash": "^4.17.21",
19
+ "marked": "^15.0.12",
20
+ "marked-highlight": "^2.2.2",
21
+ "marked-katex-extension": "^5.1.5"
22
+ },
23
+ "peerDependencies": {
24
+ "@mantine/carousel": "8.3.3",
25
+ "@mantine/core": "8.3.3",
26
+ "@mantine/dates": "8.3.3",
27
+ "@mantine/form": "8.3.3",
28
+ "@mantine/hooks": "8.3.3",
29
+ "@mantine/modals": "8.3.3",
30
+ "@mantine/notifications": "8.3.3",
31
+ "@tabler/icons-react": "^3.1.0",
32
+ "react": "19.1.0",
33
+ "react-dom": "19.1.0",
34
+ "react-redux": "^9.2.0",
35
+ "react-router-dom": "^6.23.1"
36
+ },
37
+ "devDependencies": {
38
+ "@babel/preset-env": "^7.24.5",
39
+ "@babel/preset-react": "^7.24.1",
40
+ "@babel/preset-typescript": "^7.24.1",
41
+ "@mantine/carousel": "8.3.3",
42
+ "@mantine/core": "8.3.3",
43
+ "@mantine/dates": "8.3.3",
44
+ "@mantine/form": "8.3.3",
45
+ "@mantine/hooks": "8.3.3",
46
+ "@mantine/modals": "8.3.3",
47
+ "@mantine/notifications": "8.3.3",
48
+ "@testing-library/jest-dom": "^6.6.3",
49
+ "@testing-library/react": "^16.3.0",
50
+ "@types/jest": "^29.5.12",
51
+ "@types/lodash": "^4.17.17",
52
+ "@types/node": "^22.15.18",
53
+ "@types/react": "^19.1.4",
54
+ "@types/react-dom": "^19.1.5",
55
+ "@types/react-router-dom": "^5.3.3",
56
+ "esbuild": "^0.25.9",
57
+ "esbuild-plugin-clean": "^1.0.1",
58
+ "esbuild-plugin-polyfill-node": "^0.3.0",
59
+ "esbuild-sass-plugin": "^3.3.1",
60
+ "express": "^4.19.2",
61
+ "jest": "^29.7.0",
62
+ "jest-environment-jsdom": "^29.7.0",
63
+ "postcss": "^8.5.3",
64
+ "postcss-modules": "^6.0.1",
65
+ "postcss-preset-mantine": "^1.13.0",
66
+ "postcss-simple-vars": "^7.0.1",
67
+ "prettier": "^3.2.5",
68
+ "react": "19.1.0",
69
+ "react-dom": "19.1.0",
70
+ "react-redux": "^9.2.0",
71
+ "react-router-dom": "^6.23.1",
72
+ "sass": "^1.71.1",
73
+ "typescript": "^5.8.3"
74
+ }
75
+ }
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ plugins: {
3
+ "postcss-preset-mantine": {},
4
+ "postcss-simple-vars": {
5
+ variables: {
6
+ "mantine-breakpoint-xs": "36em",
7
+ "mantine-breakpoint-sm": "48em",
8
+ "mantine-breakpoint-md": "62em",
9
+ "mantine-breakpoint-lg": "75em",
10
+ "mantine-breakpoint-xl": "88em",
11
+ },
12
+ },
13
+ },
14
+ };
@@ -0,0 +1 @@
1
+ module.exports = "test-file-stub";
@@ -0,0 +1 @@
1
+ module.exports = {};
@@ -0,0 +1,77 @@
1
+ @import "@mantine/core/styles.css";
2
+
3
+ .messagesContainer {
4
+ flex-grow: 1;
5
+ position: relative;
6
+ display: none;
7
+ flex-direction: column;
8
+ border: 1px solid var(--app-shell-border-color);
9
+ border-radius: var(--mantine-radius-md);
10
+ /*
11
+ * Two-level scrolling architecture:
12
+ * The parent (.messagesContainer) uses overflow-y: hidden to prevent unwanted scrollbars and control the visible area.
13
+ * The child (.scroller) uses overflow-y: scroll to enable vertical scrolling for the message list.
14
+ * This pattern is intentional; do not merge the scroll behavior into the parent.
15
+ */
16
+ overflow-y: hidden;
17
+ opacity: 1;
18
+ transition: all 0.3s ease;
19
+
20
+ .messagesList {
21
+ opacity: 0;
22
+ margin-bottom: 1rem;
23
+ }
24
+
25
+ &.empty {
26
+ flex-grow: 0;
27
+ opacity: 0;
28
+ }
29
+
30
+ .scroller {
31
+ overflow-y: scroll;
32
+ flex-grow: 1;
33
+ }
34
+
35
+ &.loadCompleted {
36
+ .scroller {
37
+ scroll-behavior: smooth;
38
+ }
39
+
40
+ display: flex;
41
+
42
+ .messagesList {
43
+ opacity: 1;
44
+ }
45
+ }
46
+ }
47
+
48
+ .anchorContainer {
49
+ position: absolute;
50
+ width: 40px;
51
+ left: calc(50% - 40px);
52
+ bottom: 0;
53
+ height: 48px;
54
+ z-index: 2;
55
+ display: flex;
56
+ flex-direction: column;
57
+ justify-items: center;
58
+ align-items: center;
59
+ opacity: 0;
60
+ transition: all 0.5s ease;
61
+
62
+ &.visible {
63
+ opacity: 1;
64
+ }
65
+
66
+ .anchor {
67
+ background-color: var(--mantine-color-body);
68
+ border-radius: 50%;
69
+ height: 32px;
70
+ width: 32px;
71
+ opacity: 0.75;
72
+
73
+ &:hover {
74
+ opacity: 1;
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,151 @@
1
+ import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
2
+ import { Group, Loader } from "@mantine/core";
3
+ import { IconCircleChevronDown } from "@tabler/icons-react";
4
+ import { Message, Model, PluginProps } from "@/core";
5
+ import { useIntersectionObserver } from "@/hooks";
6
+ import { ChatMessagesList } from "./ChatMessagesList";
7
+
8
+ import classes from "./ChatMessagesContainer.module.scss";
9
+
10
+ interface IProps {
11
+ messages?: Message[];
12
+ models: Model[];
13
+ addChatMessage: (message: Message) => void;
14
+ removeMessages: (args: { messagesToDelete?: Message[]; deleteAfter?: Message }) => void;
15
+ loadMoreMessages?: () => void;
16
+ plugins?: React.FC<PluginProps<Message>>[];
17
+ detailsPlugins?: ((message: Message) => React.ReactNode)[];
18
+ streaming?: boolean;
19
+ loading?: boolean;
20
+ loadCompleted?: boolean;
21
+ }
22
+
23
+ export interface ChatMessagesContainerRef {
24
+ scrollToBottom: () => void;
25
+ }
26
+
27
+ export const ChatMessagesContainer = React.forwardRef<ChatMessagesContainerRef, IProps>(
28
+ (
29
+ {
30
+ messages,
31
+ models,
32
+ addChatMessage,
33
+ removeMessages,
34
+ loadMoreMessages,
35
+ plugins,
36
+ detailsPlugins,
37
+ streaming = false,
38
+ loadCompleted = true,
39
+ loading = false,
40
+ },
41
+ ref
42
+ ) => {
43
+ const [showAnchorButton, setShowAnchorButton] = useState<boolean>(false);
44
+ const messagesContainerRef = useRef<HTMLDivElement>(null);
45
+ const anchorTimer = useRef<NodeJS.Timeout | null>(null);
46
+
47
+ // #region Scrolling
48
+ const scrollToBottom = useCallback(() => {
49
+ messagesContainerRef.current?.scrollTo(0, messagesContainerRef.current?.scrollHeight ?? 0);
50
+ }, [messagesContainerRef]);
51
+
52
+ // Expose scrollToBottom method to parent via ref
53
+ useImperativeHandle(
54
+ ref,
55
+ () => ({
56
+ scrollToBottom,
57
+ }),
58
+ [scrollToBottom]
59
+ );
60
+
61
+ const autoScroll = useCallback(() => {
62
+ if (!showAnchorButton) {
63
+ scrollToBottom();
64
+ }
65
+ }, [scrollToBottom, showAnchorButton]);
66
+
67
+ useEffect(() => {
68
+ autoScroll();
69
+ }, [messages, autoScroll]);
70
+
71
+ const handleScroll = useCallback(
72
+ (e: React.MouseEvent<HTMLDivElement>) => {
73
+ const { scrollTop, scrollHeight, clientHeight } = e.target as HTMLDivElement;
74
+ anchorTimer.current && clearTimeout(anchorTimer.current);
75
+
76
+ if (scrollHeight - scrollTop - clientHeight < 2) {
77
+ setShowAnchorButton(false);
78
+ } else if (messages?.length) {
79
+ if (streaming) {
80
+ anchorTimer.current = setTimeout(() => {
81
+ setShowAnchorButton(true);
82
+ }, 100);
83
+ } else {
84
+ setShowAnchorButton(true);
85
+ }
86
+ }
87
+ },
88
+ [messages?.length, streaming]
89
+ );
90
+
91
+ const anchorHandleClick = useCallback(() => {
92
+ setShowAnchorButton(false);
93
+ scrollToBottom();
94
+ }, [scrollToBottom]);
95
+
96
+ useEffect(() => {
97
+ if (loadCompleted) {
98
+ setShowAnchorButton(false);
99
+ setTimeout(scrollToBottom, 200);
100
+ }
101
+ }, [loadCompleted]);
102
+
103
+ const firstMessageRef = useIntersectionObserver<HTMLDivElement>(
104
+ () => loadMoreMessages?.(),
105
+ [loadMoreMessages],
106
+ 200
107
+ );
108
+
109
+ // #endregion
110
+
111
+ return (
112
+ <div
113
+ className={[
114
+ classes.messagesContainer,
115
+ loadCompleted ? classes.loadCompleted : "",
116
+ loadCompleted && messages?.length === 0 ? classes.empty : "",
117
+ ].join(" ")}
118
+ >
119
+ <div className={classes.scroller} ref={messagesContainerRef} onScroll={handleScroll}>
120
+ <div ref={firstMessageRef} />
121
+ {loading && (
122
+ <Group justify="center" align="center" py="xl">
123
+ <Loader />
124
+ </Group>
125
+ )}
126
+
127
+ <div className={classes.messagesList}>
128
+ {messages && (
129
+ <ChatMessagesList
130
+ messages={messages}
131
+ onMessageDeleted={removeMessages} // Reload messages after deletion
132
+ onAddMessage={addChatMessage}
133
+ models={models}
134
+ plugins={plugins}
135
+ detailsPlugins={detailsPlugins}
136
+ />
137
+ )}
138
+ </div>
139
+ </div>
140
+
141
+ <div className={[classes.anchorContainer, showAnchorButton ? classes.visible : ""].join(" ")}>
142
+ <div className={classes.anchor}>
143
+ <IconCircleChevronDown size={32} color="teal" style={{ cursor: "pointer" }} onClick={anchorHandleClick} />
144
+ </div>
145
+ </div>
146
+ </div>
147
+ );
148
+ }
149
+ );
150
+
151
+ ChatMessagesContainer.displayName = "ChatMessagesContainer";
@@ -0,0 +1,216 @@
1
+ import React, { useCallback, useRef, useState } from "react";
2
+ import { Stack, Group } from "@mantine/core";
3
+ import { notifications } from "@mantine/notifications";
4
+
5
+ import { notEmpty, ok } from "@/lib/assert";
6
+ import { Message, Model, PluginProps } from "@/core";
7
+ import { ChatMessage } from "./message/ChatMessage";
8
+ import { ImagePopup } from "../modal/ImagePopup";
9
+
10
+ interface ChatMessagesProps {
11
+ messages: Message[];
12
+ onMessageDeleted?: (args: { messagesToDelete?: Message[]; deleteAfter?: Message }) => void;
13
+ onAddMessage?: (message: Message) => void;
14
+ plugins?: React.FC<PluginProps<Message>>[];
15
+ detailsPlugins?: ((message: Message) => React.ReactNode)[];
16
+ models: Model[];
17
+ }
18
+
19
+ export const ChatMessagesList: React.FC<ChatMessagesProps> = ({
20
+ messages,
21
+ onMessageDeleted,
22
+ onAddMessage,
23
+ plugins = [],
24
+ detailsPlugins = [],
25
+ models = [],
26
+ }) => {
27
+ const componentRef = useRef<HTMLDivElement>(null);
28
+
29
+ const [imageToShow, setImageToShow] = useState<string | undefined>();
30
+ const [imageFileName, setImageFileName] = useState<string | undefined>();
31
+ const [updatedMessages, setUpdatedMessages] = useState<Set<string>>(new Set());
32
+
33
+ const addEditedMessage = (messageId: string) => setUpdatedMessages(prev => new Set(prev).add(messageId));
34
+
35
+ const clearEditedMessage = (messageId: string) => {
36
+ setUpdatedMessages(prev => {
37
+ const set = new Set(prev);
38
+ set.delete(messageId);
39
+ return set;
40
+ });
41
+ };
42
+
43
+ const resetSelectedImage = () => {
44
+ setImageToShow(undefined);
45
+ setImageFileName(undefined);
46
+ };
47
+
48
+ // common messages interaction logic
49
+ const handleMessageClick = useCallback(
50
+ (e: React.MouseEvent<HTMLDivElement>) => {
51
+ if (!e.target) return;
52
+
53
+ // common clicks logic to simplify code in ChatMessage component
54
+ const classesToFind = ["code-copy-btn", "code-toggle-all", "copy-message-btn", "code-header", "message-image"];
55
+
56
+ let el: HTMLElement = e.target as HTMLElement;
57
+ for (const cls of classesToFind) {
58
+ const trg = el.closest(`.${cls}`);
59
+ if (trg) {
60
+ el = trg as HTMLElement;
61
+ break;
62
+ }
63
+ }
64
+
65
+ if (!el) {
66
+ return;
67
+ }
68
+
69
+ const target = el as HTMLElement;
70
+ const toggleCodeBlock = (header: HTMLElement) => {
71
+ const codeBlock = header?.nextElementSibling as HTMLElement;
72
+ if (codeBlock.classList.contains("collapsed")) {
73
+ header.classList.remove("collapsed");
74
+ codeBlock && codeBlock.classList.remove("collapsed");
75
+ } else {
76
+ header.classList.add("collapsed");
77
+ codeBlock && codeBlock.classList.add("collapsed");
78
+ }
79
+ };
80
+
81
+ // copy code block
82
+ if (target.classList.contains("code-copy-btn")) {
83
+ const data = target.parentElement?.parentElement?.nextElementSibling?.querySelector(
84
+ ".code-data"
85
+ ) as HTMLElement;
86
+ if (data) {
87
+ const code = decodeURIComponent(data.dataset.code || "").trim();
88
+ navigator.clipboard.writeText(code);
89
+ }
90
+
91
+ const copyIcon = target.querySelector(".copy-icon") as HTMLElement;
92
+ const checkIcon = target.querySelector(".check-icon") as HTMLElement;
93
+ if (copyIcon && checkIcon) {
94
+ copyIcon.style.display = "none";
95
+ checkIcon.style.display = "block";
96
+ setTimeout(() => {
97
+ copyIcon.style.display = "block";
98
+ checkIcon.style.display = "none";
99
+ }, 2000);
100
+ }
101
+ }
102
+ // code toggle btn
103
+ else if (target.classList.contains("code-header")) {
104
+ toggleCodeBlock(target);
105
+ }
106
+ // all code blocks toggle
107
+ else if (target.classList.contains("code-toggle-all")) {
108
+ componentRef.current?.querySelectorAll(".code-header").forEach(header => {
109
+ toggleCodeBlock(header as HTMLElement);
110
+ });
111
+ }
112
+ // copy message
113
+ else if (target.classList.contains("copy-message-btn")) {
114
+ if (target.dataset["messageId"]) {
115
+ const index = target.dataset["messageIndex"];
116
+ const linkedIndex = target.dataset["messageLinkedIndex"];
117
+ const messageId = target.dataset["messageId"];
118
+
119
+ let msg: Message | undefined =
120
+ index != undefined ? messages[Number(index)] : messages.find(m => m.id === messageId);
121
+ if (linkedIndex != undefined) {
122
+ msg = msg?.linkedMessages?.[Number(linkedIndex)];
123
+ }
124
+ ok(msg, "Message should exist to copy");
125
+ const content = (msg.content || "").trim();
126
+ const html = msg.html || [];
127
+
128
+ if (html.length && html[0]) {
129
+ const blobHTML = new Blob([html.join("<br/>")], { type: "text/html" });
130
+ const blobPlain = new Blob([content], { type: "text/plain" });
131
+ navigator.clipboard
132
+ .write([new ClipboardItem({ [blobHTML.type]: blobHTML, [blobPlain.type]: blobPlain })])
133
+ .catch(err =>
134
+ notifications.show({
135
+ title: "Error",
136
+ message: err.message || "Failed to copy message",
137
+ color: "red",
138
+ })
139
+ );
140
+ } else {
141
+ navigator.clipboard.writeText(content);
142
+ }
143
+
144
+ const checkIcon = target.parentElement?.querySelector(".check-icon") as HTMLElement;
145
+ if (checkIcon) {
146
+ target.style.display = "none";
147
+ checkIcon.style.display = "inline-block";
148
+ setTimeout(() => {
149
+ target.style.display = "inline-block";
150
+ checkIcon.style.display = "none";
151
+ }, 2000);
152
+ }
153
+ }
154
+ }
155
+ // code toggle btn
156
+ else if (target.classList.contains("message-image")) {
157
+ const fileName = target.dataset["fileName"];
158
+ const imageUrl = (target as HTMLImageElement).src;
159
+
160
+ setImageToShow(imageUrl);
161
+ setImageFileName(fileName);
162
+ }
163
+ },
164
+ [messages]
165
+ );
166
+
167
+ const pluginsLoader = useCallback(
168
+ (msg: Message) => {
169
+ return (
170
+ <>
171
+ {plugins.map((PluginComponent, idx) => (
172
+ <PluginComponent
173
+ key={idx}
174
+ message={msg}
175
+ onAddMessage={onAddMessage}
176
+ onAction={addEditedMessage}
177
+ onActionEnd={clearEditedMessage}
178
+ onMessageDeleted={onMessageDeleted}
179
+ disabled={updatedMessages.has(msg.id)}
180
+ />
181
+ ))}
182
+ </>
183
+ );
184
+ },
185
+ [plugins, onAddMessage, onMessageDeleted, updatedMessages, addEditedMessage, clearEditedMessage]
186
+ );
187
+
188
+ const messageDetailsLoader = useCallback(
189
+ (msg: Message) => {
190
+ const details = detailsPlugins.map((plugin, idx) => plugin(msg)).filter(notEmpty);
191
+ return details.length ? details : null;
192
+ },
193
+ [plugins, detailsPlugins]
194
+ );
195
+
196
+ return (
197
+ <>
198
+ <Stack gap="xs" ref={componentRef} onClick={handleMessageClick}>
199
+ {messages.map((msg, index) => (
200
+ <Group key={msg.id} align="flex-start" gap="xs">
201
+ <ChatMessage
202
+ message={msg}
203
+ index={index}
204
+ disabled={updatedMessages.has(msg.id)}
205
+ pluginsLoader={pluginsLoader}
206
+ messageDetailsLoader={messageDetailsLoader}
207
+ models={models}
208
+ />
209
+ </Group>
210
+ ))}
211
+ </Stack>
212
+
213
+ <ImagePopup fileName={imageFileName ?? ""} fileUrl={imageToShow ?? ""} onClose={resetSelectedImage} />
214
+ </>
215
+ );
216
+ };
@@ -0,0 +1,4 @@
1
+ export * from "./message";
2
+ export * from "./input";
3
+ export * from "./ChatMessagesList";
4
+ export { ChatMessagesContainer, type ChatMessagesContainerRef } from "./ChatMessagesContainer";