useChat.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import type { ChatSDK, Conversation } from '@tencentcloud/lite-chat'
  2. import { userApi } from '~/api/user'
  3. import type { UserInfoItemVO } from '~/types/api'
  4. type TencentCloudChatStatic = typeof import('@tencentcloud/lite-chat').default
  5. export type GetConversationListOptions = Parameters<ChatSDK['getConversationList']>[0]
  6. type UserSigResponse = {
  7. code: number
  8. msg: string
  9. data: {
  10. sdkAppId: number
  11. userId: string
  12. userSig: string
  13. expire: number
  14. }
  15. }
  16. export type ChatLoginOptions = {
  17. userId: string
  18. /**
  19. * If not provided, `useChat()` will call `/api/im/userSig` to fetch it.
  20. */
  21. userSig?: string
  22. /**
  23. * Custom userSig provider. If both `userSig` and `getUserSig` are provided,
  24. * `userSig` takes precedence.
  25. */
  26. getUserSig?: (userId: string) => Promise<string>
  27. }
  28. let _initPromise: Promise<void> | null = null
  29. let _loginPromise: Promise<void> | null = null
  30. let _loginUserId: string | null = null
  31. let _sdk: TencentCloudChatStatic | null = null
  32. let _onReady: (() => void) | null = null
  33. let _onNotReady: (() => void) | null = null
  34. type ConversationListUpdatedEvent = { data: Conversation[] }
  35. let _onConversationListUpdated: ((event: ConversationListUpdatedEvent) => void) | null = null
  36. let _bizUserInfoFetchTimer: ReturnType<typeof setTimeout> | null = null
  37. function normalizeLoginUser(v: unknown) {
  38. if (v === null || v === undefined) return ''
  39. return String(v)
  40. }
  41. function chunkArray<T>(arr: T[], size: number) {
  42. if (size <= 0) return [arr]
  43. const out: T[][] = []
  44. for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
  45. return out
  46. }
  47. async function defaultGetUserSig(userId: string) {
  48. const res = await $fetch<UserSigResponse>('/api/im/userSig', {
  49. method: 'POST',
  50. body: { userId },
  51. })
  52. return res.data.userSig
  53. }
  54. export function useChat() {
  55. const chat = useState<ChatSDK | null>('im-chat-sdk', () => null)
  56. const ready = useState<boolean>('im-chat-ready', () => false)
  57. const conversationList = useState<Conversation[]>('im-conversation-list', () => [])
  58. const conversationLoading = useState<boolean>('im-conversation-loading', () => false)
  59. const conversationError = useState<string | null>('im-conversation-error', () => null)
  60. // Business user info cache: userNo(userID) -> info
  61. const bizUserInfoMap = useState<Record<string, UserInfoItemVO>>('im-biz-user-info-map', () => ({}))
  62. const bizUserInfoLoading = useState<boolean>('im-biz-user-info-loading', () => false)
  63. const bizUserInfoError = useState<string | null>('im-biz-user-info-error', () => null)
  64. function extractUserNosFromConversations(list: Conversation[]) {
  65. const userNos: string[] = []
  66. for (const conv of list) {
  67. const userNo = conv?.userProfile?.userID
  68. if (!userNo) continue
  69. if (userNo === '10000') continue
  70. userNos.push(String(userNo))
  71. }
  72. return userNos
  73. }
  74. async function fetchBizUserInfos(userNos: string[]) {
  75. const uniq = Array.from(new Set(userNos.filter(Boolean)))
  76. const missing = uniq.filter(id => !bizUserInfoMap.value[id])
  77. if (missing.length === 0) return
  78. bizUserInfoLoading.value = true
  79. bizUserInfoError.value = null
  80. try {
  81. const chunks = chunkArray(missing, 20)
  82. const results = await Promise.all(chunks.map(c => userApi.getUserInfos(c)))
  83. for (const res of results) {
  84. const list = res?.list
  85. if (!Array.isArray(list)) continue
  86. for (const item of list) {
  87. if (!item?.userNo) continue
  88. bizUserInfoMap.value[item.userNo] = item
  89. }
  90. }
  91. }
  92. catch (e: unknown) {
  93. const err = e as { message?: unknown }
  94. bizUserInfoError.value = err?.message ? String(err.message) : String(e)
  95. // Do not block IM list rendering if business api fails
  96. console.warn('[im] fetchBizUserInfos failed:', e)
  97. }
  98. finally {
  99. bizUserInfoLoading.value = false
  100. }
  101. }
  102. function queueFetchBizUserInfosFromConversations(list: Conversation[]) {
  103. if (!import.meta.client) return
  104. if (_bizUserInfoFetchTimer) clearTimeout(_bizUserInfoFetchTimer)
  105. _bizUserInfoFetchTimer = setTimeout(() => {
  106. _bizUserInfoFetchTimer = null
  107. const userNos = extractUserNosFromConversations(list)
  108. void fetchBizUserInfos(userNos)
  109. }, 80)
  110. }
  111. async function init() {
  112. if (!import.meta.client) return
  113. if (chat.value) return
  114. if (_initPromise) return _initPromise
  115. _initPromise = (async () => {
  116. const runtimeConfig = useRuntimeConfig()
  117. const sdkAppId = Number(runtimeConfig.public.imAppId)
  118. if (!sdkAppId) throw new Error('Missing runtimeConfig.public.imAppId')
  119. const { default: TencentCloudChat } = await import('@tencentcloud/lite-chat')
  120. _sdk = TencentCloudChat
  121. const instance: ChatSDK = TencentCloudChat.create({ SDKAppID: sdkAppId })
  122. instance.setLogLevel(0)
  123. _onReady = () => {
  124. ready.value = true
  125. }
  126. _onNotReady = () => {
  127. ready.value = false
  128. }
  129. instance.on(TencentCloudChat.EVENT.SDK_READY, _onReady)
  130. instance.on(TencentCloudChat.EVENT.SDK_NOT_READY, _onNotReady)
  131. _onConversationListUpdated = (event) => {
  132. const list = event?.data
  133. if (Array.isArray(list)) {
  134. conversationList.value = list
  135. queueFetchBizUserInfosFromConversations(conversationList.value)
  136. }
  137. }
  138. instance.on(TencentCloudChat.EVENT.CONVERSATION_LIST_UPDATED, _onConversationListUpdated)
  139. chat.value = instance
  140. })().finally(() => {
  141. _initPromise = null
  142. })
  143. return _initPromise
  144. }
  145. async function waitReady(timeoutMs = 15_000) {
  146. if (!import.meta.client) return
  147. await init()
  148. if (!chat.value) throw new Error('Chat SDK is not initialized')
  149. if (ready.value) return
  150. if (!_sdk?.EVENT?.SDK_READY) throw new Error('Chat SDK EVENT is not available')
  151. const instance = chat.value
  152. await new Promise<void>((resolve, reject) => {
  153. const handler = () => {
  154. cleanup()
  155. resolve()
  156. }
  157. const timer = setTimeout(() => {
  158. cleanup()
  159. reject(new Error('Chat SDK not ready (timeout)'))
  160. }, timeoutMs)
  161. const cleanup = () => {
  162. clearTimeout(timer)
  163. instance.off(_sdk!.EVENT.SDK_READY, handler)
  164. }
  165. instance.on(_sdk!.EVENT.SDK_READY, handler)
  166. })
  167. }
  168. async function login(options: ChatLoginOptions) {
  169. if (!import.meta.client) return
  170. const userId = options.userId?.trim()
  171. if (!userId) throw new Error('Missing userId')
  172. await init()
  173. if (!chat.value) throw new Error('Chat SDK is not initialized')
  174. const instance = chat.value
  175. const currentUser = normalizeLoginUser(instance.getLoginUser?.())
  176. // Already logged-in and ready: do nothing.
  177. if (ready.value && currentUser === userId) return
  178. // De-dup concurrent logins for the same user.
  179. if (_loginPromise && _loginUserId === userId) return _loginPromise
  180. _loginUserId = userId
  181. _loginPromise = (async () => {
  182. // If logged in as another user, logout first.
  183. if (currentUser && currentUser !== userId) {
  184. try {
  185. await instance.logout()
  186. }
  187. catch {
  188. // ignore
  189. }
  190. ready.value = false
  191. }
  192. const userSig
  193. = options.userSig
  194. ?? (options.getUserSig ? await options.getUserSig(userId) : await defaultGetUserSig(userId))
  195. await instance.login({ userID: userId, userSig })
  196. await waitReady()
  197. })().finally(() => {
  198. _loginPromise = null
  199. _loginUserId = null
  200. })
  201. return _loginPromise
  202. }
  203. async function logout() {
  204. if (!import.meta.client) return
  205. await init()
  206. if (!chat.value) return
  207. await chat.value.logout()
  208. }
  209. async function getConversationList(options?: GetConversationListOptions) {
  210. if (!import.meta.client) return []
  211. await waitReady()
  212. if (!chat.value) throw new Error('Chat SDK is not initialized')
  213. conversationLoading.value = true
  214. conversationError.value = null
  215. try {
  216. const resolvedOptions
  217. = options ?? (_sdk ? ({ type: _sdk.TYPES.CONV_C2C } as GetConversationListOptions) : undefined)
  218. const res = await chat.value.getConversationList(resolvedOptions)
  219. const list = res?.data?.conversationList ?? res?.data
  220. conversationList.value = Array.isArray(list) ? list : []
  221. queueFetchBizUserInfosFromConversations(conversationList.value)
  222. return conversationList.value
  223. }
  224. catch (e: unknown) {
  225. const err = e as { message?: unknown }
  226. conversationError.value = err?.message ? String(err.message) : String(e)
  227. throw e
  228. }
  229. finally {
  230. conversationLoading.value = false
  231. }
  232. }
  233. async function destroy() {
  234. if (!import.meta.client) return
  235. if (!chat.value) return
  236. const instance = chat.value
  237. if (_sdk?.EVENT && _onReady) instance.off(_sdk.EVENT.SDK_READY, _onReady)
  238. if (_sdk?.EVENT && _onNotReady) instance.off(_sdk.EVENT.SDK_NOT_READY, _onNotReady)
  239. if (_sdk?.EVENT && _onConversationListUpdated) instance.off(_sdk.EVENT.CONVERSATION_LIST_UPDATED, _onConversationListUpdated)
  240. _onReady = null
  241. _onNotReady = null
  242. _onConversationListUpdated = null
  243. await instance.destroy()
  244. chat.value = null
  245. ready.value = false
  246. conversationList.value = []
  247. conversationLoading.value = false
  248. conversationError.value = null
  249. bizUserInfoMap.value = {}
  250. bizUserInfoLoading.value = false
  251. bizUserInfoError.value = null
  252. if (_bizUserInfoFetchTimer) {
  253. clearTimeout(_bizUserInfoFetchTimer)
  254. _bizUserInfoFetchTimer = null
  255. }
  256. }
  257. return {
  258. chat,
  259. ready,
  260. conversationList,
  261. conversationLoading,
  262. conversationError,
  263. bizUserInfoMap,
  264. bizUserInfoLoading,
  265. bizUserInfoError,
  266. init,
  267. login,
  268. logout,
  269. getConversationList,
  270. destroy,
  271. }
  272. }