diff --git a/packages/im/src/ImClient.ts b/packages/im/src/ImClient.ts index 5403692..1752fee 100644 --- a/packages/im/src/ImClient.ts +++ b/packages/im/src/ImClient.ts @@ -1,3 +1,4 @@ +import { _getToken, getConfig } from '@xuqm/rn-common' import type { ImEventListener, ImMessage, SendMessageParams } from './types' interface StompFrame { @@ -14,15 +15,32 @@ export class ImClient { private shouldReconnect = true private readonly subscriptionId = 'sub-user-queue' private groupSubscriptions = new Set() + private activeWsUrl: string | null = null + private activeToken: string | null = null + private activeAppId: string | null = null constructor( - private readonly wsUrl: string, - private readonly token: string, - private readonly appId: string, + private readonly wsUrl?: string, + private readonly token?: string, + private readonly appId?: string, ) {} - connect() { + async connect() { this.shouldReconnect = true + this.activeWsUrl = this.wsUrl ?? getConfig().imWsUrl + this.activeToken = this.token ?? null + this.activeAppId = this.appId ?? getConfig().appId + if (!this.activeToken) { + this.activeToken = await _getToken() + } + if (!this.activeWsUrl) { + this.listeners.forEach(listener => listener.onError?.('IM websocket URL not configured')) + return + } + if (!this.activeToken) { + this.listeners.forEach(listener => listener.onError?.('IM token not configured')) + return + } this.openSocket() } @@ -46,6 +64,9 @@ export class ImClient { if (this.ws?.readyState !== WebSocket.OPEN) { throw new Error('IM not connected') } + if (!this.activeAppId) { + throw new Error('IM appId not configured') + } this.sendFrame( 'SEND', @@ -54,7 +75,7 @@ export class ImClient { 'content-type': 'application/json', }, JSON.stringify({ - appId: this.appId, + appId: this.activeAppId, toId: params.toId, chatType: params.chatType, msgType: params.msgType, @@ -68,6 +89,9 @@ export class ImClient { if (this.ws?.readyState !== WebSocket.OPEN) { throw new Error('IM not connected') } + if (!this.activeAppId) { + throw new Error('IM appId not configured') + } this.sendFrame( 'SEND', @@ -76,19 +100,28 @@ export class ImClient { 'content-type': 'application/json', }, JSON.stringify({ - appId: this.appId, + appId: this.activeAppId, messageId, }), ) } subscribeGroup(groupId: string) { + const alreadySubscribed = this.groupSubscriptions.has(groupId) this.groupSubscriptions.add(groupId) if (this.ws?.readyState === WebSocket.OPEN) { + if (alreadySubscribed) return this.subscribe(`/topic/group/${groupId}`, `group-${groupId}`) } } + unsubscribeGroup(groupId: string) { + this.groupSubscriptions.delete(groupId) + if (this.ws?.readyState === WebSocket.OPEN) { + this.sendFrame('UNSUBSCRIBE', { id: `group-${groupId}` }) + } + } + addListener(listener: ImEventListener) { this.listeners.push(listener) } @@ -112,12 +145,26 @@ export class ImClient { } private openSocket() { - this.ws = new WebSocket(this.wsUrl) + if (!this.activeWsUrl) return + this.ws = new WebSocket(this.activeWsUrl) this.ws.onopen = () => { + if (!this.activeToken) { + void _getToken().then(token => { + this.activeToken = token + if (token) { + this.sendFrame('CONNECT', { + 'accept-version': '1.2', + Authorization: `Bearer ${token}`, + 'heart-beat': '10000,10000', + }) + } + }) + return + } this.sendFrame('CONNECT', { 'accept-version': '1.2', - Authorization: `Bearer ${this.token}`, + Authorization: `Bearer ${this.activeToken}`, 'heart-beat': '10000,10000', }) } diff --git a/packages/im/src/ImSDK.ts b/packages/im/src/ImSDK.ts index e8a8abb..b18829f 100644 --- a/packages/im/src/ImSDK.ts +++ b/packages/im/src/ImSDK.ts @@ -2,68 +2,103 @@ import { apiRequest, _getToken, _saveToken, getConfig, getDeviceInfo } from '@xu 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 type { + BlacklistEntry, + ChatType, + ConversationData, + FriendRequest, + GroupJoinRequest, + 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) - } - } - + const conversations = await ImSDK.listConversations() + const targetIds = new Set(conversations.map(item => item.targetId)) await Promise.all( - targetIds.map(async (targetId) => { + [...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 ?? []) + const messages = await ImSDK.fetchHistory(targetId, 0, 30) if (messages.length > 0 && _currentUserId) { await ImDatabase.bulkSave(messages, _currentUserId) } } catch { - // Silently skip conversations that fail to sync + // best-effort sync only } }), ) } catch { - // Sync is best-effort; do not throw + // 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. @@ -76,12 +111,12 @@ export const ImSDK = { method: 'POST', skipAuth: true, params: { - appId: config.appId, + appId: config.appId, userId, - deviceId: device.deviceId, - platform: device.platform, - brand: device.brand, - model: device.model, + deviceId: device.deviceId, + platform: device.platform, + brand: device.brand, + model: device.model, osVersion: device.osVersion, ...(nickname ? { nickname } : {}), ...(avatar ? { avatar } : {}), @@ -100,7 +135,7 @@ export const ImSDK = { _syncHistoryForAllConversations().catch(() => {}) }, }) - client.connect() + void client.connect() }, /** @@ -122,7 +157,7 @@ export const ImSDK = { _syncHistoryForAllConversations().catch(() => {}) }, }) - client.connect() + void client.connect() }, async reconnect(): Promise { @@ -130,44 +165,124 @@ export const ImSDK = { 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() + void 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 { + 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(), + 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(page), size: String(size) } }, + { + 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 }, @@ -190,6 +305,151 @@ export const ImSDK = { return msg }, + 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`, { @@ -202,12 +462,12 @@ export const ImSDK = { return msg }, - async createGroup(name: string, memberIds: string[]): Promise { + 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 }, + body: { name, memberIds, groupType }, }) }, @@ -219,86 +479,232 @@ export const ImSDK = { return Array.isArray(res) ? res : (res.content ?? []) }, - async addGroupMember(groupId: string, userId: string): Promise { + 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', - params: { appId: config.appId }, body: { userId }, }) }, async removeGroupMember(groupId: string, targetUserId: string): Promise { - const config = getConfig() return apiRequest( `/api/im/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(targetUserId)}`, - { method: 'DELETE', params: { appId: config.appId } }, + { method: 'DELETE' }, ) }, - async leaveGroup(groupId: string): Promise { + async leaveGroup(groupId: string): Promise { if (!_currentUserId) throw new Error('[ImSDK] Not logged in') - await ImSDK.removeGroupMember(groupId, _currentUserId) + return ImSDK.removeGroupMember(groupId, _currentUserId) }, - async fetchGroupHistory(groupId: string, page = 0, size = 50): Promise { - const config = getConfig() + async setGroupRole(groupId: string, userId: string, role: string): Promise { + return apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/roles`, { + method: 'POST', + body: { userId, role }, + }) + }, - 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(), + 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 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<{ content?: ImMessage[] } | ImMessage[]>( - `/api/im/messages/group-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 + const res = await apiRequest('/api/im/conversations', { + params: { appId: config.appId }, + }) + const conversations = Array.isArray(res) ? res : (res.content ?? []) + return conversations.map(normalizeConversation) }, - /** 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 => ({ + const data: ConversationData[] = models.map(c => normalizeConversation({ targetId: c.targetId, - chatType: c.chatType as 'SINGLE' | 'GROUP', - lastMsgContent: c.lastMsgContent ?? '', - lastMsgType: c.lastMsgType ?? '', + chatType: c.chatType, + lastMsgContent: c.lastMsgContent, + lastMsgType: c.lastMsgType, lastMsgTime: c.lastMsgTime, unreadCount: c.unreadCount, isMuted: c.isMuted, @@ -308,37 +714,82 @@ export const ImSDK = { }) }, - /** Mark a conversation as read (clears unread count). */ - async markRead(targetId: string): Promise { - if (!ImDatabase.isInitialized()) return + async markRead(targetId: string, chatType: ChatType = 'SINGLE'): Promise { const config = getConfig() - await ImDatabase.markRead(config.appId, targetId) + 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: string, muted: boolean): Promise { - if (!ImDatabase.isInitialized()) return + async setConversationMuted(targetId: string, chatType: ChatType, muted: boolean): Promise { const config = getConfig() - await ImDatabase.setConversationMuted(config.appId, targetId, muted) + 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: string, pinned: boolean): Promise { - if (!ImDatabase.isInitialized()) return + async setConversationPinned(targetId: string, chatType: ChatType, pinned: boolean): Promise { const config = getConfig() - await ImDatabase.setConversationPinned(config.appId, targetId, pinned) + 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) }, - /** - * 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() @@ -364,123 +815,20 @@ export const ImSDK = { }) }, - 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) + removeListener(listener: ImEventListener): void { + client?.removeListener(listener) }, - /** - * 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) + subscribeGroup(groupId: string): void { + client?.subscribeGroup(groupId) }, - /** - * 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) + unsubscribeGroup(groupId: string): void { + client?.unsubscribeGroup(groupId) }, - /** - * 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) - }, - - async listFriends(): Promise { - const config = getConfig() - const res = await apiRequest<{ data?: string[] } | string[]>('/api/im/friends', { - params: { appId: config.appId }, - }) - if (Array.isArray(res)) return res - return (res as { data?: string[] }).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 }, - }) + isConnected(): boolean { + return client?.isConnected() ?? false }, disconnect(): void { @@ -489,22 +837,3 @@ export const ImSDK = { _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 -} diff --git a/packages/im/src/db/ImDatabase.ts b/packages/im/src/db/ImDatabase.ts index 99113ff..5f8365a 100644 --- a/packages/im/src/db/ImDatabase.ts +++ b/packages/im/src/db/ImDatabase.ts @@ -243,6 +243,28 @@ export const ImDatabase = { } }, + async deleteConversation(appId: string, targetId: string, chatType: string, currentUserId: string): Promise { + const db = getDb() + const convId = conversationId(appId, currentUserId, targetId, chatType) + const convs = await db + .get('im_conversations') + .query(Q.where('app_id', appId), Q.where('target_id', targetId)) + .fetch() + const messages = await db + .get('im_messages') + .query(Q.where('conversation_id', convId)) + .fetch() + + await db.write(async () => { + for (const message of messages) { + await message.destroyPermanently() + } + for (const conversation of convs) { + await conversation.destroyPermanently() + } + }) + }, + isInitialized(): boolean { return _db !== null }, diff --git a/packages/im/src/index.ts b/packages/im/src/index.ts index f8667df..b8ec220 100644 --- a/packages/im/src/index.ts +++ b/packages/im/src/index.ts @@ -1,5 +1,4 @@ export { ImSDK } from './ImSDK' -export type { ConversationData } from './ImSDK' import { ImSDK as _ImSDK } from './ImSDK' // Convenience named exports for friend APIs @@ -12,6 +11,10 @@ export type { MessageSearchParams } from './db/ImDatabase' export type { ImMessage, ImGroup, ChatType, MsgType, MsgStatus, ImEventListener, SendMessageParams, + ConversationData, + FriendRequest, + GroupJoinRequest, + BlacklistEntry, } from './types' export { uploadFile } from './upload' export type { UploadResult } from './upload' diff --git a/packages/im/src/types.ts b/packages/im/src/types.ts index 62ae252..c0fc641 100644 --- a/packages/im/src/types.ts +++ b/packages/im/src/types.ts @@ -12,10 +12,12 @@ export type MsgType = | 'RICH_TEXT' | 'CALL_AUDIO' | 'CALL_VIDEO' + | 'QUOTE' + | 'MERGE' | 'REVOKED' | 'FORWARD' -export type MsgStatus = 'SENT' | 'DELIVERED' | 'READ' | 'REVOKED' +export type MsgStatus = 'SENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED' | 'REVOKED' export interface ImMessage { id: string @@ -27,7 +29,8 @@ export interface ImMessage { content: string status: MsgStatus mentionedUserIds?: string - createdAt: string + groupReadCount?: number + createdAt: number } export interface ImEventListener { @@ -50,8 +53,51 @@ export interface ImGroup { id: string appId: string name: string + groupType?: string creatorId: string memberIds: string adminIds: string - createdAt: string + announcement?: string | null + createdAt: number +} + +export interface ConversationData { + targetId: string + chatType: ChatType + lastMsgContent?: string | null + lastMsgType?: string | null + lastMsgTime: number + unreadCount: number + isMuted: boolean + isPinned: boolean +} + +export interface FriendRequest { + id: string + appId: string + fromUserId: string + toUserId: string + remark?: string | null + status: 'PENDING' | 'ACCEPTED' | 'REJECTED' + createdAt: number + reviewedAt?: number | null +} + +export interface GroupJoinRequest { + id: string + appId: string + groupId: string + requesterId: string + remark?: string | null + status: 'PENDING' | 'ACCEPTED' | 'REJECTED' + createdAt: number + reviewedAt?: number | null +} + +export interface BlacklistEntry { + id: string + appId: string + userId: string + blockedUserId: string + createdAt: number } diff --git a/src/index.ts b/src/index.ts index f04ec5e..f804d9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,9 @@ export type { ImMessage, ImGroup, ChatType, MsgType, MsgStatus, ImEventListener, SendMessageParams, ConversationData, + FriendRequest, + GroupJoinRequest, + BlacklistEntry, MessageSearchParams, UploadResult, } from '../packages/im/src'