From 3d488250ee1c4539f6a957eeffe8dcff8e22e75a Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Wed, 29 Apr 2026 00:37:10 +0800 Subject: [PATCH] feat(rn): add xuqm_release Node.js script, expand IM SDK with friends/groups/conversations Co-Authored-By: Claude Sonnet 4.6 --- packages/im/src/ImSDK.ts | 212 ++++++++++++++++++++++- packages/im/src/db/ImDatabase.ts | 38 ++++ packages/im/src/db/MessageModel.ts | 1 + packages/im/src/db/schema.ts | 3 +- packages/im/src/types.ts | 1 + packages/update/scripts/xuqm_release.mjs | 157 +++++++++++++++++ src/im/imSDK.ts | 9 + src/im/types.ts | 1 + src/index.ts | 2 +- 9 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 packages/update/scripts/xuqm_release.mjs diff --git a/packages/im/src/ImSDK.ts b/packages/im/src/ImSDK.ts index c16eef8..69c6b76 100644 --- a/packages/im/src/ImSDK.ts +++ b/packages/im/src/ImSDK.ts @@ -12,6 +12,7 @@ import type { ImGroup, ImMessage, MsgType, + PageResult, UserProfile, } from './types' import { uploadFile } from './upload' @@ -20,6 +21,10 @@ let client: ImClient | null = null let _currentUserId: string | null = null const draftStore = new Map() const listenerMap = new WeakMap() +let conversationMemory: ConversationData[] = [] +const conversationListeners = new Set<(conversations: ConversationData[]) => void>() +const messageConversationMemory = new Map() +const conversationLastMessageMemory = new Map() function generateMessageId(): string { const cryptoApi = globalThis as typeof globalThis & { crypto?: { randomUUID?: () => string } } @@ -110,6 +115,142 @@ function normalizeConversation(item: { } } +function sortConversations(conversations: ConversationData[]): ConversationData[] { + return [...conversations].sort((a, b) => { + if (a.isPinned !== b.isPinned) return Number(b.isPinned) - Number(a.isPinned) + return b.lastMsgTime - a.lastMsgTime + }) +} + +function conversationKey(targetId: string, chatType: ChatType): string { + return `${chatType}:${targetId}` +} + +function emitConversationMemory(): void { + const snapshot = sortConversations(conversationMemory) + conversationMemory = snapshot + conversationListeners.forEach(listener => listener(snapshot)) +} + +function upsertConversationMemory(conversation: ConversationData): void { + const index = conversationMemory.findIndex( + item => item.targetId === conversation.targetId && item.chatType === conversation.chatType, + ) + if (index >= 0) { + const existing = conversationMemory[index] + conversationMemory[index] = { + ...existing, + ...conversation, + unreadCount: conversation.unreadCount, + isMuted: conversation.isMuted, + isPinned: conversation.isPinned, + } + } else { + conversationMemory = [...conversationMemory, conversation] + } + emitConversationMemory() +} + +function markConversationReadMemory(targetId: string, chatType: ChatType): void { + conversationMemory = conversationMemory.map(item => + item.targetId === targetId && item.chatType === chatType + ? { ...item, unreadCount: 0 } + : item, + ) + emitConversationMemory() +} + +function setConversationMutedMemory(targetId: string, chatType: ChatType, muted: boolean): void { + conversationMemory = conversationMemory.map(item => + item.targetId === targetId && item.chatType === chatType + ? { ...item, isMuted: muted } + : item, + ) + emitConversationMemory() +} + +function setConversationPinnedMemory(targetId: string, chatType: ChatType, pinned: boolean): void { + conversationMemory = conversationMemory.map(item => + item.targetId === targetId && item.chatType === chatType + ? { ...item, isPinned: pinned } + : item, + ) + emitConversationMemory() +} + +function deleteConversationMemory(targetId: string, chatType: ChatType): void { + conversationMemory = conversationMemory.filter( + item => !(item.targetId === targetId && item.chatType === chatType), + ) + emitConversationMemory() +} + +function applyMessageToMemory(message: ImMessage): void { + const normalized = normalizeMessage(message) + const key = conversationKey(normalized.toId, normalized.chatType) + messageConversationMemory.set(normalized.id, { targetId: normalized.toId, chatType: normalized.chatType }) + conversationLastMessageMemory.set(key, normalized.id) + const index = conversationMemory.findIndex( + item => item.targetId === normalized.toId && item.chatType === normalized.chatType, + ) + const revoked = normalized.revoked || normalized.status === 'REVOKED' || normalized.msgType === 'REVOKED' + const lastMsgContent = revoked ? '消息已撤回' : normalized.content + const unreadDelta = !revoked && normalized.fromId !== _currentUserId ? 1 : 0 + const nextConversation: ConversationData = { + targetId: normalized.toId, + chatType: normalized.chatType, + lastMsgContent, + lastMsgType: revoked ? 'REVOKED' : normalized.msgType, + lastMsgTime: normalized.createdAt, + unreadCount: unreadDelta, + isMuted: index >= 0 ? conversationMemory[index].isMuted : false, + isPinned: index >= 0 ? conversationMemory[index].isPinned : false, + } + if (index >= 0) { + const current = conversationMemory[index] + conversationMemory[index] = { + ...current, + ...nextConversation, + unreadCount: unreadDelta > 0 ? current.unreadCount + unreadDelta : current.unreadCount, + } + } else { + conversationMemory = [...conversationMemory, nextConversation] + } + emitConversationMemory() +} + +function applyRevokeToMemory(messageId: string): void { + const messageConversation = messageConversationMemory.get(messageId) + if (!messageConversation) return + const key = conversationKey(messageConversation.targetId, messageConversation.chatType) + if (conversationLastMessageMemory.get(key) !== messageId) return + conversationMemory = conversationMemory.map(item => + item.targetId === messageConversation.targetId && item.chatType === messageConversation.chatType + ? { ...item, lastMsgContent: '消息已撤回', lastMsgType: 'REVOKED' } + : item, + ) + emitConversationMemory() +} + +function applyEditToMemory(message: ImMessage): void { + const normalized = normalizeMessage(message) + const messageConversation = messageConversationMemory.get(normalized.id) + if (!messageConversation) return + const key = conversationKey(messageConversation.targetId, messageConversation.chatType) + if (conversationLastMessageMemory.get(key) !== normalized.id) return + conversationMemory = conversationMemory.map(item => + item.targetId === messageConversation.targetId && item.chatType === messageConversation.chatType + ? { + ...item, + lastMsgContent: normalized.content, + lastMsgType: normalized.msgType, + lastMsgTime: normalized.createdAt, + } + : item, + ) + emitConversationMemory() +} + function formatQueryDateTime(value?: Date | string | number): string | undefined { if (value === undefined || value === null) return undefined if (typeof value === 'string') return value @@ -236,7 +377,9 @@ export const ImSDK = { content: model.content, status: model.status as ImMessage['status'], mentionedUserIds: model.mentionedUserIds ?? undefined, + revoked: model.status === 'REVOKED', createdAt: model.serverCreatedAt, + editedAt: model.editedAt ?? undefined, })) } } @@ -295,7 +438,9 @@ export const ImSDK = { content: model.content, status: model.status as ImMessage['status'], mentionedUserIds: model.mentionedUserIds ?? undefined, + revoked: model.status === 'REVOKED', createdAt: model.serverCreatedAt, + editedAt: model.editedAt ?? undefined, })) } } @@ -398,12 +543,16 @@ export const ImSDK = { const finalMsg = normalizeMessage(msg, outgoing) if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(finalMsg, _currentUserId) + } else { + applyMessageToMemory(finalMsg) } return finalMsg } catch (error) { const failed = { ...outgoing, status: 'FAILED' as const } if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(failed, _currentUserId) + } else { + applyMessageToMemory(failed) } return failed } @@ -562,6 +711,10 @@ export const ImSDK = { }) if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) + await ImDatabase.revokeMessage(config.appId, messageId) + } else { + applyMessageToMemory(msg) + applyRevokeToMemory(messageId) } return msg }, @@ -575,6 +728,8 @@ export const ImSDK = { }) if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) + } else { + applyEditToMemory(msg) } return msg }, @@ -611,6 +766,26 @@ export const ImSDK = { return apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}`) }, + async listGroupMembers(groupId: string): Promise { + const config = getConfig() + const res = await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/members`, { + params: { appId: config.appId }, + }) + return Array.isArray(res) ? res : (res.content ?? []) + }, + + async searchGroupMembers(groupId: string, keyword: string, size = 20): Promise { + const config = getConfig() + const res = await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/members/search`, { + params: { + appId: config.appId, + keyword, + size: String(size), + }, + }) + return Array.isArray(res) ? res : (res.content ?? []) + }, + async updateGroupInfo(groupId: string, name?: string, announcement?: string): Promise { return apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}`, { method: 'PUT', @@ -840,7 +1015,7 @@ export const ImSDK = { if (ImDatabase.isInitialized()) { const models = await ImDatabase.getConversations(config.appId) if (models.length > 0) { - return models.map(model => normalizeConversation({ + const conversations = models.map(model => normalizeConversation({ targetId: model.targetId, chatType: model.chatType, lastMsgContent: model.lastMsgContent, @@ -850,6 +1025,9 @@ export const ImSDK = { isMuted: model.isMuted, isPinned: model.isPinned, })) + conversationMemory = conversations + emitConversationMemory() + return conversations } } @@ -857,12 +1035,19 @@ export const ImSDK = { params: { appId: config.appId }, }) const conversations = Array.isArray(res) ? res : (res.content ?? []) - return conversations.map(normalizeConversation) + const normalized = conversations.map(normalizeConversation) + conversationMemory = normalized + emitConversationMemory() + return normalized }, subscribeConversations(callback: (conversations: ConversationData[]) => void): () => void { if (!ImDatabase.isInitialized()) { - return () => {} + conversationListeners.add(callback) + callback(sortConversations(conversationMemory)) + return () => { + conversationListeners.delete(callback) + } } const config = getConfig() return ImDatabase.subscribeConversations(config.appId, (models) => { @@ -888,6 +1073,8 @@ export const ImSDK = { }) if (ImDatabase.isInitialized()) { await ImDatabase.markRead(config.appId, targetId) + } else { + markConversationReadMemory(targetId, chatType) } }, @@ -903,6 +1090,8 @@ export const ImSDK = { }) if (ImDatabase.isInitialized()) { await ImDatabase.setConversationMuted(config.appId, targetId, muted) + } else { + setConversationMutedMemory(targetId, chatType, muted) } }, @@ -918,6 +1107,8 @@ export const ImSDK = { }) if (ImDatabase.isInitialized()) { await ImDatabase.setConversationPinned(config.appId, targetId, pinned) + } else { + setConversationPinnedMemory(targetId, chatType, pinned) } }, @@ -953,6 +1144,8 @@ export const ImSDK = { }) if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.deleteConversation(config.appId, targetId, chatType, _currentUserId) + } else { + deleteConversationMemory(targetId, chatType) } }, @@ -978,28 +1171,41 @@ export const ImSDK = { onMessage: async (msg) => { if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) + } else { + applyMessageToMemory(normalizeMessage(msg)) } listener.onMessage?.(normalizeMessage(msg)) }, onGroupMessage: async (msg) => { if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) + } else { + applyMessageToMemory(normalizeMessage(msg)) } listener.onGroupMessage?.(normalizeMessage(msg)) }, onSystemMessage: async (msg) => { if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) + } else { + applyMessageToMemory(normalizeMessage(msg)) } listener.onSystemMessage?.(normalizeMessage(msg)) }, onRead: async (msg) => { if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) + } else { + applyMessageToMemory(normalizeMessage(msg)) } listener.onRead?.(normalizeMessage(msg)) }, onRevoke: async (data) => { + if (ImDatabase.isInitialized()) { + await ImDatabase.revokeMessage(getConfig().appId, data.msgId) + } else { + applyRevokeToMemory(data.msgId) + } listener.onRevoke?.(data) }, } diff --git a/packages/im/src/db/ImDatabase.ts b/packages/im/src/db/ImDatabase.ts index 3db4969..6fee9cc 100644 --- a/packages/im/src/db/ImDatabase.ts +++ b/packages/im/src/db/ImDatabase.ts @@ -74,12 +74,14 @@ export const ImDatabase = { m.status = msg.status m.mentionedUserIds = msg.mentionedUserIds ?? null m.serverCreatedAt = new Date(msg.createdAt).getTime() + m.editedAt = msg.editedAt ?? null m.syncedAt = now }) } else { await existing[0].update((m: MessageModel) => { m.status = msg.status m.content = msg.content + m.editedAt = msg.editedAt ?? m.editedAt m.syncedAt = now }) } @@ -124,6 +126,42 @@ export const ImDatabase = { }) }, + async revokeMessage(appId: string, messageId: string): Promise { + const db = getDb() + const messages = await db + .get('im_messages') + .query(Q.where('app_id', appId), Q.where('server_id', messageId)) + .fetch() + if (messages.length === 0) return + + const message = messages[0] + const now = Date.now() + const revokedContent = '消息已撤回' + + await db.write(async () => { + await message.update((m: MessageModel) => { + m.status = 'REVOKED' + m.content = revokedContent + m.syncedAt = now + }) + + const conversations = await db + .get('im_conversations') + .query(Q.where('app_id', appId), Q.where('target_id', message.toId)) + .fetch() + + if (conversations.length > 0) { + await conversations[0].update((c: ConversationModel) => { + if (c.lastMsgId === messageId) { + c.lastMsgContent = revokedContent + c.lastMsgType = 'REVOKED' + c.updatedAt = new Date() + } + }) + } + }) + }, + async getMessages(appId: string, targetId: string, chatType: string, currentUserId: string, limit = 50): Promise { const db = getDb() const convId = conversationId(appId, currentUserId, targetId, chatType) diff --git a/packages/im/src/db/MessageModel.ts b/packages/im/src/db/MessageModel.ts index 1900506..e3a7ea1 100644 --- a/packages/im/src/db/MessageModel.ts +++ b/packages/im/src/db/MessageModel.ts @@ -15,5 +15,6 @@ export class MessageModel extends Model { @field('status') status!: string @field('mentioned_user_ids') mentionedUserIds!: string | null @field('server_created_at') serverCreatedAt!: number + @field('edited_at') editedAt!: number | null @field('synced_at') syncedAt!: number } diff --git a/packages/im/src/db/schema.ts b/packages/im/src/db/schema.ts index 0c7dbf9..b801077 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: 3, + version: 4, tables: [ tableSchema({ name: 'im_conversations', @@ -34,6 +34,7 @@ export const imDbSchema = appSchema({ { name: 'status', type: 'string' }, { name: 'mentioned_user_ids', type: 'string', isOptional: true }, { name: 'server_created_at', type: 'number' }, + { name: 'edited_at', type: 'number', isOptional: true }, { name: 'synced_at', type: 'number' }, ], }), diff --git a/packages/im/src/types.ts b/packages/im/src/types.ts index b04bd71..f4072c1 100644 --- a/packages/im/src/types.ts +++ b/packages/im/src/types.ts @@ -33,6 +33,7 @@ export interface ImMessage { groupReadCount?: number revoked?: boolean createdAt: number + editedAt?: number | null } export interface ImEventListener { diff --git a/packages/update/scripts/xuqm_release.mjs b/packages/update/scripts/xuqm_release.mjs new file mode 100644 index 0000000..c31fe04 --- /dev/null +++ b/packages/update/scripts/xuqm_release.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +/** + * XuqmGroup Update Service — React Native Bundle Release Script + * + * Platform-native approach: runs via Node.js (always available in RN projects). + * Add to your app's package.json scripts: + * "xuqm:release": "node node_modules/@xuqm/update/scripts/xuqm_release.mjs" + * + * Config: xuqm.config.json in the project root + * { + * "serverUrl": "https://update.dev.xuqinmin.com", + * "appId": "your-app-id", + * "apiToken": "your-api-token", + * "rn": { + * "modules": [ + * { "moduleId": "main", "entryFile": "index.js", "platforms": ["android", "ios"] } + * ], + * "bundleOutputDir": "/tmp/xuqm_rn_bundle" + * } + * } + * + * Dependencies: none beyond Node.js built-ins (uses native fetch + node:child_process) + * Minimum Node.js: 18 (built-in fetch) + */ + +import { execSync } from 'node:child_process' +import { createReadStream, mkdirSync, existsSync, readFileSync } from 'node:fs' +import { createInterface } from 'node:readline' +import path from 'node:path' + +// ── Config ───────────────────────────────────────────────────────────────── + +const CONFIG_FILE = process.env.XUQM_CONFIG_FILE ?? 'xuqm.config.json' +if (!existsSync(CONFIG_FILE)) { + console.error(`[xuqm] Config not found: ${CONFIG_FILE}`) + process.exit(1) +} +const cfg = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) +const { serverUrl, appId, apiToken, rn = {} } = cfg +if (!serverUrl || !appId || !apiToken) { + console.error('[xuqm] serverUrl / appId / apiToken are required in config') + process.exit(1) +} +const modules = rn.modules ?? [{ moduleId: 'main', entryFile: 'index.js', platforms: ['android', 'ios'] }] +const bundleOutputDir = rn.bundleOutputDir ?? '/tmp/xuqm_rn_bundle' + +// ── Helpers ───────────────────────────────────────────────────────────────── + +const rl = createInterface({ input: process.stdin, output: process.stdout }) +const ask = (q) => new Promise(res => rl.question(`\x1b[36m${q}\x1b[0m`, res)) +const confirm = async (q) => /^y/i.test(((await ask(`${q} [y/N]: `)) ?? '').trim()) + +function apiHeaders() { return { Authorization: `Bearer ${apiToken}` } } + +async function apiFetch(path, opts = {}) { + const res = await fetch(`${serverUrl}${path}`, { headers: apiHeaders(), ...opts }) + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`) + return res.json() +} + +// Multipart upload using FormData (Node 18+ has built-in FormData) +async function uploadBundle(moduleId, platform, bundleFile, version, minVersion, note) { + const form = new FormData() + const blob = new Blob([readFileSync(bundleFile)], { type: 'application/octet-stream' }) + form.append('appId', appId) + form.append('moduleId', moduleId) + form.append('platform', platform.toUpperCase()) + form.append('version', version) + form.append('minCommonVersion', minVersion) + form.append('note', note) + form.append('bundle', blob, path.basename(bundleFile)) + + const res = await fetch(`${serverUrl}/api/v1/rn/upload`, { + method: 'POST', + headers: apiHeaders(), + body: form, + }) + if (!res.ok) throw new Error(`Upload failed: ${await res.text()}`) + return res.json() +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + console.log('\x1b[36m=== XuqmGroup React Native Release ===\x1b[0m\n') + + // ── 1. Local version from package.json ────────────────────────────────── + const pkg = JSON.parse(readFileSync('package.json', 'utf8')) + let localVersion = pkg.version ?? '' + console.log(`Local version (package.json): \x1b[32m${localVersion}\x1b[0m`) + + // ── 2. Server latest ───────────────────────────────────────────────────── + let serverVersion = 'none' + try { + const resp = await apiFetch(`/api/v1/rn/list?appId=${appId}`) + const published = (resp.data ?? []).filter(x => x.publishStatus === 'PUBLISHED') + serverVersion = published[0]?.version ?? 'none' + } catch { /* no bundles yet */ } + console.log(`Server latest: \x1b[33m${serverVersion}\x1b[0m`) + + // ── 3. Validate version ─────────────────────────────────────────────────── + if (localVersion && serverVersion !== 'none' && localVersion <= serverVersion) { + console.warn(`\x1b[33m⚠ Local (${localVersion}) ≤ server (${serverVersion})\x1b[0m`) + const cont = await confirm('Continue anyway?') + if (!cont) { localVersion = await ask('New version string: ') } + } + if (!localVersion) localVersion = await ask('Version string (e.g. 1.2.3): ') + + // ── 4. Options ──────────────────────────────────────────────────────────── + const note = await ask('Release notes: ') + const minVersion = await ask('Min compatible native version (e.g. 1.0.0): ') + + console.log('\n\x1b[36m--- Summary ---\x1b[0m') + console.log(` Version: ${localVersion}`) + console.log(` Modules: ${modules.map(m => m.moduleId).join(', ')}`) + const ok = await confirm('Proceed?') + if (!ok) { console.log('Aborted.'); rl.close(); return } + + // ── 5. Build & upload each module/platform ──────────────────────────────── + mkdirSync(bundleOutputDir, { recursive: true }) + + for (const mod of modules) { + for (const platform of mod.platforms) { + const outDir = path.join(bundleOutputDir, mod.moduleId, platform) + mkdirSync(outDir, { recursive: true }) + const bundleFile = path.join(outDir, `${mod.moduleId}.${platform}.bundle`) + + // Build using react-native CLI (platform-native, no extra deps) + console.log(`\n\x1b[36mBuilding ${mod.moduleId}/${platform}...\x1b[0m`) + execSync( + `npx react-native bundle \ + --platform ${platform} \ + --entry-file ${mod.entryFile ?? 'index.js'} \ + --bundle-output ${bundleFile} \ + --dev false \ + --minify true`, + { stdio: 'inherit' } + ) + + // Upload + console.log('\x1b[36mUploading...\x1b[0m') + const resp = await uploadBundle(mod.moduleId, platform, bundleFile, localVersion, minVersion, note) + const bundleId = resp.data?.id + console.log(`\x1b[32m✓ ${mod.moduleId}/${platform} uploaded, ID: ${bundleId}\x1b[0m`) + console.log(` Publish: POST ${serverUrl}/api/v1/rn/${bundleId}/publish`) + } + } + + rl.close() + console.log('\n\x1b[32m=== Release complete ===\x1b[0m') +} + +main().catch(e => { + console.error(`\n\x1b[31m❌ ${e.message}\x1b[0m`) + rl.close() + process.exit(1) +}) diff --git a/src/im/imSDK.ts b/src/im/imSDK.ts index de70845..03faed7 100644 --- a/src/im/imSDK.ts +++ b/src/im/imSDK.ts @@ -71,6 +71,15 @@ export const ImSDK = { }) }, + async editMessage(messageId: string, content: string): Promise { + const config = getConfig() + return apiRequest(`/api/im/messages/${encodeURIComponent(messageId)}`, { + method: 'PUT', + params: { appId: config.appId }, + body: { content }, + }) + }, + addListener(listener: ImEventListener) { client?.addListener(listener) }, diff --git a/src/im/types.ts b/src/im/types.ts index 62ae252..03dc3f4 100644 --- a/src/im/types.ts +++ b/src/im/types.ts @@ -28,6 +28,7 @@ export interface ImMessage { status: MsgStatus mentionedUserIds?: string createdAt: string + editedAt?: string | null } export interface ImEventListener { diff --git a/src/index.ts b/src/index.ts index b6cfb19..3efe123 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ export { ScaledImage } from '../packages/common/src' export { ImSDK } from '../packages/im/src' export { ImClient } from '../packages/im/src' export { uploadFile } from '../packages/im/src' -export { listFriends, addFriend, removeFriend } from '../packages/im/src' +export { listFriends, addFriend, removeFriend, editMessage } from '../packages/im/src' export type { ImMessage, ImGroup, ChatType, MsgType, MsgStatus, ImEventListener, SendMessageParams,