@memori.ai/memori-react 8.17.3 → 8.18.1
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 +25 -0
- package/README.md +1 -0
- package/dist/components/Chat/Chat.d.ts +1 -0
- package/dist/components/Chat/Chat.js +2 -2
- package/dist/components/Chat/Chat.js.map +1 -1
- package/dist/components/ChatBubble/ChatBubble.js +2 -2
- package/dist/components/ChatBubble/ChatBubble.js.map +1 -1
- package/dist/components/ChatInputs/ChatInputs.css +9 -3
- package/dist/components/ChatInputs/ChatInputs.d.ts +1 -0
- package/dist/components/ChatInputs/ChatInputs.js +69 -3
- package/dist/components/ChatInputs/ChatInputs.js.map +1 -1
- package/dist/components/ChatTextArea/ChatTextArea.d.ts +1 -0
- package/dist/components/ChatTextArea/ChatTextArea.js +2 -2
- package/dist/components/ChatTextArea/ChatTextArea.js.map +1 -1
- package/dist/components/ContentPreviewModal/ContentPreviewModal.css +15 -0
- package/dist/components/FilePreview/FilePreview.css +17 -9
- package/dist/components/FilePreview/FilePreview.js +1 -2
- package/dist/components/FilePreview/FilePreview.js.map +1 -1
- package/dist/components/MediaWidget/MediaItemWidget.js +7 -1
- package/dist/components/MediaWidget/MediaItemWidget.js.map +1 -1
- package/dist/components/MemoriWidget/MemoriWidget.d.ts +2 -1
- package/dist/components/MemoriWidget/MemoriWidget.js +2 -1
- package/dist/components/MemoriWidget/MemoriWidget.js.map +1 -1
- package/dist/components/UploadButton/UploadButton.d.ts +1 -0
- package/dist/components/UploadButton/UploadButton.js +50 -24
- package/dist/components/UploadButton/UploadButton.js.map +1 -1
- package/dist/components/UploadButton/UploadDocuments/UploadDocuments.d.ts +5 -1
- package/dist/components/UploadButton/UploadDocuments/UploadDocuments.js +76 -21
- package/dist/components/UploadButton/UploadDocuments/UploadDocuments.js.map +1 -1
- package/dist/components/UploadButton/UploadImages/UploadImages.js +23 -4
- package/dist/components/UploadButton/UploadImages/UploadImages.js.map +1 -1
- package/dist/components/layouts/fullpage.css +1 -0
- package/dist/helpers/constants.d.ts +3 -1
- package/dist/helpers/constants.js +4 -2
- package/dist/helpers/constants.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/locales/de.json +12 -1
- package/dist/locales/en.json +15 -1
- package/dist/locales/es.json +12 -1
- package/dist/locales/fr.json +12 -1
- package/dist/locales/it.json +15 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/esm/components/Chat/Chat.d.ts +1 -0
- package/esm/components/Chat/Chat.js +2 -2
- package/esm/components/Chat/Chat.js.map +1 -1
- package/esm/components/ChatBubble/ChatBubble.js +2 -2
- package/esm/components/ChatBubble/ChatBubble.js.map +1 -1
- package/esm/components/ChatInputs/ChatInputs.css +9 -3
- package/esm/components/ChatInputs/ChatInputs.d.ts +1 -0
- package/esm/components/ChatInputs/ChatInputs.js +70 -4
- package/esm/components/ChatInputs/ChatInputs.js.map +1 -1
- package/esm/components/ChatTextArea/ChatTextArea.d.ts +1 -0
- package/esm/components/ChatTextArea/ChatTextArea.js +2 -2
- package/esm/components/ChatTextArea/ChatTextArea.js.map +1 -1
- package/esm/components/ContentPreviewModal/ContentPreviewModal.css +15 -0
- package/esm/components/FilePreview/FilePreview.css +17 -9
- package/esm/components/FilePreview/FilePreview.js +1 -2
- package/esm/components/FilePreview/FilePreview.js.map +1 -1
- package/esm/components/MediaWidget/MediaItemWidget.js +7 -1
- package/esm/components/MediaWidget/MediaItemWidget.js.map +1 -1
- package/esm/components/MemoriWidget/MemoriWidget.d.ts +2 -1
- package/esm/components/MemoriWidget/MemoriWidget.js +2 -1
- package/esm/components/MemoriWidget/MemoriWidget.js.map +1 -1
- package/esm/components/UploadButton/UploadButton.d.ts +1 -0
- package/esm/components/UploadButton/UploadButton.js +50 -24
- package/esm/components/UploadButton/UploadButton.js.map +1 -1
- package/esm/components/UploadButton/UploadDocuments/UploadDocuments.d.ts +5 -1
- package/esm/components/UploadButton/UploadDocuments/UploadDocuments.js +77 -22
- package/esm/components/UploadButton/UploadDocuments/UploadDocuments.js.map +1 -1
- package/esm/components/UploadButton/UploadImages/UploadImages.js +23 -4
- package/esm/components/UploadButton/UploadImages/UploadImages.js.map +1 -1
- package/esm/components/layouts/fullpage.css +1 -0
- package/esm/helpers/constants.d.ts +3 -1
- package/esm/helpers/constants.js +3 -1
- package/esm/helpers/constants.js.map +1 -1
- package/esm/index.d.ts +1 -0
- package/esm/index.js +3 -2
- package/esm/index.js.map +1 -1
- package/esm/locales/de.json +12 -1
- package/esm/locales/en.json +15 -1
- package/esm/locales/es.json +12 -1
- package/esm/locales/fr.json +12 -1
- package/esm/locales/it.json +15 -1
- package/esm/version.d.ts +1 -1
- package/esm/version.js +1 -1
- package/package.json +1 -1
- package/src/__snapshots__/index.test.tsx.snap +5 -5
- package/src/components/Chat/Chat.tsx +4 -0
- package/src/components/Chat/__snapshots__/Chat.test.tsx.snap +27 -81
- package/src/components/ChatBubble/ChatBubble.tsx +2 -2
- package/src/components/ChatBubble/__snapshots__/ChatBubble.test.tsx.snap +7 -21
- package/src/components/ChatInputs/ChatInputs.css +9 -3
- package/src/components/ChatInputs/ChatInputs.test.tsx +328 -1
- package/src/components/ChatInputs/ChatInputs.tsx +130 -8
- package/src/components/ChatTextArea/ChatTextArea.tsx +3 -0
- package/src/components/ContentPreviewModal/ContentPreviewModal.css +15 -0
- package/src/components/FilePreview/FilePreview.css +17 -9
- package/src/components/FilePreview/FilePreview.tsx +1 -7
- package/src/components/FilePreview/__snapshots__/FilePreview.test.tsx.snap +3 -3
- package/src/components/MediaWidget/MediaItemWidget.tsx +20 -2
- package/src/components/MemoriWidget/MemoriWidget.tsx +4 -0
- package/src/components/UploadButton/UploadButton.tsx +58 -31
- package/src/components/UploadButton/UploadDocuments/UploadDocuments.tsx +98 -30
- package/src/components/UploadButton/UploadImages/UploadImages.tsx +26 -5
- package/src/components/layouts/layouts.stories.tsx +1 -31
- package/src/helpers/constants.ts +16 -4
- package/src/index.stories.tsx +3 -2
- package/src/index.tsx +5 -0
- package/src/locales/de.json +12 -1
- package/src/locales/en.json +15 -1
- package/src/locales/es.json +12 -1
- package/src/locales/fr.json +12 -1
- package/src/locales/it.json +15 -1
- package/src/version.ts +1 -1
|
@@ -1,7 +1,69 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { render } from '@testing-library/react';
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
3
3
|
import ChatInputs from './ChatInputs';
|
|
4
4
|
import { dialogState } from '../../mocks/data';
|
|
5
|
+
import {
|
|
6
|
+
PASTE_AS_CARD_CHAR_THRESHOLD,
|
|
7
|
+
PASTE_AS_CARD_LINE_THRESHOLD,
|
|
8
|
+
} from '../../helpers/constants';
|
|
9
|
+
|
|
10
|
+
jest.mock('react-hot-toast', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
default: {
|
|
13
|
+
success: jest.fn(),
|
|
14
|
+
error: jest.fn(),
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// jsdom does not define DataTransfer; UploadButton's paste handler uses it when clipboard has files
|
|
19
|
+
if (typeof globalThis.DataTransfer === 'undefined') {
|
|
20
|
+
(globalThis as any).DataTransfer = class DataTransfer {
|
|
21
|
+
_files: File[] = [];
|
|
22
|
+
get files(): File[] {
|
|
23
|
+
return this._files;
|
|
24
|
+
}
|
|
25
|
+
items = {
|
|
26
|
+
add: (file: File) => {
|
|
27
|
+
this._files.push(file);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// jsdom does not define speechSynthesis/SpeechSynthesisUtterance; ChatInputs uses them in onSendMessage
|
|
34
|
+
if (typeof (globalThis as any).speechSynthesis === 'undefined') {
|
|
35
|
+
(globalThis as any).speechSynthesis = { speak: jest.fn() };
|
|
36
|
+
}
|
|
37
|
+
if (typeof (globalThis as any).SpeechSynthesisUtterance === 'undefined') {
|
|
38
|
+
(globalThis as any).SpeechSynthesisUtterance = function () {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const defaultProps = {
|
|
42
|
+
dialogState,
|
|
43
|
+
userMessage: '',
|
|
44
|
+
onChangeUserMessage: jest.fn(),
|
|
45
|
+
sendMessage: jest.fn(),
|
|
46
|
+
onTextareaFocus: jest.fn(),
|
|
47
|
+
onTextareaBlur: jest.fn(),
|
|
48
|
+
setAttachmentsMenuOpen: jest.fn(),
|
|
49
|
+
setSendOnEnter: jest.fn(),
|
|
50
|
+
listening: false,
|
|
51
|
+
isPlayingAudio: false,
|
|
52
|
+
stopAudio: jest.fn(),
|
|
53
|
+
startListening: jest.fn(),
|
|
54
|
+
stopListening: jest.fn(),
|
|
55
|
+
showMicrophone: true,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function createPasteEvent(plainText: string, files: File[] = []) {
|
|
59
|
+
return {
|
|
60
|
+
clipboardData: {
|
|
61
|
+
files,
|
|
62
|
+
getData: (type: string) => (type === 'text/plain' ? plainText : ''),
|
|
63
|
+
},
|
|
64
|
+
preventDefault: jest.fn(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
5
67
|
|
|
6
68
|
it('renders ChatInputs unchanged', () => {
|
|
7
69
|
const { container } = render(
|
|
@@ -141,3 +203,268 @@ it('renders ChatInputs disabled unchanged', () => {
|
|
|
141
203
|
);
|
|
142
204
|
expect(container).toMatchSnapshot();
|
|
143
205
|
});
|
|
206
|
+
|
|
207
|
+
describe('paste as card (long text becomes attachment)', () => {
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
jest.clearAllMocks();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('adds pasted long text (by chars) as attachment when showUpload is true', async () => {
|
|
213
|
+
const longText = 'x'.repeat(PASTE_AS_CARD_CHAR_THRESHOLD + 1);
|
|
214
|
+
render(
|
|
215
|
+
<ChatInputs
|
|
216
|
+
{...defaultProps}
|
|
217
|
+
showUpload={true}
|
|
218
|
+
dialogState={{ ...dialogState, acceptsMedia: true }}
|
|
219
|
+
/>
|
|
220
|
+
);
|
|
221
|
+
const textarea = document.querySelector('textarea');
|
|
222
|
+
expect(textarea).toBeTruthy();
|
|
223
|
+
|
|
224
|
+
const paste = createPasteEvent(longText);
|
|
225
|
+
fireEvent.paste(textarea!, paste);
|
|
226
|
+
|
|
227
|
+
await waitFor(() => {
|
|
228
|
+
expect(screen.getByText('upload.pastedText')).toBeTruthy();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('adds pasted long text (by lines) as attachment when showUpload is true', async () => {
|
|
233
|
+
const lines = Array(PASTE_AS_CARD_LINE_THRESHOLD + 1)
|
|
234
|
+
.fill('line')
|
|
235
|
+
.join('\n');
|
|
236
|
+
render(
|
|
237
|
+
<ChatInputs
|
|
238
|
+
{...defaultProps}
|
|
239
|
+
showUpload={true}
|
|
240
|
+
dialogState={{ ...dialogState, acceptsMedia: true }}
|
|
241
|
+
/>
|
|
242
|
+
);
|
|
243
|
+
const textarea = document.querySelector('textarea');
|
|
244
|
+
const paste = createPasteEvent(lines);
|
|
245
|
+
fireEvent.paste(textarea!, paste);
|
|
246
|
+
|
|
247
|
+
await waitFor(() => {
|
|
248
|
+
expect(screen.getByText('upload.pastedText')).toBeTruthy();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('adds card for short paste (all paste treated as document)', async () => {
|
|
253
|
+
const shortText = 'hello';
|
|
254
|
+
render(
|
|
255
|
+
<ChatInputs
|
|
256
|
+
{...defaultProps}
|
|
257
|
+
showUpload={true}
|
|
258
|
+
dialogState={{ ...dialogState, acceptsMedia: true }}
|
|
259
|
+
/>
|
|
260
|
+
);
|
|
261
|
+
const textarea = document.querySelector('textarea');
|
|
262
|
+
const paste = createPasteEvent(shortText);
|
|
263
|
+
fireEvent.paste(textarea!, paste);
|
|
264
|
+
|
|
265
|
+
await waitFor(() => {
|
|
266
|
+
expect(screen.getByText('upload.pastedText')).toBeTruthy();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('adds card even when showUpload is false (paste-as-card always enabled)', async () => {
|
|
271
|
+
const longText = 'x'.repeat(PASTE_AS_CARD_CHAR_THRESHOLD + 1);
|
|
272
|
+
render(
|
|
273
|
+
<ChatInputs
|
|
274
|
+
{...defaultProps}
|
|
275
|
+
showUpload={false}
|
|
276
|
+
/>
|
|
277
|
+
);
|
|
278
|
+
const textarea = document.querySelector('textarea');
|
|
279
|
+
fireEvent.paste(textarea!, createPasteEvent(longText));
|
|
280
|
+
|
|
281
|
+
await waitFor(() => {
|
|
282
|
+
expect(screen.getByText('upload.pastedText')).toBeTruthy();
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('calls toast.error when max attachments reached', async () => {
|
|
287
|
+
const { MAX_DOCUMENTS_PER_MESSAGE } = require('../../helpers/constants');
|
|
288
|
+
const longText = 'x'.repeat(PASTE_AS_CARD_CHAR_THRESHOLD + 1);
|
|
289
|
+
render(
|
|
290
|
+
<ChatInputs
|
|
291
|
+
{...defaultProps}
|
|
292
|
+
showUpload={true}
|
|
293
|
+
dialogState={{ ...dialogState, acceptsMedia: true }}
|
|
294
|
+
/>
|
|
295
|
+
);
|
|
296
|
+
const textarea = document.querySelector('textarea');
|
|
297
|
+
for (let i = 0; i < MAX_DOCUMENTS_PER_MESSAGE; i++) {
|
|
298
|
+
fireEvent.paste(textarea!, createPasteEvent(longText + i));
|
|
299
|
+
await waitFor(() => {
|
|
300
|
+
expect(document.querySelectorAll('.memori--preview-filename').length).toBe(i + 1);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
fireEvent.paste(textarea!, createPasteEvent(longText + ' over'));
|
|
305
|
+
|
|
306
|
+
await waitFor(() => {
|
|
307
|
+
const toast = require('react-hot-toast').default;
|
|
308
|
+
expect(toast.error).toHaveBeenCalled();
|
|
309
|
+
});
|
|
310
|
+
expect(document.querySelectorAll('.memori--preview-filename').length).toBe(
|
|
311
|
+
MAX_DOCUMENTS_PER_MESSAGE
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('calls toast.error when pasted content exceeds size limit', async () => {
|
|
316
|
+
const { MAX_DOCUMENT_CONTENT_LENGTH } = require('../../helpers/constants');
|
|
317
|
+
const tooLongText = 'x'.repeat(MAX_DOCUMENT_CONTENT_LENGTH + 1);
|
|
318
|
+
render(
|
|
319
|
+
<ChatInputs
|
|
320
|
+
{...defaultProps}
|
|
321
|
+
showUpload={true}
|
|
322
|
+
dialogState={{ ...dialogState, acceptsMedia: true }}
|
|
323
|
+
/>
|
|
324
|
+
);
|
|
325
|
+
const textarea = document.querySelector('textarea');
|
|
326
|
+
const paste = createPasteEvent(tooLongText);
|
|
327
|
+
fireEvent.paste(textarea!, paste);
|
|
328
|
+
|
|
329
|
+
await waitFor(() => {
|
|
330
|
+
const toast = require('react-hot-toast').default;
|
|
331
|
+
expect(toast.error).toHaveBeenCalled();
|
|
332
|
+
});
|
|
333
|
+
expect(screen.queryByText('upload.pastedText')).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('adds card when paste has few lines', async () => {
|
|
337
|
+
const fewLines = Array(5).fill('line').join('\n');
|
|
338
|
+
render(
|
|
339
|
+
<ChatInputs
|
|
340
|
+
{...defaultProps}
|
|
341
|
+
showUpload={true}
|
|
342
|
+
dialogState={{ ...dialogState, acceptsMedia: true }}
|
|
343
|
+
/>
|
|
344
|
+
);
|
|
345
|
+
const textarea = document.querySelector('textarea');
|
|
346
|
+
fireEvent.paste(textarea!, createPasteEvent(fewLines));
|
|
347
|
+
await waitFor(() => {
|
|
348
|
+
expect(screen.getByText('upload.pastedText')).toBeTruthy();
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('adds card when paste has few chars', async () => {
|
|
353
|
+
const fewChars = 'hi';
|
|
354
|
+
render(
|
|
355
|
+
<ChatInputs
|
|
356
|
+
{...defaultProps}
|
|
357
|
+
showUpload={true}
|
|
358
|
+
dialogState={{ ...dialogState, acceptsMedia: true }}
|
|
359
|
+
/>
|
|
360
|
+
);
|
|
361
|
+
const textarea = document.querySelector('textarea');
|
|
362
|
+
fireEvent.paste(textarea!, createPasteEvent(fewChars));
|
|
363
|
+
await waitFor(() => {
|
|
364
|
+
expect(screen.getByText('upload.pastedText')).toBeTruthy();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('does not add card when clipboard has files (handler returns early)', () => {
|
|
369
|
+
const longText = 'x'.repeat(PASTE_AS_CARD_CHAR_THRESHOLD + 1);
|
|
370
|
+
const file = new File(['a'], 'a.txt', { type: 'text/plain' });
|
|
371
|
+
const paste = createPasteEvent(longText, [file]);
|
|
372
|
+
render(
|
|
373
|
+
<ChatInputs
|
|
374
|
+
{...defaultProps}
|
|
375
|
+
showUpload={true}
|
|
376
|
+
dialogState={{ ...dialogState, acceptsMedia: true }}
|
|
377
|
+
/>
|
|
378
|
+
);
|
|
379
|
+
const textarea = document.querySelector('textarea');
|
|
380
|
+
fireEvent.paste(textarea!, paste);
|
|
381
|
+
expect(screen.queryByText('upload.pastedText')).toBeNull();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('does not add card for empty or whitespace-only paste', () => {
|
|
385
|
+
render(
|
|
386
|
+
<ChatInputs
|
|
387
|
+
{...defaultProps}
|
|
388
|
+
showUpload={true}
|
|
389
|
+
dialogState={{ ...dialogState, acceptsMedia: true }}
|
|
390
|
+
/>
|
|
391
|
+
);
|
|
392
|
+
const textarea = document.querySelector('textarea');
|
|
393
|
+
fireEvent.paste(textarea!, createPasteEvent(''));
|
|
394
|
+
fireEvent.paste(textarea!, createPasteEvent(' \n\t '));
|
|
395
|
+
expect(screen.queryByText('upload.pastedText')).toBeNull();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('adds two cards when same long text is pasted twice (duplicate paste)', async () => {
|
|
399
|
+
const longText = 'x'.repeat(PASTE_AS_CARD_CHAR_THRESHOLD + 1);
|
|
400
|
+
render(
|
|
401
|
+
<ChatInputs
|
|
402
|
+
{...defaultProps}
|
|
403
|
+
showUpload={true}
|
|
404
|
+
dialogState={{ ...dialogState, acceptsMedia: true }}
|
|
405
|
+
/>
|
|
406
|
+
);
|
|
407
|
+
const textarea = document.querySelector('textarea');
|
|
408
|
+
fireEvent.paste(textarea!, createPasteEvent(longText));
|
|
409
|
+
await waitFor(() => {
|
|
410
|
+
expect(screen.getByText('upload.pastedText')).toBeTruthy();
|
|
411
|
+
});
|
|
412
|
+
fireEvent.paste(textarea!, createPasteEvent(longText));
|
|
413
|
+
await waitFor(() => {
|
|
414
|
+
expect(document.querySelectorAll('.memori--preview-filename').length).toBe(2);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('shows FilePreview when showUpload is false but pasted card was added', async () => {
|
|
419
|
+
const longText = 'x'.repeat(PASTE_AS_CARD_CHAR_THRESHOLD + 1);
|
|
420
|
+
render(
|
|
421
|
+
<ChatInputs
|
|
422
|
+
{...defaultProps}
|
|
423
|
+
showUpload={false}
|
|
424
|
+
/>
|
|
425
|
+
);
|
|
426
|
+
expect(document.querySelector('.memori--preview-container')).toBeNull();
|
|
427
|
+
const textarea = document.querySelector('textarea');
|
|
428
|
+
fireEvent.paste(textarea!, createPasteEvent(longText));
|
|
429
|
+
await waitFor(() => {
|
|
430
|
+
expect(screen.getByText('upload.pastedText')).toBeTruthy();
|
|
431
|
+
});
|
|
432
|
+
expect(document.querySelector('.memori--preview-container')).toBeTruthy();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('calls sendMessage with pasted card in media when user sends', async () => {
|
|
436
|
+
const sendMessageMock = jest.fn();
|
|
437
|
+
const pastedText = 'pasted content here';
|
|
438
|
+
const { container } = render(
|
|
439
|
+
<ChatInputs
|
|
440
|
+
{...defaultProps}
|
|
441
|
+
showUpload={true}
|
|
442
|
+
userMessage="hello"
|
|
443
|
+
sendMessage={sendMessageMock}
|
|
444
|
+
dialogState={{ ...dialogState, acceptsMedia: true }}
|
|
445
|
+
/>
|
|
446
|
+
);
|
|
447
|
+
const textarea = document.querySelector('textarea');
|
|
448
|
+
fireEvent.paste(textarea!, createPasteEvent(pastedText));
|
|
449
|
+
await waitFor(() => {
|
|
450
|
+
expect(screen.getByText('upload.pastedText')).toBeTruthy();
|
|
451
|
+
});
|
|
452
|
+
const sendButton = container.querySelector('.memori-chat-inputs--send-btn');
|
|
453
|
+
expect(sendButton).toBeTruthy();
|
|
454
|
+
fireEvent.click(sendButton!);
|
|
455
|
+
|
|
456
|
+
expect(sendMessageMock).toHaveBeenCalledTimes(1);
|
|
457
|
+
const [, media] = sendMessageMock.mock.calls[0];
|
|
458
|
+
expect(media).toHaveLength(1);
|
|
459
|
+
expect(media![0]).toMatchObject({
|
|
460
|
+
mimeType: 'text/plain',
|
|
461
|
+
title: 'upload.pastedText',
|
|
462
|
+
type: 'document',
|
|
463
|
+
properties: expect.objectContaining({ isAttachedFile: true }),
|
|
464
|
+
});
|
|
465
|
+
expect(media![0].mediumID).toBeDefined();
|
|
466
|
+
expect(media![0].content).toContain('<document_attachment');
|
|
467
|
+
expect(media![0].content).toContain('</document_attachment>');
|
|
468
|
+
expect(media![0].content).toContain(pastedText);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useCallback, useState } from 'react';
|
|
2
2
|
import { DialogState, Medium } from '@memori.ai/memori-api-client/dist/types';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import toast from 'react-hot-toast';
|
|
3
5
|
import ChatTextArea from '../ChatTextArea/ChatTextArea';
|
|
4
6
|
import Button from '../ui/Button';
|
|
5
|
-
import { useTranslation } from 'react-i18next';
|
|
6
7
|
import Send from '../icons/Send';
|
|
7
8
|
import MicrophoneButton from '../MicrophoneButton/MicrophoneButton';
|
|
8
9
|
import cx from 'classnames';
|
|
@@ -11,6 +12,11 @@ import UploadButton from '../UploadButton/UploadButton';
|
|
|
11
12
|
import FilePreview from '../FilePreview/FilePreview';
|
|
12
13
|
import memoriApiClient from '@memori.ai/memori-api-client';
|
|
13
14
|
import Plus from '../icons/Plus';
|
|
15
|
+
import {
|
|
16
|
+
MAX_DOCUMENTS_PER_MESSAGE,
|
|
17
|
+
MAX_DOCUMENT_CONTENT_LENGTH,
|
|
18
|
+
MAX_TOTAL_MESSAGE_PAYLOAD,
|
|
19
|
+
} from '../../helpers/constants';
|
|
14
20
|
export interface Props {
|
|
15
21
|
dialogState?: DialogState;
|
|
16
22
|
instruct?: boolean;
|
|
@@ -37,6 +43,8 @@ export interface Props {
|
|
|
37
43
|
memoriID?: string;
|
|
38
44
|
client?: ReturnType<typeof memoriApiClient>;
|
|
39
45
|
onTextareaExpanded?: (expanded: boolean) => void;
|
|
46
|
+
/** Override total document payload limit (character count). */
|
|
47
|
+
maxTotalMessagePayload?: number;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
const ChatInputs: React.FC<Props> = ({
|
|
@@ -60,6 +68,7 @@ const ChatInputs: React.FC<Props> = ({
|
|
|
60
68
|
memoriID,
|
|
61
69
|
client,
|
|
62
70
|
onTextareaExpanded,
|
|
71
|
+
maxTotalMessagePayload,
|
|
63
72
|
}) => {
|
|
64
73
|
const { t } = useTranslation();
|
|
65
74
|
|
|
@@ -84,6 +93,8 @@ const ChatInputs: React.FC<Props> = ({
|
|
|
84
93
|
dialog: { postMediumDeselectedEvent: null },
|
|
85
94
|
};
|
|
86
95
|
|
|
96
|
+
const totalPayloadLimit = maxTotalMessagePayload ?? MAX_TOTAL_MESSAGE_PAYLOAD;
|
|
97
|
+
|
|
87
98
|
/**
|
|
88
99
|
* Handles sending a message, including any attached files
|
|
89
100
|
*/
|
|
@@ -100,6 +111,17 @@ const ChatInputs: React.FC<Props> = ({
|
|
|
100
111
|
) => {
|
|
101
112
|
if (isTyping) return;
|
|
102
113
|
|
|
114
|
+
const totalContentLength = files.reduce((sum, f) => sum + f.content.length, 0);
|
|
115
|
+
if (totalContentLength > totalPayloadLimit) {
|
|
116
|
+
toast.error(
|
|
117
|
+
t('upload.contextSizeExceedsLimit', {
|
|
118
|
+
defaultValue:
|
|
119
|
+
'Context size exceeds the limit. Try reducing the number of files or content in the conversation.',
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
103
125
|
const mediaWithIds = files.map((file, index) => {
|
|
104
126
|
const generatedMediumID =
|
|
105
127
|
file.mediumID ||
|
|
@@ -142,6 +164,19 @@ const ChatInputs: React.FC<Props> = ({
|
|
|
142
164
|
|
|
143
165
|
if (sendOnEnter === 'keypress' && userMessage?.length > 0) {
|
|
144
166
|
stopListening();
|
|
167
|
+
const totalContentLength = documentPreviewFiles.reduce(
|
|
168
|
+
(sum, f) => sum + f.content.length,
|
|
169
|
+
0
|
|
170
|
+
);
|
|
171
|
+
if (totalContentLength > totalPayloadLimit) {
|
|
172
|
+
toast.error(
|
|
173
|
+
t('upload.contextSizeExceedsLimit', {
|
|
174
|
+
defaultValue:
|
|
175
|
+
'Context size exceeds the limit. Try reducing the number of files or content in the conversation.',
|
|
176
|
+
})
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
145
180
|
const mediaWithIds = documentPreviewFiles.map((file, index) => {
|
|
146
181
|
const generatedMediumID =
|
|
147
182
|
file.mediumID ||
|
|
@@ -199,6 +234,89 @@ const ChatInputs: React.FC<Props> = ({
|
|
|
199
234
|
}
|
|
200
235
|
};
|
|
201
236
|
|
|
237
|
+
/**
|
|
238
|
+
* All pasted text is treated as a document attachment: wrapped in <document_attachment>
|
|
239
|
+
* and added to the document preview, then sent as media via sendMessage (same as other media).
|
|
240
|
+
*/
|
|
241
|
+
const handleTextareaPaste = useCallback(
|
|
242
|
+
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
243
|
+
if (e.clipboardData.files?.length) return;
|
|
244
|
+
const text = e.clipboardData.getData('text/plain');
|
|
245
|
+
if (!text?.trim()) return;
|
|
246
|
+
|
|
247
|
+
// Critical: max attachments reached – prevent dumping long text into textarea, show feedback
|
|
248
|
+
if (documentPreviewFiles.length >= MAX_DOCUMENTS_PER_MESSAGE) {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
toast.error(
|
|
251
|
+
t('upload.pasteMaxAttachmentsReached', {
|
|
252
|
+
max: MAX_DOCUMENTS_PER_MESSAGE,
|
|
253
|
+
defaultValue: `Maximum ${MAX_DOCUMENTS_PER_MESSAGE} attachments. Remove one to add this as a file.`,
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Critical: pasted content exceeds single-document size limit – reject and inform (same prop as total limit)
|
|
260
|
+
const perDocumentLimit = maxTotalMessagePayload ?? MAX_DOCUMENT_CONTENT_LENGTH;
|
|
261
|
+
if (text.length > perDocumentLimit) {
|
|
262
|
+
e.preventDefault();
|
|
263
|
+
toast.error(
|
|
264
|
+
t('upload.contextSizeExceedsLimit', {
|
|
265
|
+
defaultValue:
|
|
266
|
+
'Context size exceeds the limit. Try reducing the number of files or content in the conversation.',
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const totalPayloadLimit = maxTotalMessagePayload ?? MAX_TOTAL_MESSAGE_PAYLOAD;
|
|
273
|
+
const currentTotal = documentPreviewFiles.reduce(
|
|
274
|
+
(sum, f) => sum + f.content.length,
|
|
275
|
+
0
|
|
276
|
+
);
|
|
277
|
+
if (currentTotal + text.length > totalPayloadLimit) {
|
|
278
|
+
e.preventDefault();
|
|
279
|
+
toast.error(
|
|
280
|
+
t('upload.contextSizeExceedsLimit', {
|
|
281
|
+
defaultValue:
|
|
282
|
+
'Context size exceeds the limit. Try reducing the number of files or content in the conversation.',
|
|
283
|
+
})
|
|
284
|
+
);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
e.preventDefault();
|
|
289
|
+
const displayName = t('upload.pastedText') || 'pasted-text';
|
|
290
|
+
const wrappedContent = `<document_attachment filename="pasted-text.txt" type="text/plain">
|
|
291
|
+
|
|
292
|
+
${text}
|
|
293
|
+
|
|
294
|
+
</document_attachment>`;
|
|
295
|
+
const newFile = {
|
|
296
|
+
name: displayName,
|
|
297
|
+
id: `paste_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
|
298
|
+
content: wrappedContent,
|
|
299
|
+
mediumID: undefined as string | undefined,
|
|
300
|
+
mimeType: 'text/plain',
|
|
301
|
+
type: 'document',
|
|
302
|
+
};
|
|
303
|
+
setDocumentPreviewFiles(
|
|
304
|
+
(
|
|
305
|
+
prev: {
|
|
306
|
+
name: string;
|
|
307
|
+
id: string;
|
|
308
|
+
content: string;
|
|
309
|
+
mediumID: string | undefined;
|
|
310
|
+
mimeType: string;
|
|
311
|
+
type: string;
|
|
312
|
+
url?: string;
|
|
313
|
+
}[]
|
|
314
|
+
) => [...prev, newFile]
|
|
315
|
+
);
|
|
316
|
+
},
|
|
317
|
+
[documentPreviewFiles, maxTotalMessagePayload, t]
|
|
318
|
+
);
|
|
319
|
+
|
|
202
320
|
const isDisabled =
|
|
203
321
|
dialogState?.state === 'X2a' || dialogState?.state === 'X3';
|
|
204
322
|
const textareaDisabled = ['R2', 'R3', 'R4', 'R5', 'G3', 'X3'].includes(
|
|
@@ -214,12 +332,14 @@ const ChatInputs: React.FC<Props> = ({
|
|
|
214
332
|
})}
|
|
215
333
|
disabled={isDisabled}
|
|
216
334
|
>
|
|
217
|
-
{/* Preview for document files */}
|
|
218
|
-
{showUpload && (
|
|
219
|
-
<
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
335
|
+
{/* Preview for document files (show when upload enabled or when paste added cards) */}
|
|
336
|
+
{(showUpload || documentPreviewFiles.length > 0) && (
|
|
337
|
+
<div className="memori-chat-inputs--preview-wrapper">
|
|
338
|
+
<FilePreview
|
|
339
|
+
previewFiles={documentPreviewFiles}
|
|
340
|
+
removeFile={removeFile}
|
|
341
|
+
/>
|
|
342
|
+
</div>
|
|
223
343
|
)}
|
|
224
344
|
<div className="memori-chat-inputs--container">
|
|
225
345
|
{/* Leading area - Plus button */}
|
|
@@ -234,6 +354,7 @@ const ChatInputs: React.FC<Props> = ({
|
|
|
234
354
|
setDocumentPreviewFiles={setDocumentPreviewFiles}
|
|
235
355
|
documentPreviewFiles={documentPreviewFiles}
|
|
236
356
|
memoriID={memoriID}
|
|
357
|
+
maxTotalMessagePayload={maxTotalMessagePayload}
|
|
237
358
|
/>
|
|
238
359
|
</div>
|
|
239
360
|
)}
|
|
@@ -245,6 +366,7 @@ const ChatInputs: React.FC<Props> = ({
|
|
|
245
366
|
value={userMessage}
|
|
246
367
|
onChange={onChangeUserMessage}
|
|
247
368
|
onPressEnter={onTextareaPressEnter}
|
|
369
|
+
onPaste={handleTextareaPaste}
|
|
248
370
|
onFocus={onTextareaFocus}
|
|
249
371
|
onBlur={onTextareaBlur}
|
|
250
372
|
onExpandedChange={handleTextareaExpanded}
|
|
@@ -11,6 +11,7 @@ export interface Props {
|
|
|
11
11
|
value: string;
|
|
12
12
|
onChange: (value: string) => void;
|
|
13
13
|
onPressEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
14
|
+
onPaste?: (e: React.ClipboardEvent<HTMLTextAreaElement>) => void;
|
|
14
15
|
onFocus?: (e: React.FocusEvent) => void;
|
|
15
16
|
onBlur?: (e: React.FocusEvent) => void;
|
|
16
17
|
onExpandedChange?: (expanded: boolean) => void;
|
|
@@ -21,6 +22,7 @@ const ChatTextArea: React.FC<Props> = ({
|
|
|
21
22
|
value,
|
|
22
23
|
onChange,
|
|
23
24
|
onPressEnter,
|
|
25
|
+
onPaste,
|
|
24
26
|
onFocus,
|
|
25
27
|
onBlur,
|
|
26
28
|
onExpandedChange,
|
|
@@ -125,6 +127,7 @@ const ChatTextArea: React.FC<Props> = ({
|
|
|
125
127
|
onPressEnter(e);
|
|
126
128
|
}
|
|
127
129
|
}}
|
|
130
|
+
onPaste={onPaste}
|
|
128
131
|
onFocus={onFocus}
|
|
129
132
|
onBlur={onBlur}
|
|
130
133
|
maxLength={100000}
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
/* ContentPreviewModal – modern preview modal for files and media */
|
|
2
2
|
|
|
3
|
+
/* Stack above Chat History Drawer (z-index: 10000) and other drawers */
|
|
4
|
+
.memori-content-preview-modal.memori-modal {
|
|
5
|
+
z-index: 10001;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.memori-content-preview-modal.memori-modal .memori-modal--backdrop,
|
|
9
|
+
.memori-content-preview-modal.memori-modal .memori-modal--container {
|
|
10
|
+
z-index: 10001;
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
.memori-content-preview-modal.memori-modal .memori-modal--panel {
|
|
14
|
+
z-index: 10001;
|
|
4
15
|
overflow: hidden;
|
|
16
|
+
min-width: 500px;
|
|
17
|
+
min-height: 500px;
|
|
18
|
+
max-height: 80vh;
|
|
5
19
|
border-radius: 16px;
|
|
6
20
|
background: var(--memori-content-preview-bg, #fafafa);
|
|
7
21
|
box-shadow:
|
|
8
22
|
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
|
9
23
|
0 0 0 1px rgba(0, 0, 0, 0.05);
|
|
24
|
+
overflow-y: auto;
|
|
10
25
|
}
|
|
11
26
|
|
|
12
27
|
/* Image variant: modal at least 600px wide (capped by viewport on small screens) */
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
.memori--preview-container {
|
|
4
4
|
z-index: 10;
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
width: 100%;
|
|
7
|
+
min-width: 0;
|
|
8
|
+
max-width: 100%;
|
|
7
9
|
border-radius: 8px;
|
|
8
|
-
/* margin-bottom: 12px; */
|
|
9
10
|
animation: slide-in 0.3s ease;
|
|
10
11
|
background: white;
|
|
11
|
-
/* box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); */
|
|
12
12
|
transition: all 0.3s ease;
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -114,11 +114,16 @@
|
|
|
114
114
|
|
|
115
115
|
.memori--preview-list {
|
|
116
116
|
display: flex;
|
|
117
|
+
width: 100%;
|
|
118
|
+
min-width: 0;
|
|
117
119
|
flex-direction: row;
|
|
120
|
+
flex-wrap: nowrap;
|
|
118
121
|
padding: 0.875rem;
|
|
119
122
|
padding-bottom: 0.625rem;
|
|
120
123
|
gap: 8px;
|
|
124
|
+
-webkit-overflow-scrolling: touch;
|
|
121
125
|
overflow-x: auto;
|
|
126
|
+
overflow-y: hidden;
|
|
122
127
|
scrollbar-color: hsla(var(--border-300) / 35%) transparent;
|
|
123
128
|
scrollbar-width: thin;
|
|
124
129
|
}
|
|
@@ -127,6 +132,7 @@
|
|
|
127
132
|
position: relative;
|
|
128
133
|
display: flex;
|
|
129
134
|
max-width: fit-content;
|
|
135
|
+
flex-shrink: 0;
|
|
130
136
|
align-items: center;
|
|
131
137
|
padding: 8px;
|
|
132
138
|
border-radius: 8px;
|
|
@@ -217,16 +223,18 @@
|
|
|
217
223
|
transition: all 0.2s ease;
|
|
218
224
|
}
|
|
219
225
|
|
|
220
|
-
.memori--remove-button.visible {
|
|
221
|
-
opacity: 1;
|
|
222
|
-
transform: scale(1);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
226
|
.memori--remove-button:hover {
|
|
226
227
|
background-color: #c92a2a;
|
|
227
228
|
transform: scale(1.1);
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
|
|
232
|
+
/* Show remove button only when user hovers the preview item */
|
|
233
|
+
.memori--preview-item:hover .memori--remove-button {
|
|
234
|
+
opacity: 1;
|
|
235
|
+
transform: scale(1);
|
|
236
|
+
}
|
|
237
|
+
|
|
230
238
|
.memori--remove-icon {
|
|
231
239
|
width: 12px;
|
|
232
240
|
height: 12px;
|
|
@@ -26,8 +26,6 @@ FilePreviewProps) => {
|
|
|
26
26
|
type?: string;
|
|
27
27
|
} | null>(null);
|
|
28
28
|
|
|
29
|
-
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
30
|
-
|
|
31
29
|
const getFileType = (filename: string, type?: string) => {
|
|
32
30
|
// If type is explicitly provided, use it first
|
|
33
31
|
if (type === 'image') {
|
|
@@ -136,8 +134,6 @@ FilePreviewProps) => {
|
|
|
136
134
|
? 'memori--preview-item--image'
|
|
137
135
|
: 'memori--preview-item--document'
|
|
138
136
|
}`}
|
|
139
|
-
onMouseEnter={() => setHoveredId(file.id)}
|
|
140
|
-
onMouseLeave={() => setHoveredId(null)}
|
|
141
137
|
onClick={() => setSelectedFile(file)}
|
|
142
138
|
>
|
|
143
139
|
{isImageContent(file.content, file.type) ? (
|
|
@@ -160,9 +156,7 @@ FilePreviewProps) => {
|
|
|
160
156
|
shape="rounded"
|
|
161
157
|
icon={<CloseIcon />}
|
|
162
158
|
danger
|
|
163
|
-
className=
|
|
164
|
-
hoveredId === file.id ? 'visible' : ''
|
|
165
|
-
}`}
|
|
159
|
+
className="memori--remove-button"
|
|
166
160
|
onClick={e => {
|
|
167
161
|
e.stopPropagation();
|
|
168
162
|
removeFile(file.id, file?.mediumID);
|