XuqmGroup-RNSDK/packages/im/src/ImSDK.ts
XuqmGroup d00231279e feat(chat): 添加聊天界面视图模型和联系人管理功能
- 实现 ChatViewModel 处理消息收发、历史记录加载和状态管理
- 添加消息搜索、草稿保存、引用回复等功能
- 实现多媒体附件发送包括图片、视频、音频和文件
- 添加群组提及用户功能和消息撤回机制
- 实现联系人管理功能包括好友搜索、添加、删除和黑名单管理
- 添加好友请求处理和实时消息监听
- 实现会话列表管理包含未读消息统计和实时更新
- 集成 IM SDK 的连接状态管理和事件监听
- 添加消息状态跟踪和超时处理机制
- 实现数据缓存机制优化用户体验
2026-04-28 22:32:21 +08:00

1035 行
32 KiB
TypeScript

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
const draftStore = new Map<string, string>()
const listenerMap = new WeakMap<ImEventListener, ImEventListener>()
function generateMessageId(): string {
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)}`
}
function normalizeMessage(msg: ImMessage, fallback?: Partial<ImMessage>): 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<void> {
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<void> {
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<void> {
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<void> {
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<ImMessage[]> {
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<ImMessage[]> {
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<ImMessage[]> {
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<ImMessage[]> {
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 locateHistoryPage(
toId: string,
messageId: string,
pageSize = 20,
maxPages = 20,
): Promise<ImMessage[] | null> {
for (let page = 0; page < Math.max(maxPages, 1); page += 1) {
const messages = await this.fetchHistory(toId, page, pageSize)
if (messages.some((item) => item.id === messageId)) {
return messages
}
if (messages.length < pageSize) return null
}
return null
},
async locateGroupHistoryPage(
groupId: string,
messageId: string,
pageSize = 50,
maxPages = 20,
): Promise<ImMessage[] | null> {
for (let page = 0; page < Math.max(maxPages, 1); page += 1) {
const messages = await this.fetchGroupHistory(groupId, page, pageSize)
if (messages.some((item) => item.id === messageId)) {
return messages
}
if (messages.length < pageSize) return null
}
return null
},
async sendMessage(
toId: string,
chatType: ChatType,
msgType: MsgType,
content: string,
mentionedUserIds?: string,
): Promise<ImMessage> {
const config = getConfig()
const outgoing = buildOutgoingMessage({
toId,
chatType,
msgType,
content,
mentionedUserIds,
})
try {
const msg = await apiRequest<ImMessage>('/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<ImMessage> {
return ImSDK.sendMessage(toId, chatType, 'TEXT', content, mentionedUserIds)
},
async sendImageMessage(
toId: string,
chatType: ChatType,
localUri: string,
width?: number,
height?: number,
): Promise<ImMessage> {
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<ImMessage> {
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<ImMessage> {
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<ImMessage> {
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<ImMessage> {
return ImSDK.sendMessage(
toId,
chatType,
'NOTIFY',
JSON.stringify({ title, content }),
)
},
async sendQuoteMessage(
toId: string,
chatType: ChatType,
quotedMsgId: string,
quotedContent: string,
text: string,
): Promise<ImMessage> {
return ImSDK.sendMessage(
toId,
chatType,
'QUOTE',
JSON.stringify({ quotedMsgId, quotedContent, text }),
)
},
async sendMergeMessage(
toId: string,
chatType: ChatType,
title: string,
msgList: string[],
): Promise<ImMessage> {
return ImSDK.sendMessage(
toId,
chatType,
'MERGE',
JSON.stringify({ title, msgList }),
)
},
async sendCallAudioMessage(
toId: string,
chatType: ChatType,
action: string,
): Promise<ImMessage> {
return ImSDK.sendMessage(toId, chatType, 'CALL_AUDIO', JSON.stringify({ action }))
},
async sendCallVideoMessage(
toId: string,
chatType: ChatType,
action: string,
): Promise<ImMessage> {
return ImSDK.sendMessage(toId, chatType, 'CALL_VIDEO', JSON.stringify({ action }))
},
async revokeMessage(messageId: string): Promise<ImMessage> {
const config = getConfig()
const msg = await apiRequest<ImMessage>(`/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 editMessage(messageId: string, content: string): Promise<ImMessage> {
const config = getConfig()
const msg = await apiRequest<ImMessage>(`/api/im/messages/${encodeURIComponent(messageId)}`, {
method: 'PUT',
params: { appId: config.appId },
body: { content },
})
if (ImDatabase.isInitialized() && _currentUserId) {
await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId)
}
return msg
},
async createGroup(name: string, memberIds: string[], groupType = 'WORK'): Promise<ImGroup> {
const config = getConfig()
return apiRequest<ImGroup>('/api/im/groups', {
method: 'POST',
params: { appId: config.appId },
body: { name, memberIds, groupType },
})
},
async listGroups(): Promise<ImGroup[]> {
const config = getConfig()
const res = await apiRequest<ImGroup[] | { content?: ImGroup[] }>('/api/im/groups', {
params: { appId: config.appId },
})
return Array.isArray(res) ? res : (res.content ?? [])
},
async listPublicGroups(keyword?: string): Promise<ImGroup[]> {
const config = getConfig()
const res = await apiRequest<ImGroup[] | { content?: ImGroup[] }>('/api/im/groups/public', {
params: {
appId: config.appId,
...(keyword ? { keyword } : {}),
},
})
return Array.isArray(res) ? res : (res.content ?? [])
},
async getGroupInfo(groupId: string): Promise<ImGroup> {
return apiRequest<ImGroup>(`/api/im/groups/${encodeURIComponent(groupId)}`)
},
async updateGroupInfo(groupId: string, name?: string, announcement?: string): Promise<ImGroup> {
return apiRequest<ImGroup>(`/api/im/groups/${encodeURIComponent(groupId)}`, {
method: 'PUT',
body: { name, announcement },
})
},
async addGroupMember(groupId: string, userId: string): Promise<ImGroup> {
return apiRequest<ImGroup>(`/api/im/groups/${encodeURIComponent(groupId)}/members`, {
method: 'POST',
body: { userId },
})
},
async removeGroupMember(groupId: string, targetUserId: string): Promise<ImGroup> {
return apiRequest<ImGroup>(
`/api/im/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(targetUserId)}`,
{ method: 'DELETE' },
)
},
async leaveGroup(groupId: string): Promise<ImGroup> {
if (!_currentUserId) throw new Error('[ImSDK] Not logged in')
return ImSDK.removeGroupMember(groupId, _currentUserId)
},
async setGroupRole(groupId: string, userId: string, role: string): Promise<ImGroup> {
return apiRequest<ImGroup>(`/api/im/groups/${encodeURIComponent(groupId)}/roles`, {
method: 'POST',
body: { userId, role },
})
},
async muteGroupMember(groupId: string, userId: string, minutes: number): Promise<ImGroup> {
return apiRequest<ImGroup>(`/api/im/groups/${encodeURIComponent(groupId)}/mute`, {
method: 'POST',
body: { userId, minutes },
})
},
async dismissGroup(groupId: string): Promise<void> {
await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}`, { method: 'DELETE' })
},
async sendGroupJoinRequest(groupId: string, remark?: string): Promise<GroupJoinRequest> {
const config = getConfig()
return apiRequest<GroupJoinRequest>(`/api/im/groups/${encodeURIComponent(groupId)}/join-requests`, {
method: 'POST',
params: {
appId: config.appId,
...(remark ? { remark } : {}),
},
})
},
async listGroupJoinRequests(groupId: string): Promise<GroupJoinRequest[]> {
const config = getConfig()
const res = await apiRequest<GroupJoinRequest[] | { content?: GroupJoinRequest[] }>(
`/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<GroupJoinRequest> {
const config = getConfig()
return apiRequest<GroupJoinRequest>(
`/api/im/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/accept`,
{
method: 'POST',
params: { appId: config.appId },
},
)
},
async rejectGroupJoinRequest(groupId: string, requestId: string): Promise<GroupJoinRequest> {
const config = getConfig()
return apiRequest<GroupJoinRequest>(
`/api/im/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/reject`,
{
method: 'POST',
params: { appId: config.appId },
},
)
},
async listFriends(): Promise<string[]> {
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<void> {
const config = getConfig()
await apiRequest('/api/im/friends', {
method: 'POST',
params: { appId: config.appId, friendId },
})
},
async removeFriend(friendId: string): Promise<void> {
const config = getConfig()
await apiRequest(`/api/im/friends/${encodeURIComponent(friendId)}`, {
method: 'DELETE',
params: { appId: config.appId },
})
},
async listFriendRequests(direction: 'incoming' | 'outgoing' = 'incoming'): Promise<FriendRequest[]> {
const config = getConfig()
const res = await apiRequest<FriendRequest[] | { content?: FriendRequest[] }>('/api/im/friend-requests', {
params: {
appId: config.appId,
direction,
},
})
return Array.isArray(res) ? res : (res.content ?? [])
},
async sendFriendRequest(toUserId: string, remark?: string): Promise<FriendRequest> {
const config = getConfig()
return apiRequest<FriendRequest>('/api/im/friend-requests', {
method: 'POST',
params: {
appId: config.appId,
toUserId,
...(remark ? { remark } : {}),
},
})
},
async acceptFriendRequest(requestId: string): Promise<FriendRequest> {
const config = getConfig()
return apiRequest<FriendRequest>(`/api/im/friend-requests/${encodeURIComponent(requestId)}/accept`, {
method: 'POST',
params: { appId: config.appId },
})
},
async rejectFriendRequest(requestId: string): Promise<FriendRequest> {
const config = getConfig()
return apiRequest<FriendRequest>(`/api/im/friend-requests/${encodeURIComponent(requestId)}/reject`, {
method: 'POST',
params: { appId: config.appId },
})
},
async listBlacklist(): Promise<BlacklistEntry[]> {
const config = getConfig()
const res = await apiRequest<BlacklistEntry[] | { content?: BlacklistEntry[] }>('/api/im/blacklist', {
params: { appId: config.appId },
})
return Array.isArray(res) ? res : (res.content ?? [])
},
async addToBlacklist(blockedUserId: string): Promise<BlacklistEntry> {
const config = getConfig()
return apiRequest<BlacklistEntry>('/api/im/blacklist', {
method: 'POST',
params: { appId: config.appId, blockedUserId },
})
},
async removeFromBlacklist(blockedUserId: string): Promise<void> {
const config = getConfig()
await apiRequest('/api/im/blacklist', {
method: 'DELETE',
params: { appId: config.appId, blockedUserId },
})
},
async getProfile(userId: string): Promise<UserProfile> {
const config = getConfig()
return apiRequest<UserProfile>(`/api/im/accounts/${encodeURIComponent(userId)}`, {
params: { appId: config.appId },
})
},
async updateProfile(
userId: string,
nickname?: string,
avatar?: string,
gender?: string,
): Promise<UserProfile> {
const config = getConfig()
return apiRequest<UserProfile>(`/api/im/accounts/${encodeURIComponent(userId)}`, {
method: 'PUT',
params: {
appId: config.appId,
...(nickname ? { nickname } : {}),
...(avatar ? { avatar } : {}),
...(gender ? { gender } : {}),
},
})
},
async searchUsers(keyword: string, size = 20): Promise<UserProfile[]> {
const config = getConfig()
const res = await apiRequest<UserProfile[] | { content?: UserProfile[] }>('/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<ImGroup[]> {
const config = getConfig()
const res = await apiRequest<ImGroup[] | { content?: ImGroup[] }>('/api/im/groups/search', {
params: {
appId: config.appId,
keyword,
size: String(size),
},
})
return Array.isArray(res) ? res : (res.content ?? [])
},
async listConversations(): Promise<ConversationData[]> {
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<ConversationData[] | { content?: ConversationData[] }>('/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<void> {
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<void> {
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<void> {
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<void> {
const config = getConfig()
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<string> {
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<void> {
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<number> {
const conversations = await ImSDK.listConversations()
return conversations.reduce((sum, conversation) => sum + conversation.unreadCount, 0)
},
async syncHistoryForAllConversations(): Promise<void> {
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 {
const wrapped: ImEventListener = {
...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))
},
onSystemMessage: async (msg) => {
if (ImDatabase.isInitialized() && _currentUserId) {
await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId)
}
listener.onSystemMessage?.(normalizeMessage(msg))
},
onRead: async (msg) => {
if (ImDatabase.isInitialized() && _currentUserId) {
await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId)
}
listener.onRead?.(normalizeMessage(msg))
},
onRevoke: async (data) => {
listener.onRevoke?.(data)
},
}
listenerMap.set(listener, wrapped)
client?.addListener(wrapped)
},
removeListener(listener: ImEventListener): void {
const wrapped = listenerMap.get(listener)
client?.removeListener(wrapped ?? listener)
listenerMap.delete(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)
},
}