useChat.ts 12 KB

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