feat(im): 添加即时通讯功能模块
- 添加了 IM API 接口定义,包含登录、消息、群组、好友等接口 - 实现了 ImSDK 核心功能,支持发送各类消息和管理会话 - 集成了 WebSocket 连接管理和自动重连机制 - 添加了本地联系人缓存并优化对话标题显示逻辑 - 实现了 HarmonyOS 平台 HTTP 客户端基础功能
这个提交包含在:
父节点
fe8b72d2be
当前提交
930c8f36ae
@ -13,6 +13,11 @@ export type {
|
|||||||
PushTokenInfo,
|
PushTokenInfo,
|
||||||
MsgType,
|
MsgType,
|
||||||
ChatType,
|
ChatType,
|
||||||
|
MsgStatus,
|
||||||
|
ConversationData,
|
||||||
|
HistoryQuery,
|
||||||
|
PageResult,
|
||||||
|
UserProfile,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
} from './src/main/ets/core/Types'
|
} from './src/main/ets/core/Types'
|
||||||
export type { ImEventDelegate, RevokeData } from './src/main/ets/im/ImClient'
|
export type { ImEventDelegate, RevokeData } from './src/main/ets/im/ImClient'
|
||||||
|
|||||||
@ -21,6 +21,14 @@ export class XuqmSDK {
|
|||||||
return SDKContext.getToken()
|
return SDKContext.getToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static setUserId(userId: string | null): void {
|
||||||
|
SDKContext.setUserId(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
static getUserId(): string | null {
|
||||||
|
return SDKContext.getUserId()
|
||||||
|
}
|
||||||
|
|
||||||
static get im(): ImClient {
|
static get im(): ImClient {
|
||||||
if (!XuqmSDK._imClient) {
|
if (!XuqmSDK._imClient) {
|
||||||
XuqmSDK._imClient = new ImClient()
|
XuqmSDK._imClient = new ImClient()
|
||||||
|
|||||||
@ -3,10 +3,23 @@ import type { ApiResponse } from './Types'
|
|||||||
import { SDKContext } from './SDKContext'
|
import { SDKContext } from './SDKContext'
|
||||||
|
|
||||||
export class HttpClient {
|
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 config = SDKContext.getConfig()
|
||||||
const token = SDKContext.getToken()
|
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()
|
const client = http.createHttp()
|
||||||
try {
|
try {
|
||||||
@ -30,19 +43,19 @@ export class HttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static get<T>(path: string): Promise<T> {
|
static get<T>(path: string, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> {
|
||||||
return HttpClient.request<T>(http.RequestMethod.GET, path)
|
return HttpClient.request<T>(http.RequestMethod.GET, path, undefined, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
static post<T>(path: string, body?: object): Promise<T> {
|
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)
|
return HttpClient.request<T>(http.RequestMethod.POST, path, body, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
static put<T>(path: string, body?: object): Promise<T> {
|
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)
|
return HttpClient.request<T>(http.RequestMethod.PUT, path, body, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
static delete<T>(path: string): Promise<T> {
|
static delete<T>(path: string, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> {
|
||||||
return HttpClient.request<T>(http.RequestMethod.DELETE, path)
|
return HttpClient.request<T>(http.RequestMethod.DELETE, path, undefined, query)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const PREF_NAME = 'xuqm_sdk_prefs'
|
|||||||
export class SDKContext {
|
export class SDKContext {
|
||||||
private static _config: SDKConfig | null = null
|
private static _config: SDKConfig | null = null
|
||||||
private static _token: string | null = null
|
private static _token: string | null = null
|
||||||
|
private static _userId: string | null = null
|
||||||
private static _pref: preferences.Preferences | null = null
|
private static _pref: preferences.Preferences | null = null
|
||||||
|
|
||||||
static init(config: SDKConfig): void {
|
static init(config: SDKConfig): void {
|
||||||
@ -44,4 +45,12 @@ export class SDKContext {
|
|||||||
static getToken(): string | null {
|
static getToken(): string | null {
|
||||||
return SDKContext._token
|
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'
|
| 'RICH_TEXT'
|
||||||
| 'CALL_AUDIO'
|
| 'CALL_AUDIO'
|
||||||
| 'CALL_VIDEO'
|
| 'CALL_VIDEO'
|
||||||
|
| 'QUOTE'
|
||||||
|
| 'MERGE'
|
||||||
| 'REVOKED'
|
| 'REVOKED'
|
||||||
| 'FORWARD'
|
| 'FORWARD'
|
||||||
|
|
||||||
export type ChatType = 'SINGLE' | 'GROUP'
|
export type ChatType = 'SINGLE' | 'GROUP'
|
||||||
|
|
||||||
|
export type MsgStatus = 'SENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED' | 'REVOKED'
|
||||||
|
|
||||||
export interface ImMessage {
|
export interface ImMessage {
|
||||||
id: string
|
id: string
|
||||||
|
appId: string
|
||||||
|
fromUserId: string
|
||||||
fromId: string
|
fromId: string
|
||||||
toId: string
|
toId: string
|
||||||
chatType: ChatType
|
chatType: ChatType
|
||||||
msgType: MsgType
|
msgType: MsgType
|
||||||
content: string
|
content: string
|
||||||
extra?: string
|
status: MsgStatus
|
||||||
revoked: boolean
|
mentionedUserIds?: string
|
||||||
createdAt: 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 {
|
export interface SendMessageParams {
|
||||||
|
messageId?: string
|
||||||
toId: string
|
toId: string
|
||||||
chatType: ChatType
|
chatType: ChatType
|
||||||
msgType: MsgType
|
msgType: MsgType
|
||||||
content: string
|
content: string
|
||||||
extra?: string
|
mentionedUserIds?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppVersionInfo {
|
export interface AppVersionInfo {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import webSocket from '@ohos.net.webSocket'
|
import webSocket from '@ohos.net.webSocket'
|
||||||
import type { ImMessage, SendMessageParams } from '../core/Types'
|
import { HttpClient } from '../core/HttpClient'
|
||||||
import { SDKContext } from '../core/SDKContext'
|
import { SDKContext } from '../core/SDKContext'
|
||||||
|
import type { ChatType, ConversationData, HistoryQuery, ImMessage, MsgType, PageResult, SendMessageParams, UserProfile } from '../core/Types'
|
||||||
|
|
||||||
export interface ImEventDelegate {
|
export interface ImEventDelegate {
|
||||||
onConnected?(): void
|
onConnected?(): void
|
||||||
@ -41,9 +42,9 @@ export class ImClient {
|
|||||||
this.ws.on('message', (_err: Error, value: string | ArrayBuffer) => {
|
this.ws.on('message', (_err: Error, value: string | ArrayBuffer) => {
|
||||||
try {
|
try {
|
||||||
const text = typeof value === 'string' ? value : new TextDecoder().decode(value)
|
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') {
|
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') {
|
} else if (frame.type === 'REVOKE') {
|
||||||
this.delegate?.onRevoke?.(frame.payload as RevokeData)
|
this.delegate?.onRevoke?.(frame.payload as RevokeData)
|
||||||
}
|
}
|
||||||
@ -64,19 +65,133 @@ export class ImClient {
|
|||||||
this.ws.connect(url, {})
|
this.ws.connect(url, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
send(params: SendMessageParams): void {
|
send(params: SendMessageParams): ImMessage {
|
||||||
if (!this.ws) throw new Error('WebSocket not connected')
|
const outgoing = this.buildOutgoingMessage(params)
|
||||||
const frame = JSON.stringify({ destination: '/app/chat.send', payload: params })
|
if (!this.ws) {
|
||||||
this.ws.send(frame, (_err: Error) => {
|
return { ...outgoing, status: 'FAILED' }
|
||||||
if (_err) console.error('[ImClient] send error', _err.message)
|
}
|
||||||
})
|
|
||||||
|
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 {
|
revoke(msgId: string): void {
|
||||||
if (!this.ws) throw new Error('WebSocket not connected')
|
if (!this.ws) {
|
||||||
const frame = JSON.stringify({ destination: '/app/chat.revoke', payload: { msgId } })
|
throw new Error('WebSocket not connected')
|
||||||
this.ws.send(frame, (_err: Error) => {
|
}
|
||||||
if (_err) console.error('[ImClient] revoke error', _err.message)
|
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)
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||||
}, 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('')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户