From 62b84c3e8c6b61512fb861f71f7eb6764f4a8428 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 28 Apr 2026 20:11:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=B7=BB=E5=8A=A0=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E7=95=8C=E9=9D=A2=E5=92=8C=E6=96=87=E4=BB=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0SDK=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现完整的聊天界面UI组件,支持文本、图片、视频、音频、文件等多种消息类型 - 集成IM消息收发功能,实现消息气泡显示和用户头像占位符 - 添加媒体文件选择和拍摄功能,支持相册图片、视频及相机拍照录像 - 实现语音录制和播放功能,包含按住说话交互和权限处理 - 添加群组提及功能,支持@用户和提及候选列表显示 - 实现消息回复和引用功能,支持消息长按回复操作 - 添加本地消息搜索功能,支持搜索当前会话的历史消息 - 实现文件上传下载功能,集成FileSDK进行文件传输管理 - 添加应用更新检查功能,集成UpdateSDK支持版本更新 - 实现消息状态显示,包括发送、送达、已读等状态标识 - 添加群组已读人数统计,显示消息在群聊中的阅读情况 - 实现草稿保存和恢复功能,支持断点续聊体验 - 添加连接状态横幅,实时显示IM服务连接状态 - 实现滚动加载更多历史消息,优化大量消息的性能表现 - 添加多媒体文件下载保存功能,支持保存到应用专属目录 --- packages/im/src/ImClient.ts | 12 +++-- packages/im/src/ImSDK.ts | 70 ++++++++++++++++++++----- packages/im/src/db/ConversationModel.ts | 1 + packages/im/src/db/ImDatabase.ts | 43 +++++++++++++-- packages/im/src/db/schema.ts | 3 +- packages/im/src/index.ts | 2 + packages/im/src/types.ts | 1 + 7 files changed, 112 insertions(+), 20 deletions(-) diff --git a/packages/im/src/ImClient.ts b/packages/im/src/ImClient.ts index 2ee0f30..ca5aee3 100644 --- a/packages/im/src/ImClient.ts +++ b/packages/im/src/ImClient.ts @@ -154,7 +154,7 @@ export class ImClient { this.ws.onopen = () => { if (!this.activeToken) { - void _getToken().then(token => { + void _getToken().then((token: string | null) => { this.activeToken = token if (token) { this.sendFrame('CONNECT', { @@ -212,9 +212,12 @@ export class ImClient { const message: ImMessage = this.normalizeMessage(JSON.parse(frame.body)) if (message.chatType === 'GROUP') { this.listeners.forEach(listener => listener.onGroupMessage?.(message)) - return + } else { + this.listeners.forEach(listener => listener.onMessage?.(message)) + } + if (message.msgType === 'NOTIFY') { + this.listeners.forEach(listener => listener.onSystemMessage?.(message)) } - this.listeners.forEach(listener => listener.onMessage?.(message)) return } @@ -279,7 +282,8 @@ export class ImClient { } private generateMessageId(): string { - const cryptoId = globalThis.crypto?.randomUUID?.() + const cryptoApi = globalThis as typeof globalThis & { crypto?: { randomUUID?: () => string } } + const cryptoId = cryptoApi.crypto?.randomUUID?.() return cryptoId ?? `msg_${Date.now()}_${Math.random().toString(16).slice(2)}` } diff --git a/packages/im/src/ImSDK.ts b/packages/im/src/ImSDK.ts index 1af3c64..ee9accb 100644 --- a/packages/im/src/ImSDK.ts +++ b/packages/im/src/ImSDK.ts @@ -18,9 +18,12 @@ import { uploadFile } from './upload' let client: ImClient | null = null let _currentUserId: string | null = null +const draftStore = new Map() +const listenerMap = new WeakMap() function generateMessageId(): string { - const cryptoId = globalThis.crypto?.randomUUID?.() + const cryptoApi = globalThis as typeof globalThis & { crypto?: { randomUUID?: () => string } } + const cryptoId = cryptoApi.crypto?.randomUUID?.() return cryptoId ?? `msg_${Date.now()}_${Math.random().toString(16).slice(2)}` } @@ -763,6 +766,30 @@ export const ImSDK = { }) }, + async searchUsers(keyword: string, size = 20): Promise { + const config = getConfig() + const res = await apiRequest('/api/im/accounts/search', { + params: { + appId: config.appId, + keyword, + size: String(size), + }, + }) + return Array.isArray(res) ? res : (res.content ?? []) + }, + + async searchGroups(keyword: string, size = 20): Promise { + const config = getConfig() + const res = await apiRequest('/api/im/groups/search', { + params: { + appId: config.appId, + keyword, + size: String(size), + }, + }) + return Array.isArray(res) ? res : (res.content ?? []) + }, + async listConversations(): Promise { const config = getConfig() if (ImDatabase.isInitialized()) { @@ -851,14 +878,23 @@ export const ImSDK = { 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, - }, - }) + const draftKey = `${config.appId}:${chatType}:${targetId}` + draftStore.set(draftKey, draft) + if (ImDatabase.isInitialized()) { + await ImDatabase.setDraft(config.appId, targetId, chatType, draft) + } + }, + + async getDraft(targetId: string, chatType: ChatType): Promise { + const config = getConfig() + if (ImDatabase.isInitialized()) { + const draft = await ImDatabase.getConversationDraft(config.appId, targetId, chatType) + if (draft !== null) { + return draft + } + } + const draftKey = `${config.appId}:${chatType}:${targetId}` + return draftStore.get(draftKey) ?? '' }, async deleteConversation(targetId: string, chatType: ChatType): Promise { @@ -892,7 +928,7 @@ export const ImSDK = { }, addListener(listener: ImEventListener): void { - client?.addListener({ + const wrapped: ImEventListener = { ...listener, onMessage: async (msg) => { if (ImDatabase.isInitialized() && _currentUserId) { @@ -906,11 +942,21 @@ export const ImSDK = { } listener.onGroupMessage?.(normalizeMessage(msg)) }, - }) + onSystemMessage: async (msg) => { + if (ImDatabase.isInitialized() && _currentUserId) { + await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) + } + listener.onSystemMessage?.(normalizeMessage(msg)) + }, + } + listenerMap.set(listener, wrapped) + client?.addListener(wrapped) }, removeListener(listener: ImEventListener): void { - client?.removeListener(listener) + const wrapped = listenerMap.get(listener) + client?.removeListener(wrapped ?? listener) + listenerMap.delete(listener) }, subscribeGroup(groupId: string): void { diff --git a/packages/im/src/db/ConversationModel.ts b/packages/im/src/db/ConversationModel.ts index 18e48bc..e4fb260 100644 --- a/packages/im/src/db/ConversationModel.ts +++ b/packages/im/src/db/ConversationModel.ts @@ -14,5 +14,6 @@ export class ConversationModel extends Model { @field('unread_count') unreadCount!: number @field('is_muted') isMuted!: boolean @field('is_pinned') isPinned!: boolean + @field('draft') draft!: string | null @date('updated_at') updatedAt!: Date } diff --git a/packages/im/src/db/ImDatabase.ts b/packages/im/src/db/ImDatabase.ts index 0eb9347..3db4969 100644 --- a/packages/im/src/db/ImDatabase.ts +++ b/packages/im/src/db/ImDatabase.ts @@ -6,6 +6,7 @@ import { MessageModel } from './MessageModel' import type { ImMessage } from '../types' let _db: Database | null = null +const draftStore = new Map() function getDb(): Database { if (!_db) throw new Error('[ImDatabase] Not initialized — call ImDatabase.init() first.') @@ -18,6 +19,10 @@ function conversationId(appId: string, userId: string, targetId: string, chatTyp return `${appId}:S:${a}:${b}` } +function draftKey(appId: string, targetId: string, chatType: string): string { + return `${appId}:${chatType}:${targetId}` +} + export interface MessageSearchParams { keyword?: string toId?: string @@ -98,6 +103,7 @@ export const ImDatabase = { c.unreadCount = msg.fromUserId !== currentUserId ? 1 : 0 c.isMuted = false c.isPinned = false + c.draft = null c.updatedAt = new Date() }) } else { @@ -155,6 +161,23 @@ export const ImDatabase = { } }, + async setDraft(appId: string, targetId: string, chatType: string, draft: string): Promise { + draftStore.set(draftKey(appId, targetId, chatType), draft) + const db = getDb() + const convs = await db + .get('im_conversations') + .query(Q.where('app_id', appId), Q.where('target_id', targetId)) + .fetch() + if (convs.length > 0) { + await db.write(async () => { + await convs[0].update((c: ConversationModel) => { + c.draft = draft + c.updatedAt = new Date() + }) + }) + } + }, + async bulkSave(messages: ImMessage[], currentUserId: string): Promise { for (const msg of messages) { await this.saveMessage(msg, currentUserId) @@ -163,7 +186,7 @@ export const ImDatabase = { async searchMessages(appId: string, params: MessageSearchParams): Promise { const db = getDb() - const conditions: Parameters[0][] = [ + const conditions: any[] = [ Q.where('app_id', appId), ] @@ -191,10 +214,10 @@ export const ImDatabase = { .query(...conditions) if (params.limit !== undefined) { - query = query.extend(Q.take(params.limit)) + query = query.extend(Q.take(params.limit) as any) } if (params.offset !== undefined) { - query = query.extend(Q.skip(params.offset)) + query = query.extend(Q.skip(params.offset) as any) } return query.fetch() @@ -243,6 +266,20 @@ export const ImDatabase = { } }, + async getConversationDraft(appId: string, targetId: string, chatType: string): Promise { + const memoryDraft = draftStore.get(draftKey(appId, targetId, chatType)) + if (memoryDraft !== undefined) { + return memoryDraft + } + const db = getDb() + const convs = await db + .get('im_conversations') + .query(Q.where('app_id', appId), Q.where('target_id', targetId)) + .fetch() + if (convs.length === 0) return null + return convs[0].draft + }, + async deleteConversation(appId: string, targetId: string, chatType: string, currentUserId: string): Promise { const db = getDb() const convId = conversationId(appId, currentUserId, targetId, chatType) diff --git a/packages/im/src/db/schema.ts b/packages/im/src/db/schema.ts index 9904ea1..0c7dbf9 100644 --- a/packages/im/src/db/schema.ts +++ b/packages/im/src/db/schema.ts @@ -1,7 +1,7 @@ import { appSchema, tableSchema } from '@nozbe/watermelondb' export const imDbSchema = appSchema({ - version: 2, + version: 3, tables: [ tableSchema({ name: 'im_conversations', @@ -16,6 +16,7 @@ export const imDbSchema = appSchema({ { name: 'unread_count', type: 'number' }, { name: 'is_muted', type: 'boolean' }, { name: 'is_pinned', type: 'boolean' }, + { name: 'draft', type: 'string', isOptional: true }, { name: 'updated_at', type: 'number' }, ], }), diff --git a/packages/im/src/index.ts b/packages/im/src/index.ts index 6041be1..96afc32 100644 --- a/packages/im/src/index.ts +++ b/packages/im/src/index.ts @@ -5,6 +5,8 @@ import { ImSDK as _ImSDK } from './ImSDK' export const listFriends = (): Promise => _ImSDK.listFriends() export const addFriend = (friendId: string): Promise => _ImSDK.addFriend(friendId) export const removeFriend = (friendId: string): Promise => _ImSDK.removeFriend(friendId) +export const searchUsers = (keyword: string, size?: number): ReturnType => _ImSDK.searchUsers(keyword, size) +export const searchGroups = (keyword: string, size?: number): ReturnType => _ImSDK.searchGroups(keyword, size) export { ImClient } from './ImClient' export { ImDatabase } from './db/ImDatabase' export type { MessageSearchParams } from './db/ImDatabase' diff --git a/packages/im/src/types.ts b/packages/im/src/types.ts index 7f2e0da..1f88c61 100644 --- a/packages/im/src/types.ts +++ b/packages/im/src/types.ts @@ -40,6 +40,7 @@ export interface ImEventListener { onDisconnected?: (reason?: string) => void onMessage?: (msg: ImMessage) => void onGroupMessage?: (msg: ImMessage) => void + onSystemMessage?: (msg: ImMessage) => void onError?: (error: string) => void }