feat(chat): 添加聊天界面和文件更新SDK功能
- 实现完整的聊天界面UI组件,支持文本、图片、视频、音频、文件等多种消息类型 - 集成IM消息收发功能,实现消息气泡显示和用户头像占位符 - 添加媒体文件选择和拍摄功能,支持相册图片、视频及相机拍照录像 - 实现语音录制和播放功能,包含按住说话交互和权限处理 - 添加群组提及功能,支持@用户和提及候选列表显示 - 实现消息回复和引用功能,支持消息长按回复操作 - 添加本地消息搜索功能,支持搜索当前会话的历史消息 - 实现文件上传下载功能,集成FileSDK进行文件传输管理 - 添加应用更新检查功能,集成UpdateSDK支持版本更新 - 实现消息状态显示,包括发送、送达、已读等状态标识 - 添加群组已读人数统计,显示消息在群聊中的阅读情况 - 实现草稿保存和恢复功能,支持断点续聊体验 - 添加连接状态横幅,实时显示IM服务连接状态 - 实现滚动加载更多历史消息,优化大量消息的性能表现 - 添加多媒体文件下载保存功能,支持保存到应用专属目录
这个提交包含在:
父节点
765d8a0333
当前提交
62b84c3e8c
@ -154,7 +154,7 @@ export class ImClient {
|
|||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
if (!this.activeToken) {
|
if (!this.activeToken) {
|
||||||
void _getToken().then(token => {
|
void _getToken().then((token: string | null) => {
|
||||||
this.activeToken = token
|
this.activeToken = token
|
||||||
if (token) {
|
if (token) {
|
||||||
this.sendFrame('CONNECT', {
|
this.sendFrame('CONNECT', {
|
||||||
@ -212,9 +212,12 @@ export class ImClient {
|
|||||||
const message: ImMessage = this.normalizeMessage(JSON.parse(frame.body))
|
const message: ImMessage = this.normalizeMessage(JSON.parse(frame.body))
|
||||||
if (message.chatType === 'GROUP') {
|
if (message.chatType === 'GROUP') {
|
||||||
this.listeners.forEach(listener => listener.onGroupMessage?.(message))
|
this.listeners.forEach(listener => listener.onGroupMessage?.(message))
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
this.listeners.forEach(listener => listener.onMessage?.(message))
|
this.listeners.forEach(listener => listener.onMessage?.(message))
|
||||||
|
}
|
||||||
|
if (message.msgType === 'NOTIFY') {
|
||||||
|
this.listeners.forEach(listener => listener.onSystemMessage?.(message))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,7 +282,8 @@ export class ImClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateMessageId(): string {
|
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)}`
|
return cryptoId ?? `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,9 +18,12 @@ import { uploadFile } from './upload'
|
|||||||
|
|
||||||
let client: ImClient | null = null
|
let client: ImClient | null = null
|
||||||
let _currentUserId: string | null = null
|
let _currentUserId: string | null = null
|
||||||
|
const draftStore = new Map<string, string>()
|
||||||
|
const listenerMap = new WeakMap<ImEventListener, ImEventListener>()
|
||||||
|
|
||||||
function generateMessageId(): string {
|
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)}`
|
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<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[]> {
|
async listConversations(): Promise<ConversationData[]> {
|
||||||
const config = getConfig()
|
const config = getConfig()
|
||||||
if (ImDatabase.isInitialized()) {
|
if (ImDatabase.isInitialized()) {
|
||||||
@ -851,14 +878,23 @@ export const ImSDK = {
|
|||||||
|
|
||||||
async setDraft(targetId: string, chatType: ChatType, draft: string): Promise<void> {
|
async setDraft(targetId: string, chatType: ChatType, draft: string): Promise<void> {
|
||||||
const config = getConfig()
|
const config = getConfig()
|
||||||
await apiRequest(`/api/im/conversations/${encodeURIComponent(targetId)}/draft`, {
|
const draftKey = `${config.appId}:${chatType}:${targetId}`
|
||||||
method: 'PUT',
|
draftStore.set(draftKey, draft)
|
||||||
params: {
|
if (ImDatabase.isInitialized()) {
|
||||||
appId: config.appId,
|
await ImDatabase.setDraft(config.appId, targetId, chatType, draft)
|
||||||
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> {
|
async deleteConversation(targetId: string, chatType: ChatType): Promise<void> {
|
||||||
@ -892,7 +928,7 @@ export const ImSDK = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
addListener(listener: ImEventListener): void {
|
addListener(listener: ImEventListener): void {
|
||||||
client?.addListener({
|
const wrapped: ImEventListener = {
|
||||||
...listener,
|
...listener,
|
||||||
onMessage: async (msg) => {
|
onMessage: async (msg) => {
|
||||||
if (ImDatabase.isInitialized() && _currentUserId) {
|
if (ImDatabase.isInitialized() && _currentUserId) {
|
||||||
@ -906,11 +942,21 @@ export const ImSDK = {
|
|||||||
}
|
}
|
||||||
listener.onGroupMessage?.(normalizeMessage(msg))
|
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 {
|
removeListener(listener: ImEventListener): void {
|
||||||
client?.removeListener(listener)
|
const wrapped = listenerMap.get(listener)
|
||||||
|
client?.removeListener(wrapped ?? listener)
|
||||||
|
listenerMap.delete(listener)
|
||||||
},
|
},
|
||||||
|
|
||||||
subscribeGroup(groupId: string): void {
|
subscribeGroup(groupId: string): void {
|
||||||
|
|||||||
@ -14,5 +14,6 @@ export class ConversationModel extends Model {
|
|||||||
@field('unread_count') unreadCount!: number
|
@field('unread_count') unreadCount!: number
|
||||||
@field('is_muted') isMuted!: boolean
|
@field('is_muted') isMuted!: boolean
|
||||||
@field('is_pinned') isPinned!: boolean
|
@field('is_pinned') isPinned!: boolean
|
||||||
|
@field('draft') draft!: string | null
|
||||||
@date('updated_at') updatedAt!: Date
|
@date('updated_at') updatedAt!: Date
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { MessageModel } from './MessageModel'
|
|||||||
import type { ImMessage } from '../types'
|
import type { ImMessage } from '../types'
|
||||||
|
|
||||||
let _db: Database | null = null
|
let _db: Database | null = null
|
||||||
|
const draftStore = new Map<string, string>()
|
||||||
|
|
||||||
function getDb(): Database {
|
function getDb(): Database {
|
||||||
if (!_db) throw new Error('[ImDatabase] Not initialized — call ImDatabase.init() first.')
|
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}`
|
return `${appId}:S:${a}:${b}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function draftKey(appId: string, targetId: string, chatType: string): string {
|
||||||
|
return `${appId}:${chatType}:${targetId}`
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageSearchParams {
|
export interface MessageSearchParams {
|
||||||
keyword?: string
|
keyword?: string
|
||||||
toId?: string
|
toId?: string
|
||||||
@ -98,6 +103,7 @@ export const ImDatabase = {
|
|||||||
c.unreadCount = msg.fromUserId !== currentUserId ? 1 : 0
|
c.unreadCount = msg.fromUserId !== currentUserId ? 1 : 0
|
||||||
c.isMuted = false
|
c.isMuted = false
|
||||||
c.isPinned = false
|
c.isPinned = false
|
||||||
|
c.draft = null
|
||||||
c.updatedAt = new Date()
|
c.updatedAt = new Date()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@ -155,6 +161,23 @@ export const ImDatabase = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async setDraft(appId: string, targetId: string, chatType: string, draft: string): Promise<void> {
|
||||||
|
draftStore.set(draftKey(appId, targetId, chatType), draft)
|
||||||
|
const db = getDb()
|
||||||
|
const convs = await db
|
||||||
|
.get<ConversationModel>('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<void> {
|
async bulkSave(messages: ImMessage[], currentUserId: string): Promise<void> {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
await this.saveMessage(msg, currentUserId)
|
await this.saveMessage(msg, currentUserId)
|
||||||
@ -163,7 +186,7 @@ export const ImDatabase = {
|
|||||||
|
|
||||||
async searchMessages(appId: string, params: MessageSearchParams): Promise<MessageModel[]> {
|
async searchMessages(appId: string, params: MessageSearchParams): Promise<MessageModel[]> {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const conditions: Parameters<typeof Q.and>[0][] = [
|
const conditions: any[] = [
|
||||||
Q.where('app_id', appId),
|
Q.where('app_id', appId),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -191,10 +214,10 @@ export const ImDatabase = {
|
|||||||
.query(...conditions)
|
.query(...conditions)
|
||||||
|
|
||||||
if (params.limit !== undefined) {
|
if (params.limit !== undefined) {
|
||||||
query = query.extend(Q.take(params.limit))
|
query = query.extend(Q.take(params.limit) as any)
|
||||||
}
|
}
|
||||||
if (params.offset !== undefined) {
|
if (params.offset !== undefined) {
|
||||||
query = query.extend(Q.skip(params.offset))
|
query = query.extend(Q.skip(params.offset) as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
return query.fetch()
|
return query.fetch()
|
||||||
@ -243,6 +266,20 @@ export const ImDatabase = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getConversationDraft(appId: string, targetId: string, chatType: string): Promise<string | null> {
|
||||||
|
const memoryDraft = draftStore.get(draftKey(appId, targetId, chatType))
|
||||||
|
if (memoryDraft !== undefined) {
|
||||||
|
return memoryDraft
|
||||||
|
}
|
||||||
|
const db = getDb()
|
||||||
|
const convs = await db
|
||||||
|
.get<ConversationModel>('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<void> {
|
async deleteConversation(appId: string, targetId: string, chatType: string, currentUserId: string): Promise<void> {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const convId = conversationId(appId, currentUserId, targetId, chatType)
|
const convId = conversationId(appId, currentUserId, targetId, chatType)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { appSchema, tableSchema } from '@nozbe/watermelondb'
|
import { appSchema, tableSchema } from '@nozbe/watermelondb'
|
||||||
|
|
||||||
export const imDbSchema = appSchema({
|
export const imDbSchema = appSchema({
|
||||||
version: 2,
|
version: 3,
|
||||||
tables: [
|
tables: [
|
||||||
tableSchema({
|
tableSchema({
|
||||||
name: 'im_conversations',
|
name: 'im_conversations',
|
||||||
@ -16,6 +16,7 @@ export const imDbSchema = appSchema({
|
|||||||
{ name: 'unread_count', type: 'number' },
|
{ name: 'unread_count', type: 'number' },
|
||||||
{ name: 'is_muted', type: 'boolean' },
|
{ name: 'is_muted', type: 'boolean' },
|
||||||
{ name: 'is_pinned', type: 'boolean' },
|
{ name: 'is_pinned', type: 'boolean' },
|
||||||
|
{ name: 'draft', type: 'string', isOptional: true },
|
||||||
{ name: 'updated_at', type: 'number' },
|
{ name: 'updated_at', type: 'number' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { ImSDK as _ImSDK } from './ImSDK'
|
|||||||
export const listFriends = (): Promise<string[]> => _ImSDK.listFriends()
|
export const listFriends = (): Promise<string[]> => _ImSDK.listFriends()
|
||||||
export const addFriend = (friendId: string): Promise<void> => _ImSDK.addFriend(friendId)
|
export const addFriend = (friendId: string): Promise<void> => _ImSDK.addFriend(friendId)
|
||||||
export const removeFriend = (friendId: string): Promise<void> => _ImSDK.removeFriend(friendId)
|
export const removeFriend = (friendId: string): Promise<void> => _ImSDK.removeFriend(friendId)
|
||||||
|
export const searchUsers = (keyword: string, size?: number): ReturnType<typeof _ImSDK.searchUsers> => _ImSDK.searchUsers(keyword, size)
|
||||||
|
export const searchGroups = (keyword: string, size?: number): ReturnType<typeof _ImSDK.searchGroups> => _ImSDK.searchGroups(keyword, size)
|
||||||
export { ImClient } from './ImClient'
|
export { ImClient } from './ImClient'
|
||||||
export { ImDatabase } from './db/ImDatabase'
|
export { ImDatabase } from './db/ImDatabase'
|
||||||
export type { MessageSearchParams } from './db/ImDatabase'
|
export type { MessageSearchParams } from './db/ImDatabase'
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export interface ImEventListener {
|
|||||||
onDisconnected?: (reason?: string) => void
|
onDisconnected?: (reason?: string) => void
|
||||||
onMessage?: (msg: ImMessage) => void
|
onMessage?: (msg: ImMessage) => void
|
||||||
onGroupMessage?: (msg: ImMessage) => void
|
onGroupMessage?: (msg: ImMessage) => void
|
||||||
|
onSystemMessage?: (msg: ImMessage) => void
|
||||||
onError?: (error: string) => void
|
onError?: (error: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户