import { apiRequest, _getToken, _saveToken, getConfig, getDeviceInfo, setUserId as setCommonUserId, getUserId as getCommonUserId } from '@xuqm/rn-common' import { ImClient } from './ImClient' import { ImDatabase } from './db/ImDatabase' import type { MessageSearchParams } from './db/ImDatabase' import type { BlacklistEntry, ChatType, ConversationData, FriendRequest, GroupJoinRequest, ImEventListener, ImGroup, ImMessage, MsgType, UserProfile, } from './types' import { uploadFile } from './upload' let client: ImClient | null = null let _currentUserId: string | null = null function generateMessageId(): string { const cryptoId = globalThis.crypto?.randomUUID?.() return cryptoId ?? `msg_${Date.now()}_${Math.random().toString(16).slice(2)}` } function normalizeMessage(msg: ImMessage, fallback?: Partial): ImMessage { return { ...fallback, ...msg, appId: msg.appId ?? fallback?.appId ?? getConfig().appId, fromId: msg.fromId ?? fallback?.fromId ?? msg.fromUserId, revoked: msg.revoked ?? msg.status === 'REVOKED', } } function buildOutgoingMessage(params: { messageId?: string toId: string chatType: ChatType msgType: MsgType content: string mentionedUserIds?: string }): ImMessage { const config = getConfig() const fromId = _currentUserId ?? getCommonUserId() ?? '' const id = params.messageId ?? generateMessageId() return { id, appId: config.appId, fromUserId: fromId, fromId, toId: params.toId, chatType: params.chatType, msgType: params.msgType, content: params.content, status: 'SENDING', mentionedUserIds: params.mentionedUserIds, groupReadCount: 0, revoked: false, createdAt: Date.now(), } } async function _syncHistoryForAllConversations(): Promise { if (!ImDatabase.isInitialized() || !_currentUserId) return try { const conversations = await ImSDK.listConversations() const targetIds = new Set(conversations.map(item => item.targetId)) await Promise.all( [...targetIds].map(async (targetId) => { try { const messages = await ImSDK.fetchHistory(targetId, 0, 30) if (messages.length > 0 && _currentUserId) { await ImDatabase.bulkSave(messages, _currentUserId) } } catch { // best-effort sync only } }), ) } catch { // best-effort sync only } } function normalizeConversation(item: { targetId: string chatType: string lastMsgContent?: string | null lastMsgType?: string | null lastMsgTime: number unreadCount: number isMuted: boolean isPinned: boolean }): ConversationData { return { targetId: item.targetId, chatType: item.chatType as ChatType, lastMsgContent: item.lastMsgContent ?? null, lastMsgType: item.lastMsgType ?? null, lastMsgTime: item.lastMsgTime, unreadCount: item.unreadCount, isMuted: item.isMuted, isPinned: item.isPinned, } } function formatQueryDateTime(value?: Date | string | number): string | undefined { if (value === undefined || value === null) return undefined if (typeof value === 'string') return value const date = value instanceof Date ? value : new Date(value) if (Number.isNaN(date.getTime())) return undefined const pad = (n: number) => String(n).padStart(2, '0') return [ date.getFullYear(), '-', pad(date.getMonth() + 1), '-', pad(date.getDate()), 'T', pad(date.getHours()), ':', pad(date.getMinutes()), ':', pad(date.getSeconds()), ].join('') } function resolveMimeTypeFromUri(uri: string, fallback: string): string { const lower = uri.toLowerCase().split('?')[0] if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg' if (lower.endsWith('.png')) return 'image/png' if (lower.endsWith('.gif')) return 'image/gif' if (lower.endsWith('.webp')) return 'image/webp' if (lower.endsWith('.mp4')) return 'video/mp4' if (lower.endsWith('.mov')) return 'video/quicktime' if (lower.endsWith('.m4a')) return 'audio/mp4' if (lower.endsWith('.mp3')) return 'audio/mpeg' if (lower.endsWith('.aac')) return 'audio/aac' if (lower.endsWith('.pdf')) return 'application/pdf' return fallback } export const ImSDK = { /** * Login to IM service. Fetches a token internally and opens the WebSocket connection. * Pass dbName to enable local SQLite message caching (requires @nozbe/watermelondb). */ async login(userId: string, nickname?: string, avatar?: string, dbName?: string): Promise { const config = getConfig() const device = await getDeviceInfo() const res = await apiRequest<{ token: string }>('/api/im/auth/login', { method: 'POST', skipAuth: true, params: { appId: config.appId, userId, deviceId: device.deviceId, platform: device.platform, brand: device.brand, model: device.model, osVersion: device.osVersion, ...(nickname ? { nickname } : {}), ...(avatar ? { avatar } : {}), }, }) await _saveToken(res.token) _currentUserId = userId setCommonUserId(userId) if (dbName !== undefined || ImDatabase.isInitialized()) { ImDatabase.init(dbName ?? 'xuqm_im') } client = new ImClient(config.imWsUrl, res.token, config.appId) client.addListener({ onConnected: () => { _syncHistoryForAllConversations().catch(() => {}) }, }) void client.connect() }, /** * Login with a pre-obtained IM token (e.g. from demo-service). * Sets up the IM connection without calling the auth endpoint. */ async loginWithToken(userId: string, token: string, dbName?: string): Promise { const config = getConfig() await _saveToken(token) _currentUserId = userId setCommonUserId(userId) if (dbName !== undefined || ImDatabase.isInitialized()) { ImDatabase.init(dbName ?? 'xuqm_im') } client = new ImClient(config.imWsUrl, token, config.appId) client.addListener({ onConnected: () => { _syncHistoryForAllConversations().catch(() => {}) }, }) void client.connect() }, async reconnect(): Promise { const config = getConfig() const token = await _getToken() if (!token) throw new Error('[ImSDK] No active session — call login() first.') client = new ImClient(config.imWsUrl, token, config.appId) void client.connect() }, async fetchHistory( toId: string, page = 0, size = 20, ): Promise { const config = getConfig() if (ImDatabase.isInitialized() && page === 0 && _currentUserId) { const local = await ImDatabase.getMessages(config.appId, toId, 'SINGLE', _currentUserId, size) if (local.length > 0) { return local.map(model => ({ id: model.serverId, appId: model.appId, fromUserId: model.fromUserId, toId: model.toId, chatType: model.chatType as ChatType, msgType: model.msgType as MsgType, content: model.content, status: model.status as ImMessage['status'], mentionedUserIds: model.mentionedUserIds ?? undefined, createdAt: model.serverCreatedAt, })) } } return ImSDK.fetchHistoryWithFilters(toId, { page, size }) }, async fetchHistoryWithFilters( toId: string, params: { page?: number size?: number msgType?: MsgType | string keyword?: string startTime?: Date | string | number endTime?: Date | string | number } = {}, ): Promise { const config = getConfig() const res = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>( `/api/im/messages/history/${encodeURIComponent(toId)}`, { params: { appId: config.appId, page: String(params.page ?? 0), size: String(params.size ?? 20), ...(params.msgType ? { msgType: String(params.msgType) } : {}), ...(params.keyword ? { keyword: params.keyword } : {}), ...(formatQueryDateTime(params.startTime) ? { startTime: formatQueryDateTime(params.startTime)! } : {}), ...(formatQueryDateTime(params.endTime) ? { endTime: formatQueryDateTime(params.endTime)! } : {}), }, }, ) const messages = Array.isArray(res) ? res : (res.content ?? []) if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.bulkSave(messages, _currentUserId) } return messages }, async fetchGroupHistory( groupId: string, page = 0, size = 50, ): Promise { const config = getConfig() if (ImDatabase.isInitialized() && page === 0 && _currentUserId) { const local = await ImDatabase.getMessages(config.appId, groupId, 'GROUP', _currentUserId, size) if (local.length > 0) { return local.map(model => ({ id: model.serverId, appId: model.appId, fromUserId: model.fromUserId, toId: model.toId, chatType: model.chatType as ChatType, msgType: model.msgType as MsgType, content: model.content, status: model.status as ImMessage['status'], mentionedUserIds: model.mentionedUserIds ?? undefined, createdAt: model.serverCreatedAt, })) } } return ImSDK.fetchGroupHistoryWithFilters(groupId, { page, size }) }, async fetchGroupHistoryWithFilters( groupId: string, params: { page?: number size?: number msgType?: MsgType | string keyword?: string startTime?: Date | string | number endTime?: Date | string | number } = {}, ): Promise { const config = getConfig() const res = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>( `/api/im/messages/group-history/${encodeURIComponent(groupId)}`, { params: { appId: config.appId, page: String(params.page ?? 0), size: String(params.size ?? 50), ...(params.msgType ? { msgType: String(params.msgType) } : {}), ...(params.keyword ? { keyword: params.keyword } : {}), ...(formatQueryDateTime(params.startTime) ? { startTime: formatQueryDateTime(params.startTime)! } : {}), ...(formatQueryDateTime(params.endTime) ? { endTime: formatQueryDateTime(params.endTime)! } : {}), }, }, ) const messages = Array.isArray(res) ? res : (res.content ?? []) if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.bulkSave(messages, _currentUserId) } return messages }, async sendMessage( toId: string, chatType: ChatType, msgType: MsgType, content: string, mentionedUserIds?: string, ): Promise { const config = getConfig() const outgoing = buildOutgoingMessage({ toId, chatType, msgType, content, mentionedUserIds, }) try { const msg = await apiRequest('/api/im/messages/send', { method: 'POST', params: { appId: config.appId }, body: { toId, chatType, msgType, content, mentionedUserIds: mentionedUserIds ?? '', messageId: outgoing.id, }, }) const finalMsg = normalizeMessage(msg, outgoing) if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(finalMsg, _currentUserId) } return finalMsg } catch (error) { const failed = { ...outgoing, status: 'FAILED' as const } if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(failed, _currentUserId) } return failed } }, async sendTextMessage( toId: string, chatType: ChatType, content: string, mentionedUserIds?: string, ): Promise { return ImSDK.sendMessage(toId, chatType, 'TEXT', content, mentionedUserIds) }, async sendImageMessage( toId: string, chatType: ChatType, localUri: string, width?: number, height?: number, ): Promise { const filename = localUri.split('/').pop() ?? 'image.jpg' const mimeType = resolveMimeTypeFromUri(localUri, 'image/jpeg') const result = await uploadFile(localUri, mimeType, filename) const content = JSON.stringify({ url: result.url, thumbnailUrl: result.thumbnailUrl, width: width ?? 0, height: height ?? 0, size: result.size, name: result.originalName, }) return ImSDK.sendMessage(toId, chatType, 'IMAGE', content) }, async sendVideoMessage( toId: string, chatType: ChatType, localUri: string, thumbnailUri?: string, duration?: number, ): Promise { const filename = localUri.split('/').pop() ?? 'video.mp4' const mimeType = resolveMimeTypeFromUri(localUri, 'video/mp4') const result = await uploadFile(localUri, mimeType, filename, thumbnailUri) const content = JSON.stringify({ url: result.url, thumbnailUrl: result.thumbnailUrl, duration: duration ?? 0, size: result.size, width: 0, height: 0, }) return ImSDK.sendMessage(toId, chatType, 'VIDEO', content) }, async sendAudioMessage( toId: string, chatType: ChatType, localUri: string, duration: number, ): Promise { const filename = localUri.split('/').pop() ?? 'audio.m4a' const mimeType = resolveMimeTypeFromUri(localUri, 'audio/mp4') const result = await uploadFile(localUri, mimeType, filename) const content = JSON.stringify({ url: result.url, duration, size: result.size, }) return ImSDK.sendMessage(toId, chatType, 'AUDIO', content) }, async sendFileMessage( toId: string, chatType: ChatType, localUri: string, filename: string, size: number, ): Promise { const mimeType = resolveMimeTypeFromUri(localUri, 'application/octet-stream') const result = await uploadFile(localUri, mimeType, filename) const content = JSON.stringify({ url: result.url, name: filename, size, mimeType: result.mimeType, }) return ImSDK.sendMessage(toId, chatType, 'FILE', content) }, async sendNotifyMessage( toId: string, chatType: ChatType, title: string, content: string, ): Promise { return ImSDK.sendMessage( toId, chatType, 'NOTIFY', JSON.stringify({ title, content }), ) }, async sendQuoteMessage( toId: string, chatType: ChatType, quotedMsgId: string, quotedContent: string, text: string, ): Promise { return ImSDK.sendMessage( toId, chatType, 'QUOTE', JSON.stringify({ quotedMsgId, quotedContent, text }), ) }, async sendMergeMessage( toId: string, chatType: ChatType, title: string, msgList: string[], ): Promise { return ImSDK.sendMessage( toId, chatType, 'MERGE', JSON.stringify({ title, msgList }), ) }, async sendCallAudioMessage( toId: string, chatType: ChatType, action: string, ): Promise { return ImSDK.sendMessage(toId, chatType, 'CALL_AUDIO', JSON.stringify({ action })) }, async sendCallVideoMessage( toId: string, chatType: ChatType, action: string, ): Promise { return ImSDK.sendMessage(toId, chatType, 'CALL_VIDEO', JSON.stringify({ action })) }, async revokeMessage(messageId: string): Promise { const config = getConfig() const msg = await apiRequest(`/api/im/messages/${encodeURIComponent(messageId)}/revoke`, { method: 'POST', params: { appId: config.appId }, }) if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) } return msg }, async createGroup(name: string, memberIds: string[], groupType = 'WORK'): Promise { const config = getConfig() return apiRequest('/api/im/groups', { method: 'POST', params: { appId: config.appId }, body: { name, memberIds, groupType }, }) }, async listGroups(): Promise { const config = getConfig() const res = await apiRequest('/api/im/groups', { params: { appId: config.appId }, }) return Array.isArray(res) ? res : (res.content ?? []) }, async listPublicGroups(keyword?: string): Promise { const config = getConfig() const res = await apiRequest('/api/im/groups/public', { params: { appId: config.appId, ...(keyword ? { keyword } : {}), }, }) return Array.isArray(res) ? res : (res.content ?? []) }, async getGroupInfo(groupId: string): Promise { return apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}`) }, async updateGroupInfo(groupId: string, name?: string, announcement?: string): Promise { return apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}`, { method: 'PUT', body: { name, announcement }, }) }, async addGroupMember(groupId: string, userId: string): Promise { return apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/members`, { method: 'POST', body: { userId }, }) }, async removeGroupMember(groupId: string, targetUserId: string): Promise { return apiRequest( `/api/im/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(targetUserId)}`, { method: 'DELETE' }, ) }, async leaveGroup(groupId: string): Promise { if (!_currentUserId) throw new Error('[ImSDK] Not logged in') return ImSDK.removeGroupMember(groupId, _currentUserId) }, async setGroupRole(groupId: string, userId: string, role: string): Promise { return apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/roles`, { method: 'POST', body: { userId, role }, }) }, async muteGroupMember(groupId: string, userId: string, minutes: number): Promise { return apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/mute`, { method: 'POST', body: { userId, minutes }, }) }, async dismissGroup(groupId: string): Promise { await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}`, { method: 'DELETE' }) }, async sendGroupJoinRequest(groupId: string, remark?: string): Promise { const config = getConfig() return apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/join-requests`, { method: 'POST', params: { appId: config.appId, ...(remark ? { remark } : {}), }, }) }, async listGroupJoinRequests(groupId: string): Promise { const config = getConfig() const res = await apiRequest( `/api/im/groups/${encodeURIComponent(groupId)}/join-requests`, { params: { appId: config.appId }, }, ) return Array.isArray(res) ? res : (res.content ?? []) }, async acceptGroupJoinRequest(groupId: string, requestId: string): Promise { const config = getConfig() return apiRequest( `/api/im/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/accept`, { method: 'POST', params: { appId: config.appId }, }, ) }, async rejectGroupJoinRequest(groupId: string, requestId: string): Promise { const config = getConfig() return apiRequest( `/api/im/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', params: { appId: config.appId }, }, ) }, async listFriends(): Promise { const config = getConfig() const res = await apiRequest<{ data?: string[] } | string[]>('/api/im/friends', { params: { appId: config.appId }, }) return Array.isArray(res) ? res : (res.data ?? []) }, async addFriend(friendId: string): Promise { const config = getConfig() await apiRequest('/api/im/friends', { method: 'POST', params: { appId: config.appId, friendId }, }) }, async removeFriend(friendId: string): Promise { const config = getConfig() await apiRequest(`/api/im/friends/${encodeURIComponent(friendId)}`, { method: 'DELETE', params: { appId: config.appId }, }) }, async listFriendRequests(direction: 'incoming' | 'outgoing' = 'incoming'): Promise { const config = getConfig() const res = await apiRequest('/api/im/friend-requests', { params: { appId: config.appId, direction, }, }) return Array.isArray(res) ? res : (res.content ?? []) }, async sendFriendRequest(toUserId: string, remark?: string): Promise { const config = getConfig() return apiRequest('/api/im/friend-requests', { method: 'POST', params: { appId: config.appId, toUserId, ...(remark ? { remark } : {}), }, }) }, async acceptFriendRequest(requestId: string): Promise { const config = getConfig() return apiRequest(`/api/im/friend-requests/${encodeURIComponent(requestId)}/accept`, { method: 'POST', params: { appId: config.appId }, }) }, async rejectFriendRequest(requestId: string): Promise { const config = getConfig() return apiRequest(`/api/im/friend-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', params: { appId: config.appId }, }) }, async listBlacklist(): Promise { const config = getConfig() const res = await apiRequest('/api/im/blacklist', { params: { appId: config.appId }, }) return Array.isArray(res) ? res : (res.content ?? []) }, async addToBlacklist(blockedUserId: string): Promise { const config = getConfig() return apiRequest('/api/im/blacklist', { method: 'POST', params: { appId: config.appId, blockedUserId }, }) }, async removeFromBlacklist(blockedUserId: string): Promise { const config = getConfig() await apiRequest('/api/im/blacklist', { method: 'DELETE', params: { appId: config.appId, blockedUserId }, }) }, async getProfile(userId: string): Promise { const config = getConfig() return apiRequest(`/api/im/accounts/${encodeURIComponent(userId)}`, { params: { appId: config.appId }, }) }, async updateProfile( userId: string, nickname?: string, avatar?: string, gender?: string, ): Promise { const config = getConfig() return apiRequest(`/api/im/accounts/${encodeURIComponent(userId)}`, { method: 'PUT', params: { appId: config.appId, ...(nickname ? { nickname } : {}), ...(avatar ? { avatar } : {}), ...(gender ? { gender } : {}), }, }) }, async listConversations(): Promise { const config = getConfig() if (ImDatabase.isInitialized()) { const models = await ImDatabase.getConversations(config.appId) if (models.length > 0) { return models.map(model => normalizeConversation({ targetId: model.targetId, chatType: model.chatType, lastMsgContent: model.lastMsgContent, lastMsgType: model.lastMsgType, lastMsgTime: model.lastMsgTime, unreadCount: model.unreadCount, isMuted: model.isMuted, isPinned: model.isPinned, })) } } const res = await apiRequest('/api/im/conversations', { params: { appId: config.appId }, }) const conversations = Array.isArray(res) ? res : (res.content ?? []) return conversations.map(normalizeConversation) }, subscribeConversations(callback: (conversations: ConversationData[]) => void): () => void { if (!ImDatabase.isInitialized()) { return () => {} } const config = getConfig() return ImDatabase.subscribeConversations(config.appId, (models) => { const data: ConversationData[] = models.map(c => normalizeConversation({ targetId: c.targetId, chatType: c.chatType, lastMsgContent: c.lastMsgContent, lastMsgType: c.lastMsgType, lastMsgTime: c.lastMsgTime, unreadCount: c.unreadCount, isMuted: c.isMuted, isPinned: c.isPinned, })) callback(data) }) }, async markRead(targetId: string, chatType: ChatType = 'SINGLE'): Promise { const config = getConfig() await apiRequest(`/api/im/conversations/${encodeURIComponent(targetId)}/read`, { method: 'PUT', params: { appId: config.appId, chatType }, }) if (ImDatabase.isInitialized()) { await ImDatabase.markRead(config.appId, targetId) } }, async setConversationMuted(targetId: string, chatType: ChatType, muted: boolean): Promise { const config = getConfig() await apiRequest(`/api/im/conversations/${encodeURIComponent(targetId)}/muted`, { method: 'PUT', params: { appId: config.appId, chatType, muted: String(muted), }, }) if (ImDatabase.isInitialized()) { await ImDatabase.setConversationMuted(config.appId, targetId, muted) } }, async setConversationPinned(targetId: string, chatType: ChatType, pinned: boolean): Promise { const config = getConfig() await apiRequest(`/api/im/conversations/${encodeURIComponent(targetId)}/pinned`, { method: 'PUT', params: { appId: config.appId, chatType, pinned: String(pinned), }, }) if (ImDatabase.isInitialized()) { await ImDatabase.setConversationPinned(config.appId, targetId, pinned) } }, async setDraft(targetId: string, chatType: ChatType, draft: string): Promise { const config = getConfig() await apiRequest(`/api/im/conversations/${encodeURIComponent(targetId)}/draft`, { method: 'PUT', params: { appId: config.appId, chatType, draft, }, }) }, async deleteConversation(targetId: string, chatType: ChatType): Promise { const config = getConfig() await apiRequest(`/api/im/conversations/${encodeURIComponent(targetId)}`, { method: 'DELETE', params: { appId: config.appId, chatType, }, }) if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.deleteConversation(config.appId, targetId, chatType, _currentUserId) } }, async getTotalUnreadCount(): Promise { const conversations = await ImSDK.listConversations() return conversations.reduce((sum, conversation) => sum + conversation.unreadCount, 0) }, async syncHistoryForAllConversations(): Promise { await _syncHistoryForAllConversations() }, async searchMessages(params: MessageSearchParams & { appId?: string }) { if (!ImDatabase.isInitialized()) return [] const config = getConfig() const { appId, ...rest } = params return ImDatabase.searchMessages(appId ?? config.appId, rest) }, addListener(listener: ImEventListener): void { client?.addListener({ ...listener, onMessage: async (msg) => { if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) } listener.onMessage?.(normalizeMessage(msg)) }, onGroupMessage: async (msg) => { if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) } listener.onGroupMessage?.(normalizeMessage(msg)) }, }) }, removeListener(listener: ImEventListener): void { client?.removeListener(listener) }, subscribeGroup(groupId: string): void { client?.subscribeGroup(groupId) }, unsubscribeGroup(groupId: string): void { client?.unsubscribeGroup(groupId) }, isConnected(): boolean { return client?.isConnected() ?? false }, disconnect(): void { client?.disconnect() client = null _currentUserId = null setCommonUserId(null) }, }