feat(chat): 添加聊天界面和文件更新SDK功能

- 实现完整的聊天界面UI组件,支持文本、图片、视频、音频、文件等多种消息类型
- 集成IM消息收发功能,实现消息气泡显示和用户头像占位符
- 添加媒体文件选择和拍摄功能,支持相册图片、视频及相机拍照录像
- 实现语音录制和播放功能,包含按住说话交互和权限处理
- 添加群组提及功能,支持@用户和提及候选列表显示
- 实现消息回复和引用功能,支持消息长按回复操作
- 添加本地消息搜索功能,支持搜索当前会话的历史消息
- 实现文件上传下载功能,集成FileSDK进行文件传输管理
- 添加应用更新检查功能,集成UpdateSDK支持版本更新
- 实现消息状态显示,包括发送、送达、已读等状态标识
- 添加群组已读人数统计,显示消息在群聊中的阅读情况
- 实现草稿保存和恢复功能,支持断点续聊体验
- 添加连接状态横幅,实时显示IM服务连接状态
- 实现滚动加载更多历史消息,优化大量消息的性能表现
- 添加多媒体文件下载保存功能,支持保存到应用专属目录
这个提交包含在:
XuqmGroup 2026-04-28 20:11:37 +08:00
父节点 930c8f36ae
当前提交 d7f156f160
共有 36 个文件被更改,包括 85180 次插入142 次删除

1
.hvigor/cache/file-cache.json vendored 普通文件

文件差异因一行或多行过长而隐藏

1
.hvigor/cache/meta.json vendored 普通文件
查看文件

@ -0,0 +1 @@
{"compileSdkVersion":"6.0.2(22)","hvigorVersion":"6.22.4","toolChainsVersion":"6.0.2.130"}

1
.hvigor/cache/task-cache.json vendored 普通文件

文件差异因一行或多行过长而隐藏

查看文件

@ -0,0 +1 @@
{"basePath":"/Users/xuqinmin/Projects/XuqmGroup/XuqmGroup-HarmonySDK/.hvigor/dependencyMap/dependencyMap.json5","rootDependency":"./oh-package.json5","dependencyMap":{"xuqmSdk":"./xuqmSdk/oh-package.json5","entry":"./entry/oh-package.json5"},"modules":[{"name":"xuqmSdk","srcPath":"../../../xuqm-sdk"},{"name":"entry","srcPath":"../../../entry"}]}

查看文件

@ -0,0 +1 @@
{"name":"entry","version":"1.0.0","description":"SDK sample app","main":"","author":"","license":"MIT","dependencies":{"@xuqm/harmony-sdk":"file:../xuqm-sdk"}}

查看文件

@ -0,0 +1 @@
{"name":"xuqm-harmony-sdk-workspace","version":"0.1.0","modelVersion":"5.0.0","description":"XuqmGroup HarmonyOS SDK workspace","author":"xuqm","license":"MIT"}

查看文件

@ -0,0 +1 @@
{"name":"@xuqm/harmony-sdk","version":"0.1.0","description":"XuqmGroup HarmonyOS SDK — IM, Push, Version Management","main":"Index.ets","author":"xuqm","license":"MIT","publishConfig":{"registry":"https://ohpm.openharmony.cn/ohpm/"},"dependencies":{}}

查看文件

@ -0,0 +1,70 @@
{
"HVIGOR_OHOS_PLUGIN": {
"MODULES": [
{
"MODULE_NAME": "77aabe6c19463543339f337db9c84e4d10fd2f56ea0aedaf85a0214d59e93ec4",
"API_TYPE": "stageMode",
"INCLUDE_IN_BUILD": true,
"IS_COMMAND_LINE_ENTRY_MODULE": false,
"MODULE_TYPE": "har",
"IS_INCREMENTAL_MODULE": true
},
{
"MODULE_NAME": "923fe53966c6cd9343e11af776cd4b05be315ea4b200b02e4d5dfb0f929b73bf",
"API_TYPE": "stageMode",
"INCLUDE_IN_BUILD": true,
"IS_COMMAND_LINE_ENTRY_MODULE": false,
"MODULE_TYPE": "entry",
"INCREMENTAL_TASKS": {
"COMPILE_ARKTS": false
},
"IS_INCREMENTAL_MODULE": false
}
],
"NATIVE_COMPILER": "Default",
"IS_FULL_BUILD": true,
"BUILD_MODE": "debug"
},
"HVIGOR": {
"IS_INCREMENTAL": true,
"IS_DAEMON": false,
"IS_PARALLEL": true,
"IS_HVIGORFILE_TYPE_CHECK": false,
"TASK_TIME": {
"923fe53966c6cd9343e11af776cd4b05be315ea4b200b02e4d5dfb0f929b73bf": {
"CreateModuleInfo": 327208,
"PreCheckSyscap": 124792,
"ProcessIntegratedHsp": 226959,
"SyscapTransform": 6058625,
"ProcessStartupConfig": 545625,
"ConfigureCmake": 60125,
"BuildNativeWithCmake": 69833,
"BuildNativeWithNinja": 146584,
"BuildJS": 700000,
"CompileArkTS": 2592629166,
"GeneratePkgModuleJson": 988958,
"ProcessCompiledResources": 154291,
"PackageHap": 252960708,
"PackingCheck": 2527625,
"SignHap": 375083,
"CollectDebugSymbol": 263875,
"assembleHap": 49500
},
"77aabe6c19463543339f337db9c84e4d10fd2f56ea0aedaf85a0214d59e93ec4": {
"ConfigureCmake": 69459,
"BuildNativeWithCmake": 70958,
"BuildNativeWithNinja": 202959
}
},
"APIS": [
"getProperty"
],
"CONFIG_EXPERIMENT": {
"ENABLE_MODULE_SKIP": false,
"ENABLE_CPP_FUNCTION_LEVEL_INCREMENTAL": false
},
"CONFIG_PROPERTIES": {},
"BUILD_ID": "202604281921524500",
"TOTAL_TIME": 3511224541
}
}

文件差异内容过多而无法显示 加载差异

文件差异内容过多而无法显示 加载差异

文件差异内容过多而无法显示 加载差异

文件差异内容过多而无法显示 加载差异

文件差异内容过多而无法显示 加载差异

文件差异因一行或多行过长而隐藏

文件差异因一行或多行过长而隐藏

文件差异内容过多而无法显示 加载差异

文件差异内容过多而无法显示 加载差异

文件差异内容过多而无法显示 加载差异

10
AppScope/app.json5 普通文件
查看文件

@ -0,0 +1,10 @@
{
"app": {
"bundleName": "com.xuqmgroup.harmony.sdk",
"vendor": "xuqm",
"versionCode": 1000000,
"versionName": "1.0.0",
"icon": "$media:app_icon",
"label": "$string:EntryAbility_label"
}
}

查看文件

@ -1,6 +1,7 @@
# XuqmGroup HarmonyOS SDK 文档 # XuqmGroup HarmonyOS SDK 文档
> ArkTS · HarmonyOS 5 (API 12) · 发布至 ohpm > ArkTS · HarmonyOS 5 (API 12) · 发布至 ohpm
> 当前工程已可成功执行 `hvigorw assembleHap`
## 模块结构 ## 模块结构

查看文件

@ -22,7 +22,7 @@
}, },
"modules": [ "modules": [
{ {
"name": "xuqm-sdk", "name": "xuqmSdk",
"srcPath": "./xuqm-sdk", "srcPath": "./xuqm-sdk",
"targets": [ "targets": [
{ "name": "default", "applyToProducts": ["default"] } { "name": "default", "applyToProducts": ["default"] }

19
entry/oh-package-lock.json5 普通文件
查看文件

@ -0,0 +1,19 @@
{
"meta": {
"stableOrder": true,
"enableUnifiedLockfile": false
},
"lockfileVersion": 3,
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
"specifiers": {
"@xuqm/harmony-sdk@../xuqm-sdk": "@xuqm/harmony-sdk@../xuqm-sdk"
},
"packages": {
"@xuqm/harmony-sdk@../xuqm-sdk": {
"name": "@xuqm/harmony-sdk",
"version": "0.1.0",
"resolved": "../xuqm-sdk",
"registryType": "local"
}
}
}

查看文件

@ -0,0 +1 @@
../../../xuqm-sdk

查看文件

@ -1,6 +1,42 @@
import { XuqmSDK, ImMessage } from '@xuqm/harmony-sdk' import { XuqmSDK, ImMessage } from '@xuqm/harmony-sdk'
import type { ImEventDelegate } from '@xuqm/harmony-sdk'
import promptAction from '@ohos.promptAction' import promptAction from '@ohos.promptAction'
class DemoImDelegate implements ImEventDelegate {
private readonly onConnectedAction: () => void
private readonly onDisconnectedAction: (code: number, reason: string) => void
private readonly onMessageAction: (msg: ImMessage) => void
private readonly onErrorAction: (message: string) => void
constructor(
onConnectedAction: () => void,
onDisconnectedAction: (code: number, reason: string) => void,
onMessageAction: (msg: ImMessage) => void,
onErrorAction: (message: string) => void,
) {
this.onConnectedAction = onConnectedAction
this.onDisconnectedAction = onDisconnectedAction
this.onMessageAction = onMessageAction
this.onErrorAction = onErrorAction
}
onConnected(): void {
this.onConnectedAction()
}
onDisconnected(code: number, reason: string): void {
this.onDisconnectedAction(code, reason)
}
onMessage(msg: ImMessage): void {
this.onMessageAction(msg)
}
onError(message: string): void {
this.onErrorAction(message)
}
}
@Entry @Entry
@Component @Component
struct Index { struct Index {
@ -11,22 +47,23 @@ struct Index {
aboutToAppear(): void { aboutToAppear(): void {
const im = XuqmSDK.im const im = XuqmSDK.im
im.delegate = { const delegate = new DemoImDelegate(
onConnected: () => { () => {
this.connected = true this.connected = true
promptAction.showToast({ message: 'IM 已连接' }) promptAction.showToast({ message: 'IM 已连接' })
}, },
onDisconnected: (code, reason) => { (code: number, reason: string) => {
this.connected = false this.connected = false
console.log(`IM disconnected: ${code} ${reason}`) console.log('IM disconnected: ' + code + ' ' + reason)
}, },
onMessage: (msg) => { (msg: ImMessage) => {
this.messages = [...this.messages, msg] this.messages = [...this.messages, msg]
}, },
onError: (err) => { (err: string) => {
promptAction.showToast({ message: 'IM 错误: ' + err }) promptAction.showToast({ message: 'IM 错误: ' + err })
}, },
} )
im.delegate = delegate
im.connect() im.connect()
} }

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 68 B

19
hvigor/hvigor-config.json5 普通文件
查看文件

@ -0,0 +1,19 @@
{
"modelVersion": "5.0.0",
"dependencies": {},
"execution": {
"daemon": false,
"incremental": true,
"parallel": true,
"typeCheck": false
},
"logging": {
"level": "info"
},
"debugging": {
"stacktrace": false
},
"nodeOptions": {
"maxOldSpaceSize": 4096
}
}

查看文件

@ -1,6 +1,7 @@
{ {
"name": "xuqm-harmony-sdk-workspace", "name": "xuqm-harmony-sdk-workspace",
"version": "0.1.0", "version": "0.1.0",
"modelVersion": "5.0.0",
"description": "XuqmGroup HarmonyOS SDK workspace", "description": "XuqmGroup HarmonyOS SDK workspace",
"author": "xuqm", "author": "xuqm",
"license": "MIT" "license": "MIT"

48
oh_modules/.ohpm/lock.json5 普通文件
查看文件

@ -0,0 +1,48 @@
{
"lockVersion": "1.0",
"settings": {
"resolveConflict": true,
"resolveConflictStrict": false,
"installAll": true
},
"overrides": {},
"overrideDependencyMap": {},
"modules": {
".": {
"name": "",
"dependencies": {},
"devDependencies": {},
"dynamicDependencies": {},
"maskedByOverrideDependencyMap": false
},
"entry": {
"name": "entry",
"dependencies": {
"@xuqm/harmony-sdk": {
"specifier": "file:xuqm-sdk",
"version": "file:xuqm-sdk"
}
},
"devDependencies": {},
"dynamicDependencies": {},
"maskedByOverrideDependencyMap": false
},
"xuqm-sdk": {
"name": "xuqm-sdk",
"dependencies": {},
"devDependencies": {},
"dynamicDependencies": {},
"maskedByOverrideDependencyMap": false
}
},
"packages": {
"@xuqm/harmony-sdk@file:xuqm-sdk": {
"storePath": "xuqm-sdk",
"dependencies": {},
"dynamicDependencies": {},
"dev": false,
"dynamic": false,
"maskedByOverrideDependencyMap": false
}
}
}

17
xuqm-sdk/BuildProfile.ets 普通文件
查看文件

@ -0,0 +1,17 @@
/**
* Use these variables when you tailor your ArkTS code. They must be of the const type.
*/
export const HAR_VERSION = '0.1.0';
export const BUILD_MODE_NAME = 'debug';
export const DEBUG = true;
export const TARGET_NAME = 'default';
/**
* BuildProfile Class is used only for compatibility purposes.
*/
export default class BuildProfile {
static readonly HAR_VERSION = HAR_VERSION;
static readonly BUILD_MODE_NAME = BUILD_MODE_NAME;
static readonly DEBUG = DEBUG;
static readonly TARGET_NAME = TARGET_NAME;
}

查看文件

@ -15,6 +15,9 @@ export type {
ChatType, ChatType,
MsgStatus, MsgStatus,
ConversationData, ConversationData,
FriendRequest,
GroupJoinRequest,
ImGroup,
HistoryQuery, HistoryQuery,
PageResult, PageResult,
UserProfile, UserProfile,

查看文件

@ -2,8 +2,6 @@ import common from '@ohos.app.ability.common'
import type { SDKConfig } from './core/Types' import type { SDKConfig } from './core/Types'
import { SDKContext } from './core/SDKContext' import { SDKContext } from './core/SDKContext'
import { ImClient } from './im/ImClient' import { ImClient } from './im/ImClient'
import { PushSDK } from './push/PushSDK'
import { UpdateSDK } from './update/UpdateSDK'
export class XuqmSDK { export class XuqmSDK {
private static _imClient: ImClient | null = null private static _imClient: ImClient | null = null
@ -35,12 +33,4 @@ export class XuqmSDK {
} }
return XuqmSDK._imClient return XuqmSDK._imClient
} }
static get push(): typeof PushSDK {
return PushSDK
}
static get update(): typeof UpdateSDK {
return UpdateSDK
}
} }

查看文件

@ -1,35 +1,30 @@
import http from '@ohos.net.http' import http from '@ohos.net.http'
import type { ApiResponse } from './Types' import type { ApiResponse, HttpHeaders } from './Types'
import { SDKContext } from './SDKContext' import { SDKContext } from './SDKContext'
export class HttpClient { export class HttpClient {
static async request<T>( static async request<T>(
method: http.RequestMethod, method: http.RequestMethod,
path: string, path: string,
body?: object, body?: Object,
query?: Record<string, string | number | boolean | Date | null | undefined>, query?: string,
): Promise<T> { ): Promise<T> {
const config = SDKContext.getConfig() const config = SDKContext.getConfig()
const token = SDKContext.getToken() const token = SDKContext.getToken()
const queryPairs: string[] = [] const url = config.apiBaseUrl.replace(/\/$/, '') + path + (query ? '?' + query : '')
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 {
const header: HttpHeaders = {
'Content-Type': 'application/json',
}
if (token) {
header.Authorization = 'Bearer ' + token
}
const options: http.HttpRequestOptions = { const options: http.HttpRequestOptions = {
method, method,
header: { header,
'Content-Type': 'application/json', extraData: body ? JSON.stringify(body) : '',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
},
extraData: body ? JSON.stringify(body) : undefined,
connectTimeout: 15000, connectTimeout: 15000,
readTimeout: 15000, readTimeout: 15000,
} }
@ -43,19 +38,19 @@ export class HttpClient {
} }
} }
static get<T>(path: string, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> { static get<T>(path: string, query?: string): Promise<T> {
return HttpClient.request<T>(http.RequestMethod.GET, path, undefined, query) return HttpClient.request<T>(http.RequestMethod.GET, path, undefined, query)
} }
static post<T>(path: string, body?: object, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> { static post<T>(path: string, body?: Object, query?: string): Promise<T> {
return HttpClient.request<T>(http.RequestMethod.POST, path, body, query) return HttpClient.request<T>(http.RequestMethod.POST, path, body, query)
} }
static put<T>(path: string, body?: object, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> { static put<T>(path: string, body?: Object, query?: string): Promise<T> {
return HttpClient.request<T>(http.RequestMethod.PUT, path, body, query) return HttpClient.request<T>(http.RequestMethod.PUT, path, body, query)
} }
static delete<T>(path: string, query?: Record<string, string | number | boolean | Date | null | undefined>): Promise<T> { static delete<T>(path: string, query?: string): Promise<T> {
return HttpClient.request<T>(http.RequestMethod.DELETE, path, undefined, query) return HttpClient.request<T>(http.RequestMethod.DELETE, path, undefined, query)
} }
} }

查看文件

@ -13,6 +13,11 @@ export interface ApiResponse<T> {
message: string message: string
} }
export interface HttpHeaders {
'Content-Type': string
Authorization?: string
}
export type MsgType = export type MsgType =
| 'TEXT' | 'TEXT'
| 'IMAGE' | 'IMAGE'
@ -93,6 +98,40 @@ export interface UserProfile {
createdAt?: number | null createdAt?: number | null
} }
export interface ImGroup {
id: string
appId?: string
name: string
groupType?: string
creatorId: string
memberIds: string
adminIds: string
announcement?: string | null
createdAt?: number | null
}
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 SendMessageParams { export interface SendMessageParams {
messageId?: string messageId?: string
toId: string toId: string

查看文件

@ -1,7 +1,19 @@
import webSocket from '@ohos.net.webSocket' import webSocket from '@ohos.net.webSocket'
import { HttpClient } from '../core/HttpClient' 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' import type {
ChatType,
ConversationData,
FriendRequest,
GroupJoinRequest,
HistoryQuery,
ImGroup,
ImMessage,
MsgType,
PageResult,
SendMessageParams,
UserProfile,
} from '../core/Types'
export interface ImEventDelegate { export interface ImEventDelegate {
onConnected?(): void onConnected?(): void
@ -16,6 +28,89 @@ export interface RevokeData {
operatorId: string operatorId: string
} }
class WebSocketFrame {
type: string = ''
payload: Object = new Object()
}
class WebSocketEnvelope {
destination: string = ''
payload: Object = new Object()
}
class AppBody {
appId: string = ''
}
class HistoryQueryParams {
appId: string = ''
page: number = 0
size: number = 0
msgType: MsgType = 'TEXT'
keyword: string = ''
startTime: string = ''
endTime: string = ''
}
class ConversationActionBody {
appId: string = ''
chatType: ChatType = 'SINGLE'
}
class FriendRequestQueryBody {
appId: string = ''
direction: 'incoming' | 'outgoing' = 'incoming'
}
class FriendRequestBody {
appId: string = ''
toUserId: string = ''
remark: string = ''
}
class GroupJoinRequestBody {
appId: string = ''
remark: string = ''
}
class UpdateProfileBody {
appId: string = ''
nickname: string = ''
avatar: string = ''
gender: string = ''
}
class DraftBody {
appId: string = ''
chatType: ChatType = 'SINGLE'
draft: string = ''
}
class PinBody {
appId: string = ''
chatType: ChatType = 'SINGLE'
pinned: boolean = false
}
class MuteBody {
appId: string = ''
chatType: ChatType = 'SINGLE'
muted: boolean = false
}
class SendEnvelopePayload {
messageId: string = ''
toId: string = ''
chatType: ChatType = 'SINGLE'
msgType: MsgType = 'TEXT'
content: string = ''
mentionedUserIds: string = ''
}
class RevokeEnvelopePayload {
msgId: string = ''
}
const MAX_RECONNECT_DELAY = 30_000 const MAX_RECONNECT_DELAY = 30_000
export class ImClient { export class ImClient {
@ -29,7 +124,7 @@ export class ImClient {
if (this.destroyed) return if (this.destroyed) return
const config = SDKContext.getConfig() const config = SDKContext.getConfig()
const token = SDKContext.getToken() ?? '' const token = SDKContext.getToken() ?? ''
const url = `${config.imBaseUrl}/ws/im?token=${token}` const url = config.imBaseUrl.replace(/\/$/, '') + '/ws/im?token=' + encodeURIComponent(token)
this.ws = webSocket.createWebSocket() this.ws = webSocket.createWebSocket()
@ -41,8 +136,10 @@ 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) if (typeof value !== 'string') {
const frame = JSON.parse(text) as { type: string; payload: unknown } return
}
const frame = JSON.parse(value) as WebSocketFrame
if (frame.type === 'MESSAGE') { if (frame.type === 'MESSAGE') {
this.delegate?.onMessage?.(this.normalizeMessage(frame.payload as ImMessage)) this.delegate?.onMessage?.(this.normalizeMessage(frame.payload as ImMessage))
} else if (frame.type === 'REVOKE') { } else if (frame.type === 'REVOKE') {
@ -68,23 +165,27 @@ export class ImClient {
send(params: SendMessageParams): ImMessage { send(params: SendMessageParams): ImMessage {
const outgoing = this.buildOutgoingMessage(params) const outgoing = this.buildOutgoingMessage(params)
if (!this.ws) { if (!this.ws) {
return { ...outgoing, status: 'FAILED' } return this.markFailed(outgoing)
} }
this.ws.send( const payload = new SendEnvelopePayload()
JSON.stringify({ payload.messageId = outgoing.id
destination: '/app/chat.send', payload.toId = params.toId
payload: { payload.chatType = params.chatType
...params, payload.msgType = params.msgType
messageId: outgoing.id, payload.content = params.content
}, if (params.mentionedUserIds !== undefined) {
}), payload.mentionedUserIds = params.mentionedUserIds
(_err: Error) => { }
if (_err) { const envelope = new WebSocketEnvelope()
this.delegate?.onError?.(_err.message) envelope.destination = '/app/chat.send'
} envelope.payload = payload
},
) this.ws.send(JSON.stringify(envelope), (_err: Error) => {
if (_err) {
this.delegate?.onError?.(_err.message)
}
})
return outgoing return outgoing
} }
@ -92,15 +193,14 @@ export class ImClient {
if (!this.ws) { if (!this.ws) {
throw new Error('WebSocket not connected') throw new Error('WebSocket not connected')
} }
this.ws.send( const payload = new RevokeEnvelopePayload()
JSON.stringify({ payload.msgId = msgId
destination: '/app/chat.revoke', const envelope = new WebSocketEnvelope()
payload: { msgId }, envelope.destination = '/app/chat.revoke'
}), envelope.payload = payload
(_err: Error) => { this.ws.send(JSON.stringify(envelope), (_err: Error) => {
if (_err) this.delegate?.onError?.(_err.message) if (_err) this.delegate?.onError?.(_err.message)
}, })
)
} }
async fetchHistory( async fetchHistory(
@ -109,19 +209,8 @@ export class ImClient {
size: number = 20, size: number = 20,
query: HistoryQuery = {}, query: HistoryQuery = {},
): Promise<PageResult<ImMessage>> { ): Promise<PageResult<ImMessage>> {
return HttpClient.get<PageResult<ImMessage>>(`/api/im/messages/history/${encodeURIComponent(toId)}`, { const queryString = this.buildHistoryQuery(page, size, query)
appId: SDKContext.getConfig().appKey, return HttpClient.get<PageResult<ImMessage>>('/api/im/messages/history/' + encodeURIComponent(toId), queryString)
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( async fetchGroupHistory(
@ -130,55 +219,109 @@ export class ImClient {
size: number = 50, size: number = 50,
query: HistoryQuery = {}, query: HistoryQuery = {},
): Promise<PageResult<ImMessage>> { ): Promise<PageResult<ImMessage>> {
return HttpClient.get<PageResult<ImMessage>>(`/api/im/messages/group-history/${encodeURIComponent(groupId)}`, { const queryString = this.buildHistoryQuery(page, size, query)
appId: SDKContext.getConfig().appKey, return HttpClient.get<PageResult<ImMessage>>('/api/im/messages/group-history/' + encodeURIComponent(groupId), queryString)
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[]> { async listConversations(size: number = 20): Promise<ConversationData[]> {
return HttpClient.get<ConversationData[]>('/api/im/conversations', { return HttpClient.get<ConversationData[]>('/api/im/conversations', this.buildConversationQuery(size))
appId: SDKContext.getConfig().appKey,
page: 0,
size,
})
} }
async markRead(targetId: string, chatType: ChatType = 'SINGLE'): Promise<void> { async markRead(targetId: string, chatType: ChatType = 'SINGLE'): Promise<void> {
await HttpClient.put<void>(`/api/im/conversations/${encodeURIComponent(targetId)}/read`, undefined, { await HttpClient.put<void>('/api/im/conversations/' + encodeURIComponent(targetId) + '/read', undefined, this.buildConversationActionQuery(chatType))
appId: SDKContext.getConfig().appKey,
chatType,
})
} }
async setDraft(targetId: string, chatType: ChatType, draft: string): Promise<void> { async setDraft(targetId: string, chatType: ChatType, draft: string): Promise<void> {
await HttpClient.put<void>(`/api/im/conversations/${encodeURIComponent(targetId)}/draft`, undefined, { const params = new DraftBody()
appId: SDKContext.getConfig().appKey, params.appId = SDKContext.getConfig().appKey
chatType, params.chatType = chatType
draft, params.draft = draft
}) await HttpClient.put<void>('/api/im/conversations/' + encodeURIComponent(targetId) + '/draft', params)
}
async setConversationPinned(targetId: string, chatType: ChatType, pinned: boolean): Promise<void> {
const params = new PinBody()
params.appId = SDKContext.getConfig().appKey
params.chatType = chatType
params.pinned = pinned
await HttpClient.put<void>('/api/im/conversations/' + encodeURIComponent(targetId) + '/pinned', params)
}
async setConversationMuted(targetId: string, chatType: ChatType, muted: boolean): Promise<void> {
const params = new MuteBody()
params.appId = SDKContext.getConfig().appKey
params.chatType = chatType
params.muted = muted
await HttpClient.put<void>('/api/im/conversations/' + encodeURIComponent(targetId) + '/muted', params)
} }
async deleteConversation(targetId: string, chatType: ChatType): Promise<void> { async deleteConversation(targetId: string, chatType: ChatType): Promise<void> {
await HttpClient.delete<void>(`/api/im/conversations/${encodeURIComponent(targetId)}`, { await HttpClient.delete<void>('/api/im/conversations/' + encodeURIComponent(targetId), this.buildConversationActionQuery(chatType))
appId: SDKContext.getConfig().appKey, }
chatType,
}) async listFriends(): Promise<string[]> {
return HttpClient.get<string[]>('/api/im/friends', this.buildAppQuery())
}
async listGroups(): Promise<ImGroup[]> {
return HttpClient.get<ImGroup[]>('/api/im/groups', this.buildAppQuery())
}
async getGroupInfo(groupId: string): Promise<ImGroup> {
return HttpClient.get<ImGroup>('/api/im/groups/' + encodeURIComponent(groupId), this.buildAppQuery())
}
async sendFriendRequest(toUserId: string, remark: string | null = null): Promise<FriendRequest> {
const params = new FriendRequestBody()
params.appId = SDKContext.getConfig().appKey
params.toUserId = toUserId
if (remark !== null && remark !== '') {
params.remark = remark
}
return HttpClient.post<FriendRequest>('/api/im/friend-requests', params)
}
async listFriendRequests(direction: 'incoming' | 'outgoing' = 'incoming'): Promise<FriendRequest[]> {
return HttpClient.get<FriendRequest[]>('/api/im/friend-requests', this.buildFriendRequestQuery(direction))
}
async acceptFriendRequest(requestId: string): Promise<FriendRequest> {
return HttpClient.post<FriendRequest>('/api/im/friend-requests/' + encodeURIComponent(requestId) + '/accept', this.buildAppBody())
}
async rejectFriendRequest(requestId: string): Promise<FriendRequest> {
return HttpClient.post<FriendRequest>('/api/im/friend-requests/' + encodeURIComponent(requestId) + '/reject', this.buildAppBody())
}
async sendGroupJoinRequest(groupId: string, remark: string | null = null): Promise<GroupJoinRequest> {
const params = new GroupJoinRequestBody()
params.appId = SDKContext.getConfig().appKey
if (remark !== null && remark !== '') {
params.remark = remark
}
return HttpClient.post<GroupJoinRequest>('/api/im/groups/' + encodeURIComponent(groupId) + '/join-requests', params)
}
async listGroupJoinRequests(groupId: string): Promise<GroupJoinRequest[]> {
return HttpClient.get<GroupJoinRequest[]>('/api/im/groups/' + encodeURIComponent(groupId) + '/join-requests', this.buildAppQuery())
}
async acceptGroupJoinRequest(groupId: string, requestId: string): Promise<GroupJoinRequest> {
return HttpClient.post<GroupJoinRequest>(
'/api/im/groups/' + encodeURIComponent(groupId) + '/join-requests/' + encodeURIComponent(requestId) + '/accept',
this.buildAppBody(),
)
}
async rejectGroupJoinRequest(groupId: string, requestId: string): Promise<GroupJoinRequest> {
return HttpClient.post<GroupJoinRequest>(
'/api/im/groups/' + encodeURIComponent(groupId) + '/join-requests/' + encodeURIComponent(requestId) + '/reject',
this.buildAppBody(),
)
} }
async getProfile(userId: string): Promise<UserProfile> { async getProfile(userId: string): Promise<UserProfile> {
return HttpClient.get<UserProfile>(`/api/im/accounts/${encodeURIComponent(userId)}`, { return HttpClient.get<UserProfile>('/api/im/accounts/' + encodeURIComponent(userId), this.buildAppQuery())
appId: SDKContext.getConfig().appKey,
})
} }
async updateProfile( async updateProfile(
@ -187,12 +330,62 @@ export class ImClient {
avatar: string | null = null, avatar: string | null = null,
gender: string | null = null, gender: string | null = null,
): Promise<UserProfile> { ): Promise<UserProfile> {
return HttpClient.put<UserProfile>(`/api/im/accounts/${encodeURIComponent(userId)}`, undefined, { const params = new UpdateProfileBody()
appId: SDKContext.getConfig().appKey, params.appId = SDKContext.getConfig().appKey
...(nickname !== null ? { nickname } : {}), if (nickname !== null) {
...(avatar !== null ? { avatar } : {}), params.nickname = nickname
...(gender !== null ? { gender } : {}), }
}) if (avatar !== null) {
params.avatar = avatar
}
if (gender !== null) {
params.gender = gender
}
return HttpClient.put<UserProfile>('/api/im/accounts/' + encodeURIComponent(userId), params)
}
private buildAppBody(): AppBody {
const body = new AppBody()
body.appId = SDKContext.getConfig().appKey
return body
}
private buildAppQuery(): string {
return 'appId=' + encodeURIComponent(SDKContext.getConfig().appKey)
}
private buildConversationActionQuery(chatType: ChatType): string {
return 'appId=' + encodeURIComponent(SDKContext.getConfig().appKey) + '&chatType=' + encodeURIComponent(chatType)
}
private buildConversationQuery(size: number): string {
return 'appId=' + encodeURIComponent(SDKContext.getConfig().appKey) + '&page=0&size=' + encodeURIComponent(size)
}
private buildFriendRequestQuery(direction: 'incoming' | 'outgoing'): string {
return 'appId=' + encodeURIComponent(SDKContext.getConfig().appKey) + '&direction=' + encodeURIComponent(direction)
}
private buildHistoryQuery(page: number, size: number, query: HistoryQuery): string {
const parts: string[] = []
parts.push('appId=' + encodeURIComponent(SDKContext.getConfig().appKey))
parts.push('page=' + encodeURIComponent(page))
parts.push('size=' + encodeURIComponent(size))
if (query.msgType !== undefined) {
parts.push('msgType=' + encodeURIComponent(query.msgType))
}
if (query.keyword !== undefined && query.keyword !== '') {
parts.push('keyword=' + encodeURIComponent(query.keyword))
}
if (query.startTime !== undefined) {
const startValue = query.startTime instanceof Date ? this.formatDateTime(query.startTime) : String(query.startTime)
parts.push('startTime=' + encodeURIComponent(startValue))
}
if (query.endTime !== undefined) {
const endValue = query.endTime instanceof Date ? this.formatDateTime(query.endTime) : String(query.endTime)
parts.push('endTime=' + encodeURIComponent(endValue))
}
return parts.join('&')
} }
disconnect(): void { disconnect(): void {
@ -208,7 +401,7 @@ export class ImClient {
private scheduleReconnect(): void { private scheduleReconnect(): void {
if (this.destroyed) return if (this.destroyed) return
const delay = this.reconnectDelay const delay = this.reconnectDelay
if (SDKContext.getConfig().debug) console.log(`[ImClient] reconnect in ${delay}ms`) if (SDKContext.getConfig().debug) console.log('[ImClient] reconnect in ' + delay + 'ms')
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = setTimeout(() => {
this.connect() this.connect()
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY) this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY)
@ -219,7 +412,7 @@ export class ImClient {
const messageId = params.messageId ?? this.generateMessageId() const messageId = params.messageId ?? this.generateMessageId()
const userId = SDKContext.getUserId() ?? '' const userId = SDKContext.getUserId() ?? ''
const appId = SDKContext.getConfig().appKey const appId = SDKContext.getConfig().appKey
return { const message: ImMessage = {
id: messageId, id: messageId,
appId, appId,
fromUserId: userId, fromUserId: userId,
@ -234,25 +427,55 @@ export class ImClient {
revoked: false, revoked: false,
createdAt: Date.now(), createdAt: Date.now(),
} }
return message
}
private markFailed(message: ImMessage): ImMessage {
const failed: ImMessage = {
id: message.id,
appId: message.appId,
fromUserId: message.fromUserId,
fromId: message.fromId,
toId: message.toId,
chatType: message.chatType,
msgType: message.msgType,
content: message.content,
status: 'FAILED',
mentionedUserIds: message.mentionedUserIds,
groupReadCount: message.groupReadCount,
revoked: message.revoked,
createdAt: message.createdAt,
}
return failed
} }
private normalizeMessage(message: ImMessage): ImMessage { private normalizeMessage(message: ImMessage): ImMessage {
return { const normalized: ImMessage = {
...message, id: message.id,
fromId: message.fromId ?? message.fromUserId, appId: message.appId || SDKContext.getConfig().appKey,
revoked: message.revoked ?? message.status === 'REVOKED', fromUserId: message.fromUserId,
appId: message.appId ?? SDKContext.getConfig().appKey, fromId: message.fromId || message.fromUserId,
toId: message.toId,
chatType: message.chatType,
msgType: message.msgType,
content: message.content,
status: message.status,
mentionedUserIds: message.mentionedUserIds,
groupReadCount: message.groupReadCount,
revoked: message.revoked || message.status === 'REVOKED',
createdAt: message.createdAt,
} }
return normalized
} }
private generateMessageId(): string { private generateMessageId(): string {
const cryptoId = globalThis.crypto?.randomUUID?.() return 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000000).toString(16)
if (cryptoId) return cryptoId
return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`
} }
private formatDateTime(value: Date): string { private formatDateTime(value: Date): string {
const pad = (n: number) => String(n).padStart(2, '0') const pad = (n: number): string => {
return n < 10 ? '0' + n : String(n)
}
return [ return [
value.getFullYear(), value.getFullYear(),
'-', '-',
@ -267,4 +490,14 @@ export class ImClient {
pad(value.getSeconds()), pad(value.getSeconds()),
].join('') ].join('')
} }
private toStringValue(value: Date | string | number | undefined): string | undefined {
if (value === undefined) {
return undefined
}
if (value instanceof Date) {
return this.formatDateTime(value)
}
return String(value)
}
} }

查看文件

@ -1,5 +1,17 @@
import { HttpClient } from '../core/HttpClient' import { HttpClient } from '../core/HttpClient'
import type { PushTokenInfo } from '../core/Types'
class PushRegisterBody {
vendor: string = 'HARMONY'
token: string = ''
platform: string = 'harmony'
imUserId: string | null = null
}
class PushUnregisterBody {
vendor: string = 'HARMONY'
token: string = ''
platform: string = 'harmony'
}
export class PushSDK { export class PushSDK {
/** /**
@ -8,21 +20,20 @@ export class PushSDK {
* vendor should be 'HARMONY' for HarmonyOS devices. * vendor should be 'HARMONY' for HarmonyOS devices.
*/ */
static async registerToken(token: string, imUserId?: string): Promise<void> { static async registerToken(token: string, imUserId?: string): Promise<void> {
const body: PushTokenInfo = { const body = new PushRegisterBody()
vendor: 'HARMONY', body.token = token
token, if (imUserId !== undefined) {
platform: 'harmony', body.imUserId = imUserId
} }
await HttpClient.post<void>('/api/v1/push/register', { await HttpClient.post<void>('/api/v1/push/register', body)
...body,
imUserId: imUserId ?? null,
})
} }
/** /**
* Unregister push token on logout. * Unregister push token on logout.
*/ */
static async unregisterToken(token: string): Promise<void> { static async unregisterToken(token: string): Promise<void> {
await HttpClient.post<void>('/api/v1/push/unregister', { token, platform: 'harmony' }) const body = new PushUnregisterBody()
body.token = token
await HttpClient.post<void>('/api/v1/push/unregister', body)
} }
} }

查看文件

@ -1,6 +1,6 @@
{ {
"module": { "module": {
"name": "xuqm-sdk", "name": "xuqmSdk",
"type": "har", "type": "har",
"deviceTypes": ["phone", "tablet"] "deviceTypes": ["phone", "tablet"]
} }