feat(im): 添加即时通讯功能模块
- 添加了 IM API 接口定义,包含登录、消息、群组、好友等接口 - 实现了 ImSDK 核心功能,支持发送各类消息和管理会话 - 集成了 WebSocket 连接管理和自动重连机制 - 添加了本地联系人缓存并优化对话标题显示逻辑 - 实现了 HarmonyOS 平台 HTTP 客户端基础功能
这个提交包含在:
父节点
fe8b72d2be
当前提交
930c8f36ae
@ -13,6 +13,11 @@ export type {
|
||||
PushTokenInfo,
|
||||
MsgType,
|
||||
ChatType,
|
||||
MsgStatus,
|
||||
ConversationData,
|
||||
HistoryQuery,
|
||||
PageResult,
|
||||
UserProfile,
|
||||
ApiResponse,
|
||||
} from './src/main/ets/core/Types'
|
||||
export type { ImEventDelegate, RevokeData } from './src/main/ets/im/ImClient'
|
||||
|
||||
@ -21,6 +21,14 @@ export class XuqmSDK {
|
||||
return SDKContext.getToken()
|
||||
}
|
||||
|
||||
static setUserId(userId: string | null): void {
|
||||
SDKContext.setUserId(userId)
|
||||
}
|
||||
|
||||
static getUserId(): string | null {
|
||||
return SDKContext.getUserId()
|
||||
}
|
||||
|
||||
static get im(): ImClient {
|
||||
if (!XuqmSDK._imClient) {
|
||||
XuqmSDK._imClient = new ImClient()
|
||||
|
||||
@ -3,10 +3,23 @@ import type { ApiResponse } from './Types'
|
||||
import { SDKContext } from './SDKContext'
|
||||
|
||||
export class HttpClient {
|
||||
static async request<T>(method: http.RequestMethod, path: string, body?: object): Promise<T> {
|
||||
static async request<T>(
|
||||
method: http.RequestMethod,
|
||||
path: string,
|
||||
body?: object,
|
||||
query?: Record<string, string | number | boolean | Date | null | undefined>,
|
||||
): Promise<T> {
|
||||
const config = SDKContext.getConfig()
|
||||
const token = SDKContext.getToken()
|
||||
const url = config.apiBaseUrl.replace(/\/$/, '') + path
|
||||
const queryPairs: string[] = []
|
||||
if (query) {
|
||||
for (const key of Object.keys(query)) {
|
||||
const value = query[key]
|
||||
if (value === undefined || value === null || value === '') continue
|
||||
queryPairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value instanceof Date ? value.toISOString() : String(value))}`)
|
||||
}
|
||||
}
|
||||
const url = config.apiBaseUrl.replace(/\/$/, '') + path + (queryPairs.length > 0 ? `?${queryPairs.join('&')}` : '')
|
||||
|
||||
const client = http.createHttp()
|
||||
try {
|
||||
@ -30,19 +43,19 @@ export class HttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
static get<T>(path: string): Promise<T> {
|
||||
return HttpClient.request<T>(http.RequestMethod.GET, path)
|
||||
static get<T>(path: string, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> {
|
||||
return HttpClient.request<T>(http.RequestMethod.GET, path, undefined, query)
|
||||
}
|
||||
|
||||
static post<T>(path: string, body?: object): Promise<T> {
|
||||
return HttpClient.request<T>(http.RequestMethod.POST, path, body)
|
||||
static post<T>(path: string, body?: object, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> {
|
||||
return HttpClient.request<T>(http.RequestMethod.POST, path, body, query)
|
||||
}
|
||||
|
||||
static put<T>(path: string, body?: object): Promise<T> {
|
||||
return HttpClient.request<T>(http.RequestMethod.PUT, path, body)
|
||||
static put<T>(path: string, body?: object, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> {
|
||||
return HttpClient.request<T>(http.RequestMethod.PUT, path, body, query)
|
||||
}
|
||||
|
||||
static delete<T>(path: string): Promise<T> {
|
||||
return HttpClient.request<T>(http.RequestMethod.DELETE, path)
|
||||
static delete<T>(path: string, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> {
|
||||
return HttpClient.request<T>(http.RequestMethod.DELETE, path, undefined, query)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ const PREF_NAME = 'xuqm_sdk_prefs'
|
||||
export class SDKContext {
|
||||
private static _config: SDKConfig | null = null
|
||||
private static _token: string | null = null
|
||||
private static _userId: string | null = null
|
||||
private static _pref: preferences.Preferences | null = null
|
||||
|
||||
static init(config: SDKConfig): void {
|
||||
@ -44,4 +45,12 @@ export class SDKContext {
|
||||
static getToken(): string | null {
|
||||
return SDKContext._token
|
||||
}
|
||||
|
||||
static setUserId(userId: string | null): void {
|
||||
SDKContext._userId = userId
|
||||
}
|
||||
|
||||
static getUserId(): string | null {
|
||||
return SDKContext._userId
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,29 +25,81 @@ export type MsgType =
|
||||
| 'RICH_TEXT'
|
||||
| 'CALL_AUDIO'
|
||||
| 'CALL_VIDEO'
|
||||
| 'QUOTE'
|
||||
| 'MERGE'
|
||||
| 'REVOKED'
|
||||
| 'FORWARD'
|
||||
|
||||
export type ChatType = 'SINGLE' | 'GROUP'
|
||||
|
||||
export type MsgStatus = 'SENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED' | 'REVOKED'
|
||||
|
||||
export interface ImMessage {
|
||||
id: string
|
||||
appId: string
|
||||
fromUserId: string
|
||||
fromId: string
|
||||
toId: string
|
||||
chatType: ChatType
|
||||
msgType: MsgType
|
||||
content: string
|
||||
extra?: string
|
||||
revoked: boolean
|
||||
createdAt: string
|
||||
status: MsgStatus
|
||||
mentionedUserIds?: string
|
||||
groupReadCount?: number
|
||||
revoked?: boolean
|
||||
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 HistoryQuery {
|
||||
msgType?: MsgType
|
||||
keyword?: string
|
||||
startTime?: Date | string | number
|
||||
endTime?: Date | string | number
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
content: T[]
|
||||
totalElements: number
|
||||
totalPages: number
|
||||
size: number
|
||||
number: number
|
||||
numberOfElements: number
|
||||
first: boolean
|
||||
last: boolean
|
||||
empty: boolean
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id?: string
|
||||
appId?: string
|
||||
userId: string
|
||||
nickname?: string | null
|
||||
avatar?: string | null
|
||||
gender?: string | null
|
||||
status?: string | null
|
||||
createdAt?: number | null
|
||||
}
|
||||
|
||||
export interface SendMessageParams {
|
||||
messageId?: string
|
||||
toId: string
|
||||
chatType: ChatType
|
||||
msgType: MsgType
|
||||
content: string
|
||||
extra?: string
|
||||
mentionedUserIds?: string
|
||||
}
|
||||
|
||||
export interface AppVersionInfo {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import webSocket from '@ohos.net.webSocket'
|
||||
import type { ImMessage, SendMessageParams } from '../core/Types'
|
||||
import { HttpClient } from '../core/HttpClient'
|
||||
import { SDKContext } from '../core/SDKContext'
|
||||
import type { ChatType, ConversationData, HistoryQuery, ImMessage, MsgType, PageResult, SendMessageParams, UserProfile } from '../core/Types'
|
||||
|
||||
export interface ImEventDelegate {
|
||||
onConnected?(): void
|
||||
@ -41,9 +42,9 @@ export class ImClient {
|
||||
this.ws.on('message', (_err: Error, value: string | ArrayBuffer) => {
|
||||
try {
|
||||
const text = typeof value === 'string' ? value : new TextDecoder().decode(value)
|
||||
const frame = JSON.parse(text) as { type: string; payload: object }
|
||||
const frame = JSON.parse(text) as { type: string; payload: unknown }
|
||||
if (frame.type === 'MESSAGE') {
|
||||
this.delegate?.onMessage?.(frame.payload as ImMessage)
|
||||
this.delegate?.onMessage?.(this.normalizeMessage(frame.payload as ImMessage))
|
||||
} else if (frame.type === 'REVOKE') {
|
||||
this.delegate?.onRevoke?.(frame.payload as RevokeData)
|
||||
}
|
||||
@ -64,19 +65,133 @@ export class ImClient {
|
||||
this.ws.connect(url, {})
|
||||
}
|
||||
|
||||
send(params: SendMessageParams): void {
|
||||
if (!this.ws) throw new Error('WebSocket not connected')
|
||||
const frame = JSON.stringify({ destination: '/app/chat.send', payload: params })
|
||||
this.ws.send(frame, (_err: Error) => {
|
||||
if (_err) console.error('[ImClient] send error', _err.message)
|
||||
})
|
||||
send(params: SendMessageParams): ImMessage {
|
||||
const outgoing = this.buildOutgoingMessage(params)
|
||||
if (!this.ws) {
|
||||
return { ...outgoing, status: 'FAILED' }
|
||||
}
|
||||
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
destination: '/app/chat.send',
|
||||
payload: {
|
||||
...params,
|
||||
messageId: outgoing.id,
|
||||
},
|
||||
}),
|
||||
(_err: Error) => {
|
||||
if (_err) {
|
||||
this.delegate?.onError?.(_err.message)
|
||||
}
|
||||
},
|
||||
)
|
||||
return outgoing
|
||||
}
|
||||
|
||||
revoke(msgId: string): void {
|
||||
if (!this.ws) throw new Error('WebSocket not connected')
|
||||
const frame = JSON.stringify({ destination: '/app/chat.revoke', payload: { msgId } })
|
||||
this.ws.send(frame, (_err: Error) => {
|
||||
if (_err) console.error('[ImClient] revoke error', _err.message)
|
||||
if (!this.ws) {
|
||||
throw new Error('WebSocket not connected')
|
||||
}
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
destination: '/app/chat.revoke',
|
||||
payload: { msgId },
|
||||
}),
|
||||
(_err: Error) => {
|
||||
if (_err) this.delegate?.onError?.(_err.message)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fetchHistory(
|
||||
toId: string,
|
||||
page: number = 0,
|
||||
size: number = 20,
|
||||
query: HistoryQuery = {},
|
||||
): Promise<PageResult<ImMessage>> {
|
||||
return HttpClient.get<PageResult<ImMessage>>(`/api/im/messages/history/${encodeURIComponent(toId)}`, {
|
||||
appId: SDKContext.getConfig().appKey,
|
||||
page,
|
||||
size,
|
||||
msgType: query.msgType,
|
||||
keyword: query.keyword,
|
||||
startTime: query.startTime instanceof Date
|
||||
? this.formatDateTime(query.startTime)
|
||||
: query.startTime,
|
||||
endTime: query.endTime instanceof Date
|
||||
? this.formatDateTime(query.endTime)
|
||||
: query.endTime,
|
||||
})
|
||||
}
|
||||
|
||||
async fetchGroupHistory(
|
||||
groupId: string,
|
||||
page: number = 0,
|
||||
size: number = 50,
|
||||
query: HistoryQuery = {},
|
||||
): Promise<PageResult<ImMessage>> {
|
||||
return HttpClient.get<PageResult<ImMessage>>(`/api/im/messages/group-history/${encodeURIComponent(groupId)}`, {
|
||||
appId: SDKContext.getConfig().appKey,
|
||||
page,
|
||||
size,
|
||||
msgType: query.msgType,
|
||||
keyword: query.keyword,
|
||||
startTime: query.startTime instanceof Date
|
||||
? this.formatDateTime(query.startTime)
|
||||
: query.startTime,
|
||||
endTime: query.endTime instanceof Date
|
||||
? this.formatDateTime(query.endTime)
|
||||
: query.endTime,
|
||||
})
|
||||
}
|
||||
|
||||
async listConversations(size: number = 20): Promise<ConversationData[]> {
|
||||
return HttpClient.get<ConversationData[]>('/api/im/conversations', {
|
||||
appId: SDKContext.getConfig().appKey,
|
||||
page: 0,
|
||||
size,
|
||||
})
|
||||
}
|
||||
|
||||
async markRead(targetId: string, chatType: ChatType = 'SINGLE'): Promise<void> {
|
||||
await HttpClient.put<void>(`/api/im/conversations/${encodeURIComponent(targetId)}/read`, undefined, {
|
||||
appId: SDKContext.getConfig().appKey,
|
||||
chatType,
|
||||
})
|
||||
}
|
||||
|
||||
async setDraft(targetId: string, chatType: ChatType, draft: string): Promise<void> {
|
||||
await HttpClient.put<void>(`/api/im/conversations/${encodeURIComponent(targetId)}/draft`, undefined, {
|
||||
appId: SDKContext.getConfig().appKey,
|
||||
chatType,
|
||||
draft,
|
||||
})
|
||||
}
|
||||
|
||||
async deleteConversation(targetId: string, chatType: ChatType): Promise<void> {
|
||||
await HttpClient.delete<void>(`/api/im/conversations/${encodeURIComponent(targetId)}`, {
|
||||
appId: SDKContext.getConfig().appKey,
|
||||
chatType,
|
||||
})
|
||||
}
|
||||
|
||||
async getProfile(userId: string): Promise<UserProfile> {
|
||||
return HttpClient.get<UserProfile>(`/api/im/accounts/${encodeURIComponent(userId)}`, {
|
||||
appId: SDKContext.getConfig().appKey,
|
||||
})
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
nickname: string | null = null,
|
||||
avatar: string | null = null,
|
||||
gender: string | null = null,
|
||||
): Promise<UserProfile> {
|
||||
return HttpClient.put<UserProfile>(`/api/im/accounts/${encodeURIComponent(userId)}`, undefined, {
|
||||
appId: SDKContext.getConfig().appKey,
|
||||
...(nickname !== null ? { nickname } : {}),
|
||||
...(avatar !== null ? { avatar } : {}),
|
||||
...(gender !== null ? { gender } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@ -99,4 +214,57 @@ export class ImClient {
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private buildOutgoingMessage(params: SendMessageParams): ImMessage {
|
||||
const messageId = params.messageId ?? this.generateMessageId()
|
||||
const userId = SDKContext.getUserId() ?? ''
|
||||
const appId = SDKContext.getConfig().appKey
|
||||
return {
|
||||
id: messageId,
|
||||
appId,
|
||||
fromUserId: userId,
|
||||
fromId: userId,
|
||||
toId: params.toId,
|
||||
chatType: params.chatType,
|
||||
msgType: params.msgType,
|
||||
content: params.content,
|
||||
status: 'SENDING',
|
||||
mentionedUserIds: params.mentionedUserIds,
|
||||
groupReadCount: 0,
|
||||
revoked: false,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeMessage(message: ImMessage): ImMessage {
|
||||
return {
|
||||
...message,
|
||||
fromId: message.fromId ?? message.fromUserId,
|
||||
revoked: message.revoked ?? message.status === 'REVOKED',
|
||||
appId: message.appId ?? SDKContext.getConfig().appKey,
|
||||
}
|
||||
}
|
||||
|
||||
private generateMessageId(): string {
|
||||
const cryptoId = globalThis.crypto?.randomUUID?.()
|
||||
if (cryptoId) return cryptoId
|
||||
return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
private formatDateTime(value: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return [
|
||||
value.getFullYear(),
|
||||
'-',
|
||||
pad(value.getMonth() + 1),
|
||||
'-',
|
||||
pad(value.getDate()),
|
||||
'T',
|
||||
pad(value.getHours()),
|
||||
':',
|
||||
pad(value.getMinutes()),
|
||||
':',
|
||||
pad(value.getSeconds()),
|
||||
].join('')
|
||||
}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户