@manonero/chat-client-sdk 1.0.0-beta.3 → 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 +196 -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 +6 -5
  18. package/dist/http/ProxyApi.d.ts.map +1 -1
  19. package/dist/http/ProxyApi.js +15 -11
  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;
@@ -964,6 +1077,20 @@ const patched = await client.proxy.patch<Order>('trading', 'api/orders/123', {
964
1077
  // DELETE /api/proxy/trading/api/orders/123
965
1078
  await client.proxy.delete('trading', 'api/orders/123');
966
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
+
967
1094
  // Request tùy chỉnh method (hỗ trợ GET, POST, PUT, PATCH, DELETE)
968
1095
  const result = await client.proxy.request<unknown>('myservice', 'api/resource/123', {
969
1096
  method: 'DELETE',
@@ -1060,6 +1187,12 @@ await client.realtime.chat.leaveConversation('conv-id');
1060
1187
  ```
1061
1188
 
1062
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.
1063
1196
 
1064
1197
  ### Gửi tin nhắn qua SignalR
1065
1198
 
@@ -1138,6 +1271,7 @@ client.realtime.chat.off('messageReceived', handler);
1138
1271
  | `messageUpdated` | `MessageUpdatedDto` | Tin nhắn được chỉnh sửa |
1139
1272
  | `messageDeleted` | `MessageDeletedDto` | Tin nhắn bị xóa |
1140
1273
  | `messageRecovered` | `ChatMessageDto` | Tin nhắn được khôi phục |
1274
+ | `messageThumbnailsReady` | `MessageThumbnailsReadyDto` | Server đã sinh xong thumbnail cho ảnh/video — patch theo `blockIndex` |
1141
1275
  | `reactionAdded` | `ReactionAddedDto` | Thêm reaction |
1142
1276
  | `reactionRemoved` | `ReactionRemovedDto` | Xóa reaction |
1143
1277
  | `typingStarted` | `TypingDto` | Người dùng đang gõ |
@@ -1211,6 +1345,36 @@ interface ReadReceiptUpdatedDto {
1211
1345
  lastReadMessageId: string;
1212
1346
  readAt: string; // ISO 8601
1213
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
+ });
1214
1378
  ```
1215
1379
 
1216
1380
  ### Bot streaming
@@ -1288,7 +1452,7 @@ await client.realtime.notifications.resubscribePresence();
1288
1452
  ### Theo dõi trạng thái online (Presence)
1289
1453
 
1290
1454
  ```ts
1291
- // Đă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
1292
1456
  await client.realtime.notifications.subscribeToPresence(['user-1', 'user-2', 'user-3']);
1293
1457
 
1294
1458
  // Server gửi PresenceState ngay lập tức sau khi subscribe
@@ -1303,7 +1467,7 @@ client.realtime.notifications.on('presenceChanged', (dto) => {
1303
1467
  console.log(dto.participantId, 'is now', dto.isOnline ? 'online' : 'offline');
1304
1468
  });
1305
1469
 
1306
- // 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
1307
1471
  await client.realtime.notifications.unsubscribeFromPresence(['user-1']);
1308
1472
  ```
1309
1473
 
@@ -1352,11 +1516,11 @@ interface PresenceChangedDto {
1352
1516
  interface NewMessageNotificationDto {
1353
1517
  conversationId: string;
1354
1518
  conversationType: ConversationType;
1355
- conversationName?: string;
1519
+ conversationName?: string | null;
1356
1520
  messageId: string;
1357
1521
  senderId: string;
1358
1522
  senderName: string;
1359
- senderAvatar?: MediaReference;
1523
+ senderAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
1360
1524
  contentPreview: string;
1361
1525
  sentAt: string; // ISO 8601
1362
1526
  }
@@ -1381,15 +1545,15 @@ interface UnreadCountChangedDto {
1381
1545
  interface ConversationCreatedDto {
1382
1546
  conversationId: string;
1383
1547
  type: ConversationType;
1384
- name?: string;
1385
- avatar?: MediaReference;
1548
+ name?: string | null;
1549
+ avatar?: MediaReference | null;
1386
1550
  participantCount: number;
1387
1551
  }
1388
1552
 
1389
1553
  interface ConversationUpdatedDto {
1390
1554
  conversationId: string;
1391
1555
  type: ConversationType;
1392
- name?: string;
1556
+ name?: string | null;
1393
1557
  avatar?: MediaReference | null; // null = avatar đã bị xóa
1394
1558
  participantCount: number;
1395
1559
  }
@@ -1398,7 +1562,7 @@ interface ParticipantJoinedDto {
1398
1562
  conversationId: string;
1399
1563
  participantId: string;
1400
1564
  participantName: string;
1401
- participantAvatar?: MediaReference;
1565
+ participantAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
1402
1566
  changedAt: string; // ISO 8601
1403
1567
  }
1404
1568
 
@@ -1407,7 +1571,7 @@ interface ParticipantLeftDto {
1407
1571
  conversationId: string;
1408
1572
  participantId: string;
1409
1573
  participantName: string;
1410
- participantAvatar?: MediaReference;
1574
+ participantAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
1411
1575
  changedAt: string; // ISO 8601
1412
1576
  }
1413
1577
 
@@ -1465,7 +1629,7 @@ const manager = new ReconnectionManager({
1465
1629
  // Gọi API refresh token của ứng dụng
1466
1630
  const newToken = await refreshToken();
1467
1631
  if (newToken) {
1468
- 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
1469
1633
  }
1470
1634
  return newToken; // Trả về null để hủy reconnect
1471
1635
  },
@@ -1479,7 +1643,9 @@ manager.stop();
1479
1643
 
1480
1644
  **Chiến lược backoff:** 3 lần thử với delay 2s → 5s → 10s.
1481
1645
 
1482
- **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.
1483
1649
 
1484
1650
  ---
1485
1651
 
@@ -1693,7 +1859,7 @@ type ButtonStyle = 'Default' | 'Primary' | 'Danger';
1693
1859
  interface ActionButton {
1694
1860
  label: string;
1695
1861
  action: ButtonAction;
1696
- value: string | null;
1862
+ value?: string | null;
1697
1863
  style: ButtonStyle;
1698
1864
  }
1699
1865
 
@@ -1711,7 +1877,7 @@ type ChoiceMode = 'Single' | 'Multiple';
1711
1877
  interface ChoiceOption {
1712
1878
  label: string;
1713
1879
  value: string;
1714
- selected: boolean;
1880
+ selected?: boolean;
1715
1881
  }
1716
1882
 
1717
1883
  interface ChoiceBlock {
@@ -1792,7 +1958,7 @@ interface ChatMessageDto {
1792
1958
  conversationId: string;
1793
1959
  senderId: string;
1794
1960
  senderName: string; // Có ở SignalR, không có ở REST
1795
- 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
1796
1962
  senderType: SenderType; // Có ở cả REST và SignalR
1797
1963
  blocks: Block[];
1798
1964
  plainTextContent: string | null; // Tên khác với REST (plainTextIndex)
@@ -1824,14 +1990,14 @@ type SystemEventType =
1824
1990
  | 'AvatarChanged' // Đổi avatar
1825
1991
  | 'MemberAdded' // Thêm thành viên
1826
1992
  | 'MemberRemoved' // Xóa thành viên
1827
- | 'MemberLeft' // Thành viên tự rời
1828
- | 'Custom';
1993
+ | 'MemberLeft'; // Thành viên tự rời
1829
1994
 
1830
1995
  interface SystemEventInfo {
1831
1996
  type: SystemEventType;
1832
- 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)
1833
1998
  targetIds?: string[];
1834
- metadata?: Record<string, unknown>;
1999
+ // Tất cả giá trị đều là string (vd. ConversationRenamed → { newName: "Team Beta" })
2000
+ metadata?: Record<string, string>;
1835
2001
  }
1836
2002
 
1837
2003
  // SignalR (ChatHub) version — kế thừa SystemEventInfo, thêm display names
@@ -1847,7 +2013,8 @@ interface SystemEventDto extends SystemEventInfo {
1847
2013
  interface HubErrorDto {
1848
2014
  code: string;
1849
2015
  message: string;
1850
- 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;
1851
2018
  }
1852
2019
  ```
1853
2020
 
@@ -1860,8 +2027,8 @@ interface StreamStartedDto {
1860
2027
  conversationId: string;
1861
2028
  senderId: string;
1862
2029
  senderName: string;
1863
- senderAvatar?: MediaReference;
1864
- replyToMessageId?: string;
2030
+ senderAvatar?: MediaReference | null; // Có thể null khi server chưa sẵn signed URL
2031
+ replyToMessageId?: string | null;
1865
2032
  startedAt: string; // ISO 8601
1866
2033
  }
1867
2034
 
@@ -2124,7 +2291,9 @@ const cursor = Number(result.nextCursor); // BUG!
2124
2291
 
2125
2292
  - `setToken()` hoạt động ngay cho **HTTP requests**.
2126
2293
  - SignalR đọc token khi **connect()**, không phải mid-session.
2127
- - Để 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.
2128
2297
 
2129
2298
  ### REST vs SignalR — field name khác nhau
2130
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":"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"}
@@ -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([