import { apiRequest, _getToken, _saveToken, getConfig, getDeviceInfo } from '@xuqm/rn-common' import { ImClient } from './ImClient' import { ImDatabase } from './db/ImDatabase' import type { MessageSearchParams } from './db/ImDatabase' import type { ChatType, ImEventListener, ImGroup, ImMessage, MsgType } from './types' import { uploadFile } from './upload' let client: ImClient | null = null let _currentUserId: string | null = null export interface ConversationData { targetId: string chatType: 'SINGLE' | 'GROUP' lastMsgContent: string lastMsgType: string lastMsgTime: number unreadCount: number isMuted: boolean isPinned: boolean } async function _syncHistoryForAllConversations(): Promise { const config = getConfig() if (!ImDatabase.isInitialized() || !_currentUserId) return try { // Fetch top 20 conversations from server const res = await apiRequest( '/api/im/conversations', { params: { appId: config.appId, page: '0', size: '20' } }, ) const serverConversations = Array.isArray(res) ? res : (res.content ?? []) // For each conversation, fetch last 30 messages and save locally // serverConversations items may be conversation summaries; we use the common // history endpoint that accepts a targetId. If the response is ImMessage[] // we extract unique toIds. const targetIds: string[] = [] for (const item of serverConversations as any[]) { const targetId: string | undefined = item.targetId ?? item.toId ?? item.id if (targetId && !targetIds.includes(targetId)) { targetIds.push(targetId) } } await Promise.all( targetIds.map(async (targetId) => { try { const msgRes = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>( `/api/im/messages/history/${encodeURIComponent(targetId)}`, { params: { appId: config.appId, page: '0', size: '30' } }, ) const messages = Array.isArray(msgRes) ? msgRes : (msgRes.content ?? []) if (messages.length > 0 && _currentUserId) { await ImDatabase.bulkSave(messages, _currentUserId) } } catch { // Silently skip conversations that fail to sync } }), ) } catch { // Sync is best-effort; do not throw } } 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 if (dbName !== undefined || ImDatabase.isInitialized()) { ImDatabase.init(dbName ?? 'xuqm_im') } client = new ImClient(config.imWsUrl, res.token, config.appId) client.addListener({ onConnected: () => { _syncHistoryForAllConversations().catch(() => {}) }, }) 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 if (dbName !== undefined || ImDatabase.isInitialized()) { ImDatabase.init(dbName ?? 'xuqm_im') } client = new ImClient(config.imWsUrl, token, config.appId) client.addListener({ onConnected: () => { _syncHistoryForAllConversations().catch(() => {}) }, }) 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) client.connect() }, /** * Fetch message history. Reads from local DB first; falls back to server if DB is empty * or not initialized, then caches results locally. */ 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(m => ({ id: m.serverId, appId: m.appId, fromUserId: m.fromUserId, toId: m.toId, chatType: m.chatType as ChatType, msgType: m.msgType as MsgType, content: m.content, status: m.status as ImMessage['status'], mentionedUserIds: m.mentionedUserIds ?? undefined, createdAt: new Date(m.serverCreatedAt).toISOString(), })) } } const res = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>( `/api/im/messages/history/${encodeURIComponent(toId)}`, { params: { appId: config.appId, page: String(page), size: String(size) } }, ) 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 msg = await apiRequest('/api/im/messages/send', { method: 'POST', params: { appId: config.appId }, body: { toId, chatType, msgType, content, mentionedUserIds: mentionedUserIds ?? '' }, }) if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(msg, _currentUserId) } return msg }, 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(msg, _currentUserId) } return msg }, async createGroup(name: string, memberIds: string[]): Promise { const config = getConfig() return apiRequest('/api/im/groups', { method: 'POST', params: { appId: config.appId }, body: { name, memberIds }, }) }, 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 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(m => ({ id: m.serverId, appId: m.appId, fromUserId: m.fromUserId, toId: m.toId, chatType: m.chatType as ChatType, msgType: m.msgType as MsgType, content: m.content, status: m.status as ImMessage['status'], mentionedUserIds: m.mentionedUserIds ?? undefined, createdAt: new Date(m.serverCreatedAt).toISOString(), })) } } const res = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>( `/api/im/messages/history/${encodeURIComponent(groupId)}`, { params: { appId: config.appId, page: String(page), size: String(size) } }, ) const messages = Array.isArray(res) ? res : (res.content ?? []) if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.bulkSave(messages, _currentUserId) } return messages }, /** List all conversations from local DB sorted by last message time. */ async listConversations() { if (!ImDatabase.isInitialized()) return [] const config = getConfig() return ImDatabase.getConversations(config.appId) }, /** * Subscribe to conversation list changes. * Fires immediately with current data, then on every change (new message, read, mute, pin, etc.). * Returns an unsubscribe function. */ subscribeConversations(callback: (conversations: ConversationData[]) => void): () => void { if (!ImDatabase.isInitialized()) { // Return no-op if DB not ready return () => {} } const config = getConfig() return ImDatabase.subscribeConversations(config.appId, (models) => { const data: ConversationData[] = models.map(c => ({ targetId: c.targetId, chatType: c.chatType as 'SINGLE' | 'GROUP', lastMsgContent: c.lastMsgContent ?? '', lastMsgType: c.lastMsgType ?? '', lastMsgTime: c.lastMsgTime, unreadCount: c.unreadCount, isMuted: c.isMuted, isPinned: c.isPinned, })) callback(data) }) }, /** Mark a conversation as read (clears unread count). */ async markRead(targetId: string): Promise { if (!ImDatabase.isInitialized()) return const config = getConfig() await ImDatabase.markRead(config.appId, targetId) }, /** * Fetch last page of messages for each known conversation from server and save locally. * Called automatically after connection is established via login/loginWithToken. */ async syncHistoryForAllConversations(): Promise { await _syncHistoryForAllConversations() }, /** * Search messages in local DB. * appId defaults to the configured appId if not provided. */ 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(msg, _currentUserId) } listener.onMessage?.(msg) }, onGroupMessage: async (msg) => { if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(msg, _currentUserId) } listener.onGroupMessage?.(msg) }, }) }, removeListener(listener: ImEventListener): void { client?.removeListener(listener) }, subscribeGroup(groupId: string): void { client?.subscribeGroup(groupId) }, isConnected(): boolean { return client?.isConnected() ?? false }, /** * Upload a local image and send it as an IMAGE message. * The file-service generates a thumbnail automatically for image/* content. */ 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) }, /** * Upload a local video (with optional pre-generated thumbnail) and send as a VIDEO message. */ 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) }, /** * Upload a local audio file and send as an AUDIO message. */ 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) }, /** * Upload a local file and send as a FILE message. */ 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) }, disconnect(): void { client?.disconnect() client = null _currentUserId = null }, } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- 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 }