@manonero/chat-client-sdk 1.0.0-beta.2 → 1.0.0-beta.4

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 (51) hide show
  1. package/README.md +204 -27
  2. package/dist/ChatClient.d.ts +67 -8
  3. package/dist/ChatClient.d.ts.map +1 -1
  4. package/dist/ChatClient.js +82 -9
  5. package/dist/ChatClient.js.map +1 -1
  6. package/dist/http/ConversationApi.d.ts +18 -1
  7. package/dist/http/ConversationApi.d.ts.map +1 -1
  8. package/dist/http/ConversationApi.js +23 -0
  9. package/dist/http/ConversationApi.js.map +1 -1
  10. package/dist/http/HttpClient.d.ts +18 -1
  11. package/dist/http/HttpClient.d.ts.map +1 -1
  12. package/dist/http/HttpClient.js +64 -13
  13. package/dist/http/HttpClient.js.map +1 -1
  14. package/dist/http/MessageApi.d.ts.map +1 -1
  15. package/dist/http/MessageApi.js +13 -2
  16. package/dist/http/MessageApi.js.map +1 -1
  17. package/dist/http/ProxyApi.d.ts +14 -3
  18. package/dist/http/ProxyApi.d.ts.map +1 -1
  19. package/dist/http/ProxyApi.js +25 -7
  20. package/dist/http/ProxyApi.js.map +1 -1
  21. package/dist/index.d.ts +3 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/realtime/ChatHubClient.d.ts +58 -1
  26. package/dist/realtime/ChatHubClient.d.ts.map +1 -1
  27. package/dist/realtime/ChatHubClient.js +202 -15
  28. package/dist/realtime/ChatHubClient.js.map +1 -1
  29. package/dist/realtime/NotificationHubClient.d.ts +40 -0
  30. package/dist/realtime/NotificationHubClient.d.ts.map +1 -1
  31. package/dist/realtime/NotificationHubClient.js +131 -29
  32. package/dist/realtime/NotificationHubClient.js.map +1 -1
  33. package/dist/realtime/ReconnectionManager.d.ts +2 -1
  34. package/dist/realtime/ReconnectionManager.d.ts.map +1 -1
  35. package/dist/realtime/ReconnectionManager.js +13 -3
  36. package/dist/realtime/ReconnectionManager.js.map +1 -1
  37. package/dist/types/block.d.ts +17 -2
  38. package/dist/types/block.d.ts.map +1 -1
  39. package/dist/types/block.js +30 -0
  40. package/dist/types/block.js.map +1 -1
  41. package/dist/types/chat-events.d.ts +23 -3
  42. package/dist/types/chat-events.d.ts.map +1 -1
  43. package/dist/types/conversation.d.ts +52 -3
  44. package/dist/types/conversation.d.ts.map +1 -1
  45. package/dist/types/message.d.ts +5 -6
  46. package/dist/types/message.d.ts.map +1 -1
  47. package/dist/types/notification-events.d.ts +10 -7
  48. package/dist/types/notification-events.d.ts.map +1 -1
  49. package/dist/types/signalr.d.ts +2 -1
  50. package/dist/types/signalr.d.ts.map +1 -1
  51. package/package.json +4 -2
package/README.md CHANGED
@@ -171,7 +171,12 @@ const client = new ChatClient({
171
171
  });
172
172
  ```
173
173
 
174
- > **Ưu tiên token:** `token` (hoặc `setToken()`) luôn ưu tiên hơn `tokenProvider`. Khi `_token` khác `null`, `tokenProvider` bị bỏ qua.
174
+ > **Ưu tiên token (resolve mỗi request / connect):**
175
+ > 1. Nếu `tokenProvider` được cấu hình **VÀ** trả về string non-empty → dùng giá trị đó.
176
+ > 2. Ngược lại → dùng token nội bộ (`token` constructor / `setToken()` / auto-set sau login).
177
+ > 3. Ngược lại → `null` (không gửi `Authorization`).
178
+ >
179
+ > Provider luôn được hỏi trước nên các hệ thống refresh-token rotation tiếp tục hoạt động kể cả sau khi `setToken()` đã được gọi. Dùng `clearToken()` nếu muốn xoá token nội bộ và trả quyền hoàn toàn về provider.
175
180
 
176
181
  ### Cấu hình SignalR log level
177
182
 
@@ -205,6 +210,7 @@ new ChatClient(options: ChatClientOptions)
205
210
  | `baseUrl` | `string` | ✅ | URL gốc của server, ví dụ `"https://chat-api.example.com"` |
206
211
  | `token` | `string` | ❌ | JWT token ban đầu (nếu đã đăng nhập) |
207
212
  | `tokenProvider` | `() => string \| null` | ❌ | Hàm lấy token từ bên ngoài |
213
+ | `requestTimeoutMs` | `number \| null` | ❌ | Timeout HTTP / upload tính bằng ms. Mặc định `30000`. Truyền `null` để tắt (hữu ích khi upload file lớn). |
208
214
  | `signalrOptions.logLevel` | `LogLevel` | ❌ | Mức log cho SignalR (mặc định: `Warning`) |
209
215
 
210
216
  ### Thuộc tính
@@ -225,20 +231,52 @@ new ChatClient(options: ChatClientOptions)
225
231
 
226
232
  ### Phương thức
227
233
 
228
- #### `setToken(token: string): void`
234
+ #### `setToken(token: string | null): void`
229
235
 
230
- Cập nhật JWT token. Có hiệu lực ngay với mọi HTTP request tiếp theo.
236
+ Cập nhật JWT token nội bộ. Có hiệu lực **ngay** với mọi HTTP request tiếp theo. Truyền `null` để xoá token nội bộ và `currentUser` (tương đương `clearToken()`).
231
237
 
232
- > ⚠️ Các kết nối SignalR đang hoạt động **không** được cập nhật token mid-session. Cần `disconnect()` rồi `connect()` lại để dùng token mới.
238
+ > ⚠️ Các kết nối SignalR **đang hoạt động** vẫn dùng token cho đến lúc reconnect. Gọi `refreshConnections()` để áp token mới ngay cho hub đang Connected.
239
+
240
+ > ℹ️ Nếu `tokenProvider` đã được cấu hình và vẫn trả về giá trị, provider tiếp tục được ưu tiên — `setToken()` chỉ ảnh hưởng khi provider trả `null`/empty.
233
241
 
234
242
  ```ts
235
243
  client.setToken('new-jwt-token-here');
244
+ await client.refreshConnections(); // tuỳ chọn — chỉ khi muốn áp ngay cho SignalR
245
+ ```
246
+
247
+ #### `clearToken(): void`
248
+
249
+ Xoá token nội bộ và `currentUser`. Không động tới `tokenProvider` (provider tiếp tục hoạt động bình thường) và không ngắt SignalR. Dùng `logout()` nếu muốn ngắt cả hub.
250
+
251
+ ```ts
252
+ client.clearToken();
253
+ ```
254
+
255
+ #### `refreshConnections(): Promise<void>`
256
+
257
+ `disconnect()` + `connect()` lại từng hub đang ở trạng thái `Connected`. Hub đang Disconnected/Reconnecting được giữ nguyên. Dùng để áp token mới (sau `setToken()`) cho phiên SignalR đang chạy.
258
+
259
+ > ⚠️ `disconnect()` xoá local tracking (`joinedConversations`, `subscribedPresenceIds`). Sau khi gọi `refreshConnections()`, ứng dụng phải tự `joinConversation` / `subscribeToPresence` lại — hoặc dùng `ReconnectionManager` để khôi phục trong suốt cho mọi loại disconnect.
260
+
261
+ ```ts
262
+ client.setToken(newToken);
263
+ await client.refreshConnections();
264
+ ```
265
+
266
+ #### `logout(): Promise<void>`
267
+
268
+ Xoá token + `currentUser` rồi `disconnect()` cả hai hub. Chỉ clear local state — không gọi server (server không có endpoint logout). Nếu `tokenProvider` được cấu hình và vẫn trả token, request kế tiếp vẫn có thể auth lại.
269
+
270
+ ```ts
271
+ await client.logout();
236
272
  ```
237
273
 
238
274
  #### `disconnect(): Promise<void>`
239
275
 
240
276
  Ngắt kết nối tất cả các SignalR hub (ChatHub + NotificationHub) đồng thời.
241
277
 
278
+ > Cả hai hub set cờ `intentionallyClosed` trước khi tear down nên `ReconnectionManager` (nếu được attach) sẽ **bỏ qua** lần `disconnected` này thay vì auto-reconnect ngược ý người dùng.
279
+
242
280
  ```ts
243
281
  await client.disconnect();
244
282
  ```
@@ -512,6 +550,39 @@ await client.conversations.markAsRead('conv-id', 'last-message-id');
512
550
  await client.conversations.markAsUnread('conv-id');
513
551
  ```
514
552
 
553
+ ### `getMedia(conversationId, params?): Promise<CursorPaginatedResult<ConversationMediaItemDto>>`
554
+
555
+ Liệt kê toàn bộ media (image/video/audio/file) và link (LinkPreview hoặc external URL) đã từng xuất hiện trong cuộc hội thoại — sắp xếp **newest-first**, cursor pagination.
556
+
557
+ ```ts
558
+ // Mặc định: kind = 'all', limit = 50
559
+ const page1 = await client.conversations.getMedia('conv-id');
560
+
561
+ // Chỉ file/ảnh nội bộ
562
+ const attachments = await client.conversations.getMedia('conv-id', {
563
+ kind: 'attachment',
564
+ limit: 100,
565
+ });
566
+
567
+ // Chỉ link / LinkPreview
568
+ const links = await client.conversations.getMedia('conv-id', { kind: 'link' });
569
+
570
+ // Trang tiếp theo
571
+ if (page1.hasMore) {
572
+ const page2 = await client.conversations.getMedia('conv-id', {
573
+ cursor: page1.nextCursor!,
574
+ });
575
+ }
576
+ ```
577
+
578
+ | Param | Type | Default | Mô tả |
579
+ |-------|------|---------|-------|
580
+ | `kind` | `'all' \| 'attachment' \| 'link'` | `'all'` | Lọc theo loại item |
581
+ | `limit` | number | 50 | 1..100 |
582
+ | `cursor` | string | — | `nextCursor` từ trang trước (opaque) |
583
+
584
+ > **Lưu ý:** Người dùng chỉ thấy media của các message từ thời điểm họ join cuộc hội thoại trở đi. Server trả `403` nếu caller không phải participant.
585
+
515
586
  ### Kiểu dữ liệu Conversation
516
587
 
517
588
  ```ts
@@ -555,6 +626,49 @@ interface ConversationListItemDto {
555
626
  unreadCount: number;
556
627
  participantCount: number;
557
628
  }
629
+
630
+ // Media listing (getMedia)
631
+ type ConversationMediaKindFilter = 'all' | 'attachment' | 'link';
632
+ type ConversationMediaKind = 'Attachment' | 'Link';
633
+ type ConversationMediaBlockType = 'Image' | 'Video' | 'Audio' | 'File' | 'LinkPreview';
634
+
635
+ interface ConversationMediaItemDto {
636
+ id: string; // Composite "{messageId}#{blockIndex:D2}"
637
+ conversationId: string;
638
+ messageId: string;
639
+ blockIndex: number; // 0-based vị trí block trong message
640
+ senderId: string;
641
+ senderType: string; // 'User' | 'Bot' | 'System'
642
+ kind: ConversationMediaKind;
643
+ blockType: ConversationMediaBlockType;
644
+ createdAt: string; // ISO 8601 — thời điểm message được tạo
645
+
646
+ storageKey?: string | null; // set khi kind === 'Attachment'
647
+ url?: string | null; // set khi kind === 'Link'
648
+
649
+ // File / audio / video
650
+ fileName?: string | null;
651
+ mimeType?: string | null;
652
+ fileSizeBytes?: number | null;
653
+
654
+ // Image / video / audio
655
+ width?: number | null;
656
+ height?: number | null;
657
+ durationSeconds?: number | null;
658
+ caption?: string | null;
659
+ altText?: string | null;
660
+
661
+ // Image / video thumbnail
662
+ thumbnailStorageKey?: string | null;
663
+ thumbnailUrl?: string | null;
664
+
665
+ // LinkPreview metadata
666
+ linkTitle?: string | null;
667
+ linkDescription?: string | null;
668
+ linkSiteName?: string | null;
669
+ linkImageStorageKey?: string | null;
670
+ linkImageUrl?: string | null;
671
+ }
558
672
  ```
559
673
 
560
674
  ---
@@ -665,7 +779,6 @@ interface MessageDto {
665
779
  conversationId: string;
666
780
  senderId: string;
667
781
  senderType: SenderType;
668
- timestamp: string; // ISO 8601 — thời điểm gửi
669
782
  blocks: Block[]; // Nội dung tin nhắn
670
783
  plainTextIndex: string | null;
671
784
  replyToMessageId: string | null;
@@ -956,6 +1069,28 @@ const updated = await client.proxy.put<Order>('trading', 'api/orders/123', {
956
1069
  quantity: 200,
957
1070
  });
958
1071
 
1072
+ // PATCH /api/proxy/trading/api/orders/123
1073
+ const patched = await client.proxy.patch<Order>('trading', 'api/orders/123', {
1074
+ status: 'cancelled',
1075
+ });
1076
+
1077
+ // DELETE /api/proxy/trading/api/orders/123
1078
+ await client.proxy.delete('trading', 'api/orders/123');
1079
+
1080
+ // Truyền custom headers — mỗi shortcut đều nhận `headers?` ở tham số cuối
1081
+ const dataWithHeader = await client.proxy.get<StockData>(
1082
+ 'trading',
1083
+ 'api/stocks/VN30',
1084
+ { 'X-Trace-Id': 'abc-123' },
1085
+ );
1086
+
1087
+ const orderWithHeader = await client.proxy.post<Order>(
1088
+ 'trading',
1089
+ 'api/orders',
1090
+ { symbol: 'VNM', quantity: 100 },
1091
+ { 'Idempotency-Key': 'order-uuid' },
1092
+ );
1093
+
959
1094
  // Request tùy chỉnh method (hỗ trợ GET, POST, PUT, PATCH, DELETE)
960
1095
  const result = await client.proxy.request<unknown>('myservice', 'api/resource/123', {
961
1096
  method: 'DELETE',
@@ -1052,6 +1187,12 @@ await client.realtime.chat.leaveConversation('conv-id');
1052
1187
  ```
1053
1188
 
1054
1189
  > SDK theo dõi `joinedConversations` và tự động re-join sau khi reconnect.
1190
+ >
1191
+ > **Lưu ý về FORBIDDEN/UNAUTHORIZED:** Theo spec, server có thể chấp nhận lời gọi `joinConversation` nhưng từ chối quyền truy cập sau đó qua event `Error` (vd. `FORBIDDEN — You are not a participant`). SDK sẽ tự động loại conversationId đó khỏi `joinedConversations` để tránh re-join sai sau reconnect, dựa trên:
1192
+ > 1. `error.details.conversationId` nếu server cung cấp (chính xác nhất), hoặc
1193
+ > 2. Fallback: message khớp `/not a participant/i` **và** chỉ có đúng 1 lời gọi `joinConversation` đang trong cửa sổ ~2s.
1194
+ >
1195
+ > Vẫn nên đăng ký listener `error` để hiển thị thông báo cho user.
1055
1196
 
1056
1197
  ### Gửi tin nhắn qua SignalR
1057
1198
 
@@ -1130,6 +1271,7 @@ client.realtime.chat.off('messageReceived', handler);
1130
1271
  | `messageUpdated` | `MessageUpdatedDto` | Tin nhắn được chỉnh sửa |
1131
1272
  | `messageDeleted` | `MessageDeletedDto` | Tin nhắn bị xóa |
1132
1273
  | `messageRecovered` | `ChatMessageDto` | Tin nhắn được khôi phục |
1274
+ | `messageThumbnailsReady` | `MessageThumbnailsReadyDto` | Server đã sinh xong thumbnail cho ảnh/video — patch theo `blockIndex` |
1133
1275
  | `reactionAdded` | `ReactionAddedDto` | Thêm reaction |
1134
1276
  | `reactionRemoved` | `ReactionRemovedDto` | Xóa reaction |
1135
1277
  | `typingStarted` | `TypingDto` | Người dùng đang gõ |
@@ -1203,6 +1345,36 @@ interface ReadReceiptUpdatedDto {
1203
1345
  lastReadMessageId: string;
1204
1346
  readAt: string; // ISO 8601
1205
1347
  }
1348
+
1349
+ // Thumbnail ready cho một block media trong message
1350
+ interface MessageThumbnailDto {
1351
+ blockIndex: number; // Vị trí block trong mảng blocks của message
1352
+ thumbnail: MediaReference;
1353
+ }
1354
+
1355
+ // Server đã sinh xong thumbnail cho ảnh/video (xử lý bất đồng bộ).
1356
+ // Event này gửi SAU messageReceived/streamCompleted.
1357
+ interface MessageThumbnailsReadyDto {
1358
+ messageId: string;
1359
+ conversationId: string;
1360
+ thumbnails: MessageThumbnailDto[];
1361
+ }
1362
+ ```
1363
+
1364
+ ### Cập nhật thumbnail bất đồng bộ
1365
+
1366
+ Khi gửi tin nhắn có ảnh/video, server xử lý thumbnail trong nền và phát event `messageThumbnailsReady` sau khi hoàn tất. Client nên patch block theo `blockIndex` thay vì re-fetch toàn bộ message.
1367
+
1368
+ ```ts
1369
+ client.realtime.chat.on('messageThumbnailsReady', (dto) => {
1370
+ // dto.thumbnails: [{ blockIndex: 0, thumbnail: { storageKey, url } }, ...]
1371
+ for (const item of dto.thumbnails) {
1372
+ const block = messageStore.get(dto.messageId)?.blocks[item.blockIndex];
1373
+ if (block && (block.$type === 'image' || block.$type === 'video')) {
1374
+ block.thumbnail = item.thumbnail;
1375
+ }
1376
+ }
1377
+ });
1206
1378
  ```
1207
1379
 
1208
1380
  ### Bot streaming
@@ -1280,7 +1452,7 @@ await client.realtime.notifications.resubscribePresence();
1280
1452
  ### Theo dõi trạng thái online (Presence)
1281
1453
 
1282
1454
  ```ts
1283
- // Đăng ký theo dõi tối đa 200 participant mỗi lần
1455
+ // Đăng ký theo dõi presence SDK tự chia nhỏ thành các batch 200 nếu danh sách dài hơn
1284
1456
  await client.realtime.notifications.subscribeToPresence(['user-1', 'user-2', 'user-3']);
1285
1457
 
1286
1458
  // Server gửi PresenceState ngay lập tức sau khi subscribe
@@ -1295,7 +1467,7 @@ client.realtime.notifications.on('presenceChanged', (dto) => {
1295
1467
  console.log(dto.participantId, 'is now', dto.isOnline ? 'online' : 'offline');
1296
1468
  });
1297
1469
 
1298
- // Hủy theo dõi — cũng giới hạn tối đa 200 participant mỗi lần
1470
+ // Hủy theo dõi — cũng tự chia nhỏ thành các batch 200
1299
1471
  await client.realtime.notifications.unsubscribeFromPresence(['user-1']);
1300
1472
  ```
1301
1473
 
@@ -1344,11 +1516,11 @@ interface PresenceChangedDto {
1344
1516
  interface NewMessageNotificationDto {
1345
1517
  conversationId: string;
1346
1518
  conversationType: ConversationType;
1347
- conversationName?: string;
1519
+ conversationName?: string | null;
1348
1520
  messageId: string;
1349
1521
  senderId: string;
1350
1522
  senderName: string;
1351
- senderAvatar?: MediaReference;
1523
+ senderAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
1352
1524
  contentPreview: string;
1353
1525
  sentAt: string; // ISO 8601
1354
1526
  }
@@ -1373,15 +1545,15 @@ interface UnreadCountChangedDto {
1373
1545
  interface ConversationCreatedDto {
1374
1546
  conversationId: string;
1375
1547
  type: ConversationType;
1376
- name?: string;
1377
- avatar?: MediaReference;
1548
+ name?: string | null;
1549
+ avatar?: MediaReference | null;
1378
1550
  participantCount: number;
1379
1551
  }
1380
1552
 
1381
1553
  interface ConversationUpdatedDto {
1382
1554
  conversationId: string;
1383
1555
  type: ConversationType;
1384
- name?: string;
1556
+ name?: string | null;
1385
1557
  avatar?: MediaReference | null; // null = avatar đã bị xóa
1386
1558
  participantCount: number;
1387
1559
  }
@@ -1390,7 +1562,7 @@ interface ParticipantJoinedDto {
1390
1562
  conversationId: string;
1391
1563
  participantId: string;
1392
1564
  participantName: string;
1393
- participantAvatar?: MediaReference;
1565
+ participantAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
1394
1566
  changedAt: string; // ISO 8601
1395
1567
  }
1396
1568
 
@@ -1399,7 +1571,7 @@ interface ParticipantLeftDto {
1399
1571
  conversationId: string;
1400
1572
  participantId: string;
1401
1573
  participantName: string;
1402
- participantAvatar?: MediaReference;
1574
+ participantAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
1403
1575
  changedAt: string; // ISO 8601
1404
1576
  }
1405
1577
 
@@ -1457,7 +1629,7 @@ const manager = new ReconnectionManager({
1457
1629
  // Gọi API refresh token của ứng dụng
1458
1630
  const newToken = await refreshToken();
1459
1631
  if (newToken) {
1460
- client.setToken(newToken); // Cập nhật token vào SDK
1632
+ client.setToken(newToken); // Cập nhật token vào SDK; lần connect() kế tiếp sẽ dùng giá trị mới
1461
1633
  }
1462
1634
  return newToken; // Trả về null để hủy reconnect
1463
1635
  },
@@ -1471,7 +1643,9 @@ manager.stop();
1471
1643
 
1472
1644
  **Chiến lược backoff:** 3 lần thử với delay 2s → 5s → 10s.
1473
1645
 
1474
- **Phát hiện token hết hạn:** Kiểm tra error message chứa `"401"` hoặc `"unauthorized"`.
1646
+ **Phát hiện token hết hạn:** heuristic match (case-insensitive) các keyword `401`, `unauthorized`, `expired`, `invalid_token`, `invalid token` trong message của error.
1647
+
1648
+ **Bỏ qua close chủ động:** Cả `ChatHubClient` và `NotificationHubClient` expose getter `intentionallyClosed`. Trước khi tear down trong `disconnect()` (kể cả khi gọi qua `client.disconnect()` / `client.logout()`), cờ này được set `true`. Manager kiểm tra cờ ngay đầu handler và **bỏ qua** lần `disconnected` đó — không tự reconnect ngược ý người dùng. Cờ được reset về `false` ở đầu lần `connect()` kế tiếp.
1475
1649
 
1476
1650
  ---
1477
1651
 
@@ -1685,7 +1859,7 @@ type ButtonStyle = 'Default' | 'Primary' | 'Danger';
1685
1859
  interface ActionButton {
1686
1860
  label: string;
1687
1861
  action: ButtonAction;
1688
- value: string | null;
1862
+ value?: string | null;
1689
1863
  style: ButtonStyle;
1690
1864
  }
1691
1865
 
@@ -1703,7 +1877,7 @@ type ChoiceMode = 'Single' | 'Multiple';
1703
1877
  interface ChoiceOption {
1704
1878
  label: string;
1705
1879
  value: string;
1706
- selected: boolean;
1880
+ selected?: boolean;
1707
1881
  }
1708
1882
 
1709
1883
  interface ChoiceBlock {
@@ -1784,7 +1958,7 @@ interface ChatMessageDto {
1784
1958
  conversationId: string;
1785
1959
  senderId: string;
1786
1960
  senderName: string; // Có ở SignalR, không có ở REST
1787
- senderAvatar?: MediaReference; // Có ở SignalR, không có ở REST
1961
+ senderAvatar?: MediaReference | null; // Có ở SignalR, không có ở REST. Có thể null khi server chưa sẵn signed URL
1788
1962
  senderType: SenderType; // Có ở cả REST và SignalR
1789
1963
  blocks: Block[];
1790
1964
  plainTextContent: string | null; // Tên khác với REST (plainTextIndex)
@@ -1816,14 +1990,14 @@ type SystemEventType =
1816
1990
  | 'AvatarChanged' // Đổi avatar
1817
1991
  | 'MemberAdded' // Thêm thành viên
1818
1992
  | 'MemberRemoved' // Xóa thành viên
1819
- | 'MemberLeft' // Thành viên tự rời
1820
- | 'Custom';
1993
+ | 'MemberLeft'; // Thành viên tự rời
1821
1994
 
1822
1995
  interface SystemEventInfo {
1823
1996
  type: SystemEventType;
1824
- actorId: string | null; // null khi MemberLeft (tự rời)
1997
+ actorId?: string | null; // null/undefined khi không có actor (vd. MemberLeft tự rời)
1825
1998
  targetIds?: string[];
1826
- metadata?: Record<string, unknown>;
1999
+ // Tất cả giá trị đều là string (vd. ConversationRenamed → { newName: "Team Beta" })
2000
+ metadata?: Record<string, string>;
1827
2001
  }
1828
2002
 
1829
2003
  // SignalR (ChatHub) version — kế thừa SystemEventInfo, thêm display names
@@ -1839,7 +2013,8 @@ interface SystemEventDto extends SystemEventInfo {
1839
2013
  interface HubErrorDto {
1840
2014
  code: string;
1841
2015
  message: string;
1842
- details?: Record<string, unknown>;
2016
+ // Server có thể gửi `null` rõ ràng, hoặc bỏ field hoàn toàn
2017
+ details?: Record<string, unknown> | null;
1843
2018
  }
1844
2019
  ```
1845
2020
 
@@ -1852,8 +2027,8 @@ interface StreamStartedDto {
1852
2027
  conversationId: string;
1853
2028
  senderId: string;
1854
2029
  senderName: string;
1855
- senderAvatar?: MediaReference;
1856
- replyToMessageId?: string;
2030
+ senderAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
2031
+ replyToMessageId?: string | null;
1857
2032
  startedAt: string; // ISO 8601
1858
2033
  }
1859
2034
 
@@ -2116,7 +2291,9 @@ const cursor = Number(result.nextCursor); // BUG!
2116
2291
 
2117
2292
  - `setToken()` hoạt động ngay cho **HTTP requests**.
2118
2293
  - SignalR đọc token khi **connect()**, không phải mid-session.
2119
- - Để cập nhật token cho SignalR: `disconnect()` `setToken()` → `connect()`.
2294
+ - Để áp token mới ngay cho hub đang Connected: `setToken(newToken)` → `await refreshConnections()`.
2295
+ - Sau `refreshConnections()` cần tự `joinConversation()` / `subscribeToPresence()` lại (tracking bị clear khi disconnect). Hoặc dùng `ReconnectionManager` để tự động khôi phục.
2296
+ - Khi `tokenProvider` được cấu hình, provider luôn được hỏi trước. `setToken()` chỉ là fallback cho trường hợp provider trả `null`/empty.
2120
2297
 
2121
2298
  ### REST vs SignalR — field name khác nhau
2122
2299
 
@@ -24,10 +24,25 @@ export interface ChatClientOptions {
24
24
  token?: string;
25
25
  /**
26
26
  * Optional external token provider function.
27
- * When both `token` and `tokenProvider` are provided, the internal `_token`
28
- * (set via constructor or setToken()) takes priority over the external provider.
27
+ *
28
+ * Resolution order on every request / connect:
29
+ * 1. If `tokenProvider` is set AND returns a non-empty string, that value
30
+ * is used (so external rotation systems are honored on each call).
31
+ * 2. Otherwise the internal token (constructor `token`, `setToken()`, or
32
+ * auto-set after a successful login) is used.
33
+ * 3. Otherwise null (no Authorization header sent).
34
+ *
35
+ * Note: this is reversed from the previous beta — previously a one-time
36
+ * setToken() would shadow the provider permanently. Now the provider always
37
+ * wins when it returns a value. Use `clearToken()` if you want to make sure
38
+ * the provider takes over after using setToken().
29
39
  */
30
40
  tokenProvider?: () => string | null;
41
+ /**
42
+ * Per-request HTTP timeout in milliseconds. Default 30000 (30s).
43
+ * Pass `null` to disable the timeout — useful when uploading large files.
44
+ */
45
+ requestTimeoutMs?: number | null;
31
46
  /** Options for SignalR hub connections */
32
47
  signalrOptions?: ChatClientSignalrOptions;
33
48
  }
@@ -67,8 +82,13 @@ export interface ChatClientRealtime {
67
82
  */
68
83
  export declare class ChatClient {
69
84
  /**
70
- * The current JWT token held by the SDK.
71
- * Overrides any external tokenProvider when non-null.
85
+ * Internally held JWT token (set via constructor `token`, `setToken()`, or
86
+ * after a successful login).
87
+ *
88
+ * Acts as the fallback when no `tokenProvider` is configured, or when the
89
+ * configured `tokenProvider` returns null. The external `tokenProvider`
90
+ * always wins when it returns a non-empty value — see
91
+ * {@link ChatClientOptions.tokenProvider}.
72
92
  */
73
93
  private _token;
74
94
  /**
@@ -101,16 +121,55 @@ export declare class ChatClient {
101
121
  /**
102
122
  * Update the JWT token.
103
123
  *
104
- * The new token takes effect immediately for all subsequent HTTP requests.
105
- * Active SignalR connections are NOT updated they must be reconnected
106
- * (disconnect() then connect()) for the new token to be used.
124
+ * Effect on requests:
125
+ * - HTTP: takes effect on the very next request.
126
+ * - SignalR: existing active connections keep using the token they were
127
+ * opened with. To apply the new token immediately to active hubs, call
128
+ * `refreshConnections()` after `setToken()`.
129
+ *
130
+ * Pass `null` to clear the internally held token (same as `clearToken()`):
131
+ * both `_token` and `currentUser` are cleared so the SDK no longer reports
132
+ * stale identity. If a `tokenProvider` is configured and still returns a
133
+ * value, it will continue to be used regardless of this call.
134
+ */
135
+ setToken(token: string | null): void;
136
+ /**
137
+ * Clear the internally held JWT token and authenticated user.
138
+ *
139
+ * After this, the configured `tokenProvider` (if any) becomes the only
140
+ * source of tokens. Use `logout()` instead when you also want to disconnect
141
+ * SignalR hubs.
107
142
  */
108
- setToken(token: string): void;
143
+ clearToken(): void;
144
+ /**
145
+ * Disconnect any active SignalR hub and reconnect it with the latest token.
146
+ *
147
+ * Useful right after `setToken()` to propagate the new token to live hubs.
148
+ * Hubs that are not currently connected are left untouched.
149
+ *
150
+ * Re-joins of conversations / re-subscriptions of presence are NOT performed
151
+ * automatically here — `disconnect()` clears that local tracking by design.
152
+ * The application is responsible for re-issuing those after a manual refresh
153
+ * (or for using `ReconnectionManager` for transparent recovery).
154
+ */
155
+ refreshConnections(): Promise<void>;
156
+ /**
157
+ * Log the current user out: clear the internal token + currentUser and
158
+ * disconnect both SignalR hubs.
159
+ *
160
+ * Note: there is no server-side logout endpoint — this only clears local
161
+ * SDK state. If a `tokenProvider` is configured and still returns a token,
162
+ * subsequent requests can authenticate again with that token.
163
+ */
164
+ logout(): Promise<void>;
109
165
  /**
110
166
  * Disconnect all SignalR hubs (ChatHub + NotificationHub).
111
167
  *
112
168
  * Resolves after both hubs have finished disconnecting. Individual hub
113
169
  * disconnect failures do not prevent the other hub from disconnecting.
170
+ *
171
+ * Both hubs set their `intentionallyClosed` flag, so a `ReconnectionManager`
172
+ * attached to them will skip auto-reconnect for the resulting close.
114
173
  */
115
174
  disconnect(): Promise<void>;
116
175
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"ChatClient.d.ts","sourceRoot":"","sources":["../src/ChatClient.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAE9C,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAClD,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC1C,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qCAAqC,CAAC;AAC5E,OAAO,KAAK,EAAgB,YAAY,EAAgB,MAAM,iBAAiB,CAAC;AAoDhF,MAAM,WAAW,wBAAwB;IACvC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,QAAQ,CAAC;CACrB;AAED,MAAM,WAAW,iBAAiB;IAChC,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACpC,0CAA0C;IAC1C,cAAc,CAAC,EAAE,wBAAwB,CAAC;CAC3C;AAMD,MAAM,WAAW,kBAAkB;IACjC,6DAA6D;IAC7D,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,oEAAoE;IACpE,QAAQ,CAAC,aAAa,EAAE,qBAAqB,CAAC;CAC/C;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,qBAAa,UAAU;IAKrB;;;OAGG;IACH,OAAO,CAAC,MAAM,CAAgB;IAE9B;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAsB;IAM7D,+BAA+B;IAC/B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAEvB,6BAA6B;IAC7B,QAAQ,CAAC,YAAY,EAAE,cAAc,CAAC;IAEtC,8BAA8B;IAC9B,QAAQ,CAAC,aAAa,EAAE,eAAe,CAAC;IAExC,yBAAyB;IACzB,QAAQ,CAAC,QAAQ,EAAE,UAAU,CAAC;IAE9B,6BAA6B;IAC7B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IAExB,qBAAqB;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB,4BAA4B;IAC5B,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IAEzB,6BAA6B;IAC7B,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAM3B,0BAA0B;IAC1B,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,CAAC;IAMtC,OAAO,CAAC,YAAY,CAA6B;IAEjD,+EAA+E;IAC/E,IAAI,WAAW,IAAI,YAAY,GAAG,IAAI,CAErC;gBAMW,OAAO,EAAE,iBAAiB;IAuDtC;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAI7B;;;;;OAKG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAWjC;;;OAGG;IACH,OAAO,CAAC,eAAe;CAIxB"}
1
+ {"version":3,"file":"ChatClient.d.ts","sourceRoot":"","sources":["../src/ChatClient.ts"],"names":[],"mappings":"AAEA,OAAO,EAAsB,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAElE,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAClD,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC1C,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qCAAqC,CAAC;AAC5E,OAAO,KAAK,EAAgB,YAAY,EAAgB,MAAM,iBAAiB,CAAC;AAoDhF,MAAM,WAAW,wBAAwB;IACvC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,QAAQ,CAAC;CACrB;AAED,MAAM,WAAW,iBAAiB;IAChC,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;;;;;;;;OAcG;IACH,aAAa,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACpC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,0CAA0C;IAC1C,cAAc,CAAC,EAAE,wBAAwB,CAAC;CAC3C;AAMD,MAAM,WAAW,kBAAkB;IACjC,6DAA6D;IAC7D,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,oEAAoE;IACpE,QAAQ,CAAC,aAAa,EAAE,qBAAqB,CAAC;CAC/C;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,qBAAa,UAAU;IAKrB;;;;;;;;OAQG;IACH,OAAO,CAAC,MAAM,CAAgB;IAE9B;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAsB;IAM7D,+BAA+B;IAC/B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAEvB,6BAA6B;IAC7B,QAAQ,CAAC,YAAY,EAAE,cAAc,CAAC;IAEtC,8BAA8B;IAC9B,QAAQ,CAAC,aAAa,EAAE,eAAe,CAAC;IAExC,yBAAyB;IACzB,QAAQ,CAAC,QAAQ,EAAE,UAAU,CAAC;IAE9B,6BAA6B;IAC7B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IAExB,qBAAqB;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB,4BAA4B;IAC5B,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IAEzB,6BAA6B;IAC7B,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAM3B,0BAA0B;IAC1B,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,CAAC;IAMtC,OAAO,CAAC,YAAY,CAA6B;IAEjD,+EAA+E;IAC/E,IAAI,WAAW,IAAI,YAAY,GAAG,IAAI,CAErC;gBAMW,OAAO,EAAE,iBAAiB;IA+DtC;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAOpC;;;;;;OAMG;IACH,UAAU,IAAI,IAAI;IAKlB;;;;;;;;;;OAUG;IACG,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;IAqBzC;;;;;;;OAOG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAK7B;;;;;;;;OAQG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAWjC;;;OAGG;IACH,OAAO,CAAC,eAAe;CAIxB"}
@@ -1,5 +1,5 @@
1
1
  // ChatClient.ts — Main SDK facade
2
- import { LogLevel } from '@microsoft/signalr';
2
+ import { HubConnectionState, LogLevel } from '@microsoft/signalr';
3
3
  import { HttpClient } from './http/HttpClient.js';
4
4
  import { AuthApi } from './http/AuthApi.js';
5
5
  import { ParticipantApi } from './http/ParticipantApi.js';
@@ -94,16 +94,26 @@ export class ChatClient {
94
94
  // ---------------------------------------------------------------------------
95
95
  this._currentUser = null;
96
96
  this._token = options.token ?? null;
97
- // Build the combined token provider:
98
- // 1. Return _token if it has been set (via constructor or setToken()).
99
- // 2. Fall back to the external tokenProvider if provided.
100
- // 3. Return null otherwise.
97
+ // Build the combined token provider — see ChatClientOptions.tokenProvider
98
+ // for the rationale. The external provider takes priority when it returns
99
+ // a non-empty value so refresh-token rotation systems keep working even
100
+ // after setToken() has been called.
101
101
  const externalProvider = options.tokenProvider;
102
- this._internalTokenProvider = () => this._token ?? (externalProvider ? externalProvider() : null);
102
+ this._internalTokenProvider = () => {
103
+ if (externalProvider) {
104
+ const fromProvider = externalProvider();
105
+ if (fromProvider)
106
+ return fromProvider;
107
+ }
108
+ return this._token;
109
+ };
103
110
  // Shared HTTP client used by all REST API modules
104
111
  const http = new HttpClient({
105
112
  baseUrl: options.baseUrl,
106
113
  tokenProvider: this._internalTokenProvider,
114
+ ...(options.requestTimeoutMs !== undefined
115
+ ? { requestTimeoutMs: options.requestTimeoutMs }
116
+ : {}),
107
117
  });
108
118
  // Instantiate REST API modules
109
119
  this.participants = new ParticipantApi(http);
@@ -139,18 +149,81 @@ export class ChatClient {
139
149
  /**
140
150
  * Update the JWT token.
141
151
  *
142
- * The new token takes effect immediately for all subsequent HTTP requests.
143
- * Active SignalR connections are NOT updated they must be reconnected
144
- * (disconnect() then connect()) for the new token to be used.
152
+ * Effect on requests:
153
+ * - HTTP: takes effect on the very next request.
154
+ * - SignalR: existing active connections keep using the token they were
155
+ * opened with. To apply the new token immediately to active hubs, call
156
+ * `refreshConnections()` after `setToken()`.
157
+ *
158
+ * Pass `null` to clear the internally held token (same as `clearToken()`):
159
+ * both `_token` and `currentUser` are cleared so the SDK no longer reports
160
+ * stale identity. If a `tokenProvider` is configured and still returns a
161
+ * value, it will continue to be used regardless of this call.
145
162
  */
146
163
  setToken(token) {
147
164
  this._token = token;
165
+ if (token === null) {
166
+ this._currentUser = null;
167
+ }
168
+ }
169
+ /**
170
+ * Clear the internally held JWT token and authenticated user.
171
+ *
172
+ * After this, the configured `tokenProvider` (if any) becomes the only
173
+ * source of tokens. Use `logout()` instead when you also want to disconnect
174
+ * SignalR hubs.
175
+ */
176
+ clearToken() {
177
+ this._token = null;
178
+ this._currentUser = null;
179
+ }
180
+ /**
181
+ * Disconnect any active SignalR hub and reconnect it with the latest token.
182
+ *
183
+ * Useful right after `setToken()` to propagate the new token to live hubs.
184
+ * Hubs that are not currently connected are left untouched.
185
+ *
186
+ * Re-joins of conversations / re-subscriptions of presence are NOT performed
187
+ * automatically here — `disconnect()` clears that local tracking by design.
188
+ * The application is responsible for re-issuing those after a manual refresh
189
+ * (or for using `ReconnectionManager` for transparent recovery).
190
+ */
191
+ async refreshConnections() {
192
+ const tasks = [];
193
+ if (this.realtime.chat.state === HubConnectionState.Connected) {
194
+ tasks.push((async () => {
195
+ await this.realtime.chat.disconnect();
196
+ await this.realtime.chat.connect();
197
+ })());
198
+ }
199
+ if (this.realtime.notifications.state === HubConnectionState.Connected) {
200
+ tasks.push((async () => {
201
+ await this.realtime.notifications.disconnect();
202
+ await this.realtime.notifications.connect();
203
+ })());
204
+ }
205
+ await Promise.all(tasks);
206
+ }
207
+ /**
208
+ * Log the current user out: clear the internal token + currentUser and
209
+ * disconnect both SignalR hubs.
210
+ *
211
+ * Note: there is no server-side logout endpoint — this only clears local
212
+ * SDK state. If a `tokenProvider` is configured and still returns a token,
213
+ * subsequent requests can authenticate again with that token.
214
+ */
215
+ async logout() {
216
+ this.clearToken();
217
+ await this.disconnect();
148
218
  }
149
219
  /**
150
220
  * Disconnect all SignalR hubs (ChatHub + NotificationHub).
151
221
  *
152
222
  * Resolves after both hubs have finished disconnecting. Individual hub
153
223
  * disconnect failures do not prevent the other hub from disconnecting.
224
+ *
225
+ * Both hubs set their `intentionallyClosed` flag, so a `ReconnectionManager`
226
+ * attached to them will skip auto-reconnect for the resulting close.
154
227
  */
155
228
  async disconnect() {
156
229
  await Promise.allSettled([