@tuturuuu/ui 0.25.0 → 0.25.3
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 +26 -0
- package/biome.json +1 -1
- package/package.json +13 -12
- package/src/components/ui/chat/chat-sidebar-items.tsx +25 -19
- package/src/components/ui/chat/chat-utils.test.ts +88 -2
- package/src/components/ui/chat/chat-workspace.tsx +1 -0
- package/src/components/ui/chat/composer-attachment-chip.tsx +128 -0
- package/src/components/ui/chat/external-message-content.test.ts +41 -0
- package/src/components/ui/chat/external-message-content.ts +28 -0
- package/src/components/ui/chat/message-bubble.tsx +4 -2
- package/src/components/ui/chat/message-composer.tsx +121 -33
- package/src/components/ui/chat/message-links.test.tsx +14 -0
- package/src/components/ui/chat/utils.ts +39 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +2 -6
- package/src/components/ui/custom/__tests__/workspace-select-invitations.test.tsx +141 -0
- package/src/components/ui/custom/education/courses/course-row-actions.tsx +1 -1
- package/src/components/ui/custom/education/modules/course-module-row-actions.tsx +1 -1
- package/src/components/ui/custom/notification-popover-client.tsx +97 -57
- package/src/components/ui/custom/structure.test.tsx +55 -1
- package/src/components/ui/custom/structure.tsx +10 -1
- package/src/components/ui/custom/tables/custom-data-table.tsx +3 -2
- package/src/components/ui/custom/tables/data-table-column-header.tsx +4 -3
- package/src/components/ui/custom/tables/data-table-faceted-filter.tsx +4 -3
- package/src/components/ui/custom/tables/data-table-pagination.tsx +6 -6
- package/src/components/ui/custom/tables/data-table-toolbar.tsx +5 -5
- package/src/components/ui/custom/tables/data-table-view-options.tsx +4 -3
- package/src/components/ui/custom/tables/data-table.tsx +69 -30
- package/src/components/ui/custom/workspace-select-icon.tsx +58 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +202 -0
- package/src/components/ui/custom/workspace-select.tsx +43 -61
- package/src/components/ui/finance/invoices/columns.test.tsx +3 -3
- package/src/components/ui/finance/invoices/columns.tsx +4 -2
- package/src/components/ui/finance/invoices/pending-columns.tsx +1 -1
- package/src/components/ui/finance/invoices/row-actions.tsx +1 -1
- package/src/components/ui/finance/transactions/categories/columns.test.tsx +5 -5
- package/src/components/ui/finance/transactions/categories/columns.tsx +4 -2
- package/src/components/ui/finance/transactions/categories/row-actions.tsx +1 -1
- package/src/components/ui/finance/transactions/columns.test.tsx +5 -5
- package/src/components/ui/finance/transactions/columns.tsx +4 -2
- package/src/components/ui/finance/transactions/row-actions.tsx +1 -1
- package/src/components/ui/finance/wallets/columns.tsx +4 -2
- package/src/components/ui/finance/wallets/row-actions.tsx +1 -1
- package/src/hooks/use-notifications.ts +16 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.3](https://github.com/tutur3u/platform/compare/ui-v0.25.2...ui-v0.25.3) (2026-08-09)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **chat:** decode external visitor labels ([1ca3e75](https://github.com/tutur3u/platform/commit/1ca3e75a939c06222c842da9fe5892a1bf9ce729))
|
|
9
|
+
* **chat:** decode external visitor labels ([#5112](https://github.com/tutur3u/platform/issues/5112)) ([cb7af54](https://github.com/tutur3u/platform/commit/cb7af54264344961234da681c877c5677bf950fa))
|
|
10
|
+
* **platform:** merge notification and group visibility fixes ([4fe9e97](https://github.com/tutur3u/platform/commit/4fe9e970bf61bffaee4353b1ebb83ce2f880a4c8))
|
|
11
|
+
* **platform:** restore notifications and group visibility ([eb570a4](https://github.com/tutur3u/platform/commit/eb570a47e7a3d38fc855fbf3e887ecbde853ece0))
|
|
12
|
+
|
|
13
|
+
## [0.25.2](https://github.com/tutur3u/platform/compare/ui-v0.25.1...ui-v0.25.2) (2026-08-07)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **chat:** restore external inbox display parity ([#5108](https://github.com/tutur3u/platform/issues/5108)) ([b34ea6d](https://github.com/tutur3u/platform/commit/b34ea6d7d8ffda344053ed1f580128446d0d0a81))
|
|
19
|
+
* **tasks:** repair board share access and harden AI media attachments ([65b8092](https://github.com/tutur3u/platform/commit/65b809245cc01fb7a5f034f703083f329c20f1c1))
|
|
20
|
+
|
|
21
|
+
## [0.25.1](https://github.com/tutur3u/platform/compare/ui-v0.25.0...ui-v0.25.1) (2026-08-06)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* **workspaces:** harden invitation interactions ([b7f8f6c](https://github.com/tutur3u/platform/commit/b7f8f6cf52ceec1b67d36b11513eae7806284f5d))
|
|
27
|
+
* **workspaces:** restore invitation access across apps ([#5099](https://github.com/tutur3u/platform/issues/5099)) ([c7032c3](https://github.com/tutur3u/platform/commit/c7032c310639c2783b60ac560e83a84d65a5c7f5))
|
|
28
|
+
|
|
3
29
|
## [0.25.0](https://github.com/tutur3u/platform/compare/ui-v0.24.0...ui-v0.25.0) (2026-08-06)
|
|
4
30
|
|
|
5
31
|
|
package/biome.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"@tanstack/react-hotkeys": "^0.10.0",
|
|
59
59
|
"@tanstack/react-pacer": "^0.22.1",
|
|
60
60
|
"@tanstack/react-query": "^5.101.2",
|
|
61
|
-
"@tanstack/react-table": "^
|
|
61
|
+
"@tanstack/react-table": "^9.0.0",
|
|
62
62
|
"@tanstack/react-virtual": "^3.14.9",
|
|
63
63
|
"@tiptap/core": "3.29.2",
|
|
64
64
|
"@tiptap/extension-collaboration": "3.29.2",
|
|
@@ -86,13 +86,13 @@
|
|
|
86
86
|
"@tiptap/pm": "3.29.2",
|
|
87
87
|
"@tiptap/react": "3.29.2",
|
|
88
88
|
"@tiptap/starter-kit": "3.29.2",
|
|
89
|
-
"@tuturuuu/ai": "0.8.
|
|
89
|
+
"@tuturuuu/ai": "0.8.1",
|
|
90
90
|
"@tuturuuu/apis": "0.11.0",
|
|
91
91
|
"@tuturuuu/hooks": "0.1.0",
|
|
92
92
|
"@tuturuuu/icons": "0.1.0",
|
|
93
|
-
"@tuturuuu/internal-api": "0.
|
|
93
|
+
"@tuturuuu/internal-api": "0.29.0",
|
|
94
94
|
"@tuturuuu/supabase": "0.5.0",
|
|
95
|
-
"@tuturuuu/utils": "0.
|
|
95
|
+
"@tuturuuu/utils": "0.23.1",
|
|
96
96
|
"@types/debug": "^4.1.13",
|
|
97
97
|
"browser-image-compression": "^2.0.2",
|
|
98
98
|
"class-variance-authority": "^0.7.1",
|
|
@@ -104,18 +104,19 @@
|
|
|
104
104
|
"debug": "^4.4.3",
|
|
105
105
|
"embla-carousel-react": "^8.6.0",
|
|
106
106
|
"eventemitter3": "^5.0.4",
|
|
107
|
-
"framer-motion": "^
|
|
107
|
+
"framer-motion": "^13.0.0",
|
|
108
108
|
"gsap": "^3.15.0",
|
|
109
|
+
"html-entities": "^2.6.0",
|
|
109
110
|
"html2canvas-pro": "^2.3.3",
|
|
110
111
|
"input-otp": "^1.4.2",
|
|
111
112
|
"katex": "^0.18.1",
|
|
112
113
|
"lodash": "4.18.1",
|
|
113
114
|
"moment": "^2.30.1",
|
|
114
|
-
"motion": "^
|
|
115
|
+
"motion": "^13.0.0",
|
|
115
116
|
"next": "^16.3.0",
|
|
116
|
-
"next-intl": "^4.13.
|
|
117
|
+
"next-intl": "^4.13.5",
|
|
117
118
|
"next-themes": "^0.4.6",
|
|
118
|
-
"nuqs": "^2.9.
|
|
119
|
+
"nuqs": "^2.9.5",
|
|
119
120
|
"prosemirror-state": "^1.4.4",
|
|
120
121
|
"prosemirror-view": "1.42.2",
|
|
121
122
|
"qrcode.react": "^4.2.0",
|
|
@@ -142,14 +143,14 @@
|
|
|
142
143
|
"use-debounce": "^10.1.1",
|
|
143
144
|
"vaul": "^1.1.2",
|
|
144
145
|
"y-protocols": "^1.0.7",
|
|
145
|
-
"yjs": "^13.6.
|
|
146
|
+
"yjs": "^13.6.32",
|
|
146
147
|
"zod": "^4.4.3"
|
|
147
148
|
},
|
|
148
149
|
"devDependencies": {
|
|
149
150
|
"@tailwindcss/postcss": "^4.3.3",
|
|
150
151
|
"@tailwindcss/typography": "^0.5.20",
|
|
151
152
|
"@tanstack/react-query": "^5.101.4",
|
|
152
|
-
"@tanstack/react-table": "^
|
|
153
|
+
"@tanstack/react-table": "^9.0.0",
|
|
153
154
|
"@testing-library/jest-dom": "^7.0.0",
|
|
154
155
|
"@testing-library/react": "^16.3.2",
|
|
155
156
|
"@tuturuuu/types": "0.27.0",
|
|
@@ -174,7 +175,7 @@
|
|
|
174
175
|
},
|
|
175
176
|
"peerDependencies": {
|
|
176
177
|
"@tanstack/react-query": "^5.101.2",
|
|
177
|
-
"@tanstack/react-table": "^
|
|
178
|
+
"@tanstack/react-table": "^9.0.0"
|
|
178
179
|
},
|
|
179
180
|
"exports": {
|
|
180
181
|
"./globals.css": "./src/globals.css",
|
|
@@ -6,6 +6,7 @@ import { cn } from '@tuturuuu/utils/format';
|
|
|
6
6
|
import { useLocale, useTranslations } from 'next-intl';
|
|
7
7
|
import { Button } from '../button';
|
|
8
8
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
|
|
9
|
+
import { getChatMessageDisplayContent } from './external-message-content';
|
|
9
10
|
import {
|
|
10
11
|
getChatConversationQueueDetails,
|
|
11
12
|
getChatMessageSenderLabel,
|
|
@@ -37,6 +38,7 @@ export function ConversationRow({
|
|
|
37
38
|
channel: t('untitled_channel'),
|
|
38
39
|
chat: t('untitled_chat'),
|
|
39
40
|
direct: t('direct_message'),
|
|
41
|
+
external: t('external_sender'),
|
|
40
42
|
group: t('group_chat'),
|
|
41
43
|
});
|
|
42
44
|
const pinned = isChatConversationPinned(conversation, currentUserId);
|
|
@@ -160,25 +162,29 @@ export function SearchResultList({
|
|
|
160
162
|
|
|
161
163
|
return (
|
|
162
164
|
<div className="h-full space-y-1 overflow-y-auto p-2">
|
|
163
|
-
{messages.map((message) =>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
165
|
+
{messages.map((message) => {
|
|
166
|
+
const displayContent = getChatMessageDisplayContent(message);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<button
|
|
170
|
+
className="w-full rounded-md px-2 py-2 text-left transition-colors hover:bg-accent"
|
|
171
|
+
key={message.id}
|
|
172
|
+
onClick={() => onSelectConversation(message.conversationId)}
|
|
173
|
+
type="button"
|
|
174
|
+
>
|
|
175
|
+
<span className="block truncate font-medium text-sm">
|
|
176
|
+
{getChatMessageSenderLabel(message, {
|
|
177
|
+
assistant: t('assistant_name'),
|
|
178
|
+
external: t('external_sender'),
|
|
179
|
+
unknown: t('unknown_sender'),
|
|
180
|
+
})}
|
|
181
|
+
</span>
|
|
182
|
+
<span className="mt-1 line-clamp-2 text-muted-foreground text-xs">
|
|
183
|
+
{displayContent}
|
|
184
|
+
</span>
|
|
185
|
+
</button>
|
|
186
|
+
);
|
|
187
|
+
})}
|
|
182
188
|
</div>
|
|
183
189
|
);
|
|
184
190
|
}
|
|
@@ -134,6 +134,62 @@ describe('chat utils', () => {
|
|
|
134
134
|
expect(title).toBe('Ada Lovelace');
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
it('gives unnamed external conversations a stable pseudonymous title', () => {
|
|
138
|
+
const title = getConversationTitle(
|
|
139
|
+
conversation({
|
|
140
|
+
id: 'a7d12840-f72d-48f2-8057-a1ea15aecd29',
|
|
141
|
+
metadata: { externalChat: true },
|
|
142
|
+
title: 'External visitor',
|
|
143
|
+
type: 'channel',
|
|
144
|
+
}),
|
|
145
|
+
'user-1',
|
|
146
|
+
{ external: 'Website visitor' }
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(title).toBe('Website visitor #AECD29');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('prefers an external profile name over a persisted generic title', () => {
|
|
153
|
+
const title = getConversationTitle(
|
|
154
|
+
conversation({
|
|
155
|
+
metadata: { displayName: 'Visitor 42', externalChat: true },
|
|
156
|
+
title: 'External visitor',
|
|
157
|
+
type: 'channel',
|
|
158
|
+
}),
|
|
159
|
+
'user-1',
|
|
160
|
+
{ external: 'Website visitor' }
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(title).toBe('Visitor 42');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('decodes external profile and persisted conversation titles', () => {
|
|
167
|
+
expect(
|
|
168
|
+
getConversationTitle(
|
|
169
|
+
conversation({
|
|
170
|
+
metadata: {
|
|
171
|
+
displayName: 'Nguyễn Thị Vi&ecirc;n',
|
|
172
|
+
externalChat: true,
|
|
173
|
+
},
|
|
174
|
+
title: 'External visitor',
|
|
175
|
+
type: 'channel',
|
|
176
|
+
}),
|
|
177
|
+
'user-1'
|
|
178
|
+
)
|
|
179
|
+
).toBe('Nguyễn Thị Viên');
|
|
180
|
+
|
|
181
|
+
expect(
|
|
182
|
+
getConversationTitle(
|
|
183
|
+
conversation({
|
|
184
|
+
metadata: { externalChat: true },
|
|
185
|
+
title: 'Nguyễn Thị Viên',
|
|
186
|
+
type: 'channel',
|
|
187
|
+
}),
|
|
188
|
+
'user-1'
|
|
189
|
+
)
|
|
190
|
+
).toBe('Nguyễn Thị Viên');
|
|
191
|
+
});
|
|
192
|
+
|
|
137
193
|
it('builds useful message previews for files and deleted messages', () => {
|
|
138
194
|
expect(
|
|
139
195
|
getLastMessagePreview(
|
|
@@ -171,6 +227,21 @@ describe('chat utils', () => {
|
|
|
171
227
|
).toBe('message_deleted');
|
|
172
228
|
});
|
|
173
229
|
|
|
230
|
+
it('decodes external message previews without changing native previews', () => {
|
|
231
|
+
const encoded = 'Tôi đã gửi tin';
|
|
232
|
+
|
|
233
|
+
expect(
|
|
234
|
+
getLastMessagePreview({
|
|
235
|
+
...baseMessage,
|
|
236
|
+
content: encoded,
|
|
237
|
+
metadata: { externalChat: true },
|
|
238
|
+
})
|
|
239
|
+
).toBe('Tôi đã gửi tin');
|
|
240
|
+
expect(getLastMessagePreview({ ...baseMessage, content: encoded })).toBe(
|
|
241
|
+
encoded
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
174
245
|
it('formats file sizes for attachment rows', () => {
|
|
175
246
|
expect(formatFileSize(0)).toBe('0 B');
|
|
176
247
|
expect(formatFileSize(2048)).toBe('2.0 KB');
|
|
@@ -344,12 +415,12 @@ describe('chat utils', () => {
|
|
|
344
415
|
...baseMessage,
|
|
345
416
|
metadata: {
|
|
346
417
|
externalChat: true,
|
|
347
|
-
externalSender: { displayName: 'Visitor 42' },
|
|
418
|
+
externalSender: { displayName: 'Visitor &amp; 42' },
|
|
348
419
|
},
|
|
349
420
|
},
|
|
350
421
|
fallback
|
|
351
422
|
)
|
|
352
|
-
).toBe('Visitor 42');
|
|
423
|
+
).toBe('Visitor & 42');
|
|
353
424
|
expect(
|
|
354
425
|
getChatMessageSenderLabel(
|
|
355
426
|
{ ...baseMessage, metadata: { externalChat: true } },
|
|
@@ -382,6 +453,21 @@ describe('chat utils', () => {
|
|
|
382
453
|
});
|
|
383
454
|
});
|
|
384
455
|
|
|
456
|
+
it('decodes connected-site queue previews using conversation scope', () => {
|
|
457
|
+
const details = getChatConversationQueueDetails(
|
|
458
|
+
conversation({
|
|
459
|
+
latestMessage: {
|
|
460
|
+
...baseMessage,
|
|
461
|
+
content: 'Tôi đã gửi tin',
|
|
462
|
+
metadata: { status: 'seen' },
|
|
463
|
+
},
|
|
464
|
+
metadata: { externalChat: true },
|
|
465
|
+
})
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
expect(details.preview).toBe('Tôi đã gửi tin');
|
|
469
|
+
});
|
|
470
|
+
|
|
385
471
|
it('normalizes connected-site delivery and deletion states', () => {
|
|
386
472
|
expect(
|
|
387
473
|
getChatConversationDeliveryState({
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { FileText, Film, Music, X } from '@tuturuuu/icons';
|
|
4
|
+
import type { ChatAttachmentDraft } from '@tuturuuu/internal-api';
|
|
5
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
6
|
+
import { Button } from '../button';
|
|
7
|
+
import { formatFileSize } from './utils';
|
|
8
|
+
|
|
9
|
+
export type ComposerAttachmentKind = 'audio' | 'file' | 'image' | 'video';
|
|
10
|
+
|
|
11
|
+
export function getComposerAttachmentKind(
|
|
12
|
+
filename: string,
|
|
13
|
+
contentType?: string | null
|
|
14
|
+
): ComposerAttachmentKind {
|
|
15
|
+
const type = contentType?.toLowerCase() ?? '';
|
|
16
|
+
const extension = filename.split('.').pop()?.toLowerCase() ?? '';
|
|
17
|
+
|
|
18
|
+
if (
|
|
19
|
+
type.startsWith('image/') ||
|
|
20
|
+
['avif', 'gif', 'heic', 'jpeg', 'jpg', 'png', 'webp'].includes(extension)
|
|
21
|
+
) {
|
|
22
|
+
return 'image';
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
type.startsWith('video/') ||
|
|
26
|
+
['m4v', 'mov', 'mp4', 'webm'].includes(extension)
|
|
27
|
+
) {
|
|
28
|
+
return 'video';
|
|
29
|
+
}
|
|
30
|
+
if (
|
|
31
|
+
type.startsWith('audio/') ||
|
|
32
|
+
['aac', 'flac', 'm4a', 'mp3', 'ogg', 'opus', 'wav'].includes(extension)
|
|
33
|
+
) {
|
|
34
|
+
return 'audio';
|
|
35
|
+
}
|
|
36
|
+
return 'file';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const kindIcon = {
|
|
40
|
+
audio: Music,
|
|
41
|
+
file: FileText,
|
|
42
|
+
image: FileText,
|
|
43
|
+
video: Film,
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A queued attachment, shown before the message is sent. Media gets a real
|
|
48
|
+
* thumbnail from the local file rather than a filename chip — a picture is the
|
|
49
|
+
* only reliable way to confirm you attached the right screenshot.
|
|
50
|
+
*/
|
|
51
|
+
export function ComposerAttachmentChip({
|
|
52
|
+
attachment,
|
|
53
|
+
onRemove,
|
|
54
|
+
previewUrl,
|
|
55
|
+
removeLabel,
|
|
56
|
+
}: {
|
|
57
|
+
attachment: ChatAttachmentDraft;
|
|
58
|
+
onRemove: () => void;
|
|
59
|
+
previewUrl?: string;
|
|
60
|
+
removeLabel: string;
|
|
61
|
+
}) {
|
|
62
|
+
const kind = getComposerAttachmentKind(
|
|
63
|
+
attachment.filename,
|
|
64
|
+
attachment.contentType
|
|
65
|
+
);
|
|
66
|
+
const showsThumbnail = Boolean(previewUrl) && kind !== 'file';
|
|
67
|
+
const Icon = kindIcon[kind];
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
className={cn(
|
|
72
|
+
'group relative flex min-w-0 items-center gap-2 overflow-hidden rounded-md border bg-muted/40 text-sm',
|
|
73
|
+
showsThumbnail ? 'p-1 pr-1' : 'px-2 py-1'
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
{showsThumbnail ? (
|
|
77
|
+
<span className="size-10 shrink-0 overflow-hidden rounded bg-background">
|
|
78
|
+
{kind === 'video' ? (
|
|
79
|
+
<video
|
|
80
|
+
className="size-full object-cover"
|
|
81
|
+
muted
|
|
82
|
+
preload="metadata"
|
|
83
|
+
src={previewUrl}
|
|
84
|
+
/>
|
|
85
|
+
) : kind === 'audio' ? (
|
|
86
|
+
<span className="flex size-full items-center justify-center">
|
|
87
|
+
<Music className="size-4 text-muted-foreground" />
|
|
88
|
+
</span>
|
|
89
|
+
) : (
|
|
90
|
+
/* biome-ignore lint/performance/noImgElement: local blob: URL for a not-yet-sent file; next/image cannot optimize object URLs */
|
|
91
|
+
<img
|
|
92
|
+
alt={attachment.filename}
|
|
93
|
+
className="size-full object-cover"
|
|
94
|
+
src={previewUrl}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
</span>
|
|
98
|
+
) : (
|
|
99
|
+
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
<span className="min-w-0 flex-1">
|
|
103
|
+
<span
|
|
104
|
+
className="block max-w-40 truncate font-medium"
|
|
105
|
+
title={attachment.filename}
|
|
106
|
+
>
|
|
107
|
+
{attachment.filename}
|
|
108
|
+
</span>
|
|
109
|
+
{attachment.sizeBytes ? (
|
|
110
|
+
<span className="block text-muted-foreground text-xs">
|
|
111
|
+
{formatFileSize(attachment.sizeBytes)}
|
|
112
|
+
</span>
|
|
113
|
+
) : null}
|
|
114
|
+
</span>
|
|
115
|
+
|
|
116
|
+
<Button
|
|
117
|
+
aria-label={removeLabel}
|
|
118
|
+
className="size-6 shrink-0"
|
|
119
|
+
onClick={onRemove}
|
|
120
|
+
size="icon"
|
|
121
|
+
type="button"
|
|
122
|
+
variant="ghost"
|
|
123
|
+
>
|
|
124
|
+
<X className="size-3.5" />
|
|
125
|
+
</Button>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getChatMessageDisplayContent } from './external-message-content';
|
|
3
|
+
|
|
4
|
+
describe('getChatMessageDisplayContent', () => {
|
|
5
|
+
it('decodes named and numeric entities for external chat messages', () => {
|
|
6
|
+
expect(
|
|
7
|
+
getChatMessageDisplayContent({
|
|
8
|
+
content:
|
|
9
|
+
'Tôi đã gửi tin & chờ phản hồi.',
|
|
10
|
+
metadata: { externalChat: true },
|
|
11
|
+
})
|
|
12
|
+
).toBe('Tôi đã gửi tin & chờ phản hồi.');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('decodes bounded nested entities from double-encoded legacy rows', () => {
|
|
16
|
+
expect(
|
|
17
|
+
getChatMessageDisplayContent({
|
|
18
|
+
content: 'Khám trực tiếp &amp; mang theo kết quả',
|
|
19
|
+
metadata: { externalChat: true },
|
|
20
|
+
})
|
|
21
|
+
).toBe('Khám trực tiếp & mang theo kết quả');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('leaves native chat content unchanged', () => {
|
|
25
|
+
expect(
|
|
26
|
+
getChatMessageDisplayContent({
|
|
27
|
+
content: 'Keep & exactly as authored',
|
|
28
|
+
metadata: {},
|
|
29
|
+
})
|
|
30
|
+
).toBe('Keep & exactly as authored');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns decoded markup as text content rather than an HTML contract', () => {
|
|
34
|
+
expect(
|
|
35
|
+
getChatMessageDisplayContent({
|
|
36
|
+
content: '<img src=x onerror=alert(1)>',
|
|
37
|
+
metadata: { externalChat: true },
|
|
38
|
+
})
|
|
39
|
+
).toBe('<img src=x onerror=alert(1)>');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ChatMessage } from '@tuturuuu/internal-api';
|
|
2
|
+
import { decode } from 'html-entities';
|
|
3
|
+
|
|
4
|
+
const MAX_ENTITY_DECODE_PASSES = 3;
|
|
5
|
+
|
|
6
|
+
export function decodeExternalChatContent(
|
|
7
|
+
content: string,
|
|
8
|
+
externalChat: boolean
|
|
9
|
+
) {
|
|
10
|
+
if (!externalChat) return content;
|
|
11
|
+
|
|
12
|
+
let displayContent = content;
|
|
13
|
+
for (let pass = 0; pass < MAX_ENTITY_DECODE_PASSES; pass += 1) {
|
|
14
|
+
const decoded = decode(displayContent, { level: 'html5', scope: 'body' });
|
|
15
|
+
if (decoded === displayContent) break;
|
|
16
|
+
displayContent = decoded;
|
|
17
|
+
}
|
|
18
|
+
return displayContent;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getChatMessageDisplayContent(
|
|
22
|
+
message: Pick<ChatMessage, 'content' | 'metadata'>
|
|
23
|
+
) {
|
|
24
|
+
return decodeExternalChatContent(
|
|
25
|
+
message.content,
|
|
26
|
+
message.metadata.externalChat === true
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -26,6 +26,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '../popover';
|
|
|
26
26
|
import { toast } from '../sonner';
|
|
27
27
|
import { AiMessageParts } from './ai-message-parts';
|
|
28
28
|
import { getAiMessagePartsFromMetadata } from './ai-message-render-utils';
|
|
29
|
+
import { getChatMessageDisplayContent } from './external-message-content';
|
|
29
30
|
import { MessageAttachmentButton } from './message-attachment-button';
|
|
30
31
|
import { MessageLinkPreviews, MessageText } from './message-links';
|
|
31
32
|
import {
|
|
@@ -259,6 +260,7 @@ function MessageContent({
|
|
|
259
260
|
const t = useTranslations('chat');
|
|
260
261
|
const aiParts = getAiMessagePartsFromMetadata(message.metadata);
|
|
261
262
|
const externalAttachment = readExternalAttachment(message.metadata);
|
|
263
|
+
const displayContent = getChatMessageDisplayContent(message);
|
|
262
264
|
|
|
263
265
|
return (
|
|
264
266
|
<div
|
|
@@ -284,10 +286,10 @@ function MessageContent({
|
|
|
284
286
|
textFallback={message.content}
|
|
285
287
|
/>
|
|
286
288
|
) : (
|
|
287
|
-
<MessageText content={
|
|
289
|
+
<MessageText content={displayContent} />
|
|
288
290
|
)}
|
|
289
291
|
<MessageLinkPreviews
|
|
290
|
-
content={
|
|
292
|
+
content={displayContent}
|
|
291
293
|
conversationId={message.conversationId}
|
|
292
294
|
isOwnMessage={isOwnMessage}
|
|
293
295
|
wsId={wsId}
|