feat: add rn sdk chat and update support

这个提交包含在:
XuqmGroup 2026-04-24 10:42:11 +08:00
父节点 61ffd1bc8c
当前提交 b747901cfe
共有 9 个文件被更改,包括 3309 次插入54 次删除

2968
package-lock.json 自动生成的 普通文件

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

查看文件

@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "XuqmGroup React Native SDK — IM, Push, Version Management", "description": "XuqmGroup React Native SDK — IM, Push, Version Management",
"main": "src/index.ts", "main": "src/index.ts",
"react-native": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
"private": false, "private": false,
"publishConfig": { "publishConfig": {

26
src/core/sdk.ts 普通文件
查看文件

@ -0,0 +1,26 @@
import { getConfig, initSDK, type XuqmSDKConfig } from './config'
import { clearToken, getToken, saveToken } from './http'
export const XuqmSDK = {
init(config: XuqmSDKConfig) {
initSDK(config)
},
getConfig,
async getToken() {
return getToken()
},
async setToken(token: string | null) {
if (token) {
await saveToken(token)
return
}
await clearToken()
},
async clearToken() {
await clearToken()
},
}

查看文件

@ -1,4 +1,10 @@
import type { ChatType, ImMessage, ImEventListener, MsgType } from './types' import type { ImEventListener, ImMessage, SendMessageParams } from './types'
interface StompFrame {
command: string
headers: Record<string, string>
body: string
}
export class ImClient { export class ImClient {
private ws: WebSocket | null = null private ws: WebSocket | null = null
@ -6,6 +12,8 @@ export class ImClient {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectDelay = 3000 private reconnectDelay = 3000
private shouldReconnect = true private shouldReconnect = true
private readonly subscriptionId = 'sub-user-queue'
private groupSubscriptions = new Set<string>()
constructor( constructor(
private readonly wsUrl: string, private readonly wsUrl: string,
@ -15,54 +23,70 @@ export class ImClient {
connect() { connect() {
this.shouldReconnect = true this.shouldReconnect = true
this._connect() this.openSocket()
} }
private _connect() { sendMessage(
const url = `${this.wsUrl}?token=${this.token}` toId: string,
this.ws = new WebSocket(url) chatType: SendMessageParams['chatType'],
msgType: SendMessageParams['msgType'],
this.ws.onopen = () => { content: string,
this.reconnectDelay = 3000 mentionedUserIds?: string,
this.listeners.forEach(l => l.onConnected?.()) ) {
this.send({
toId,
chatType,
msgType,
content,
mentionedUserIds,
})
} }
this.ws.onmessage = (event) => { send(params: SendMessageParams) {
try {
const msg: ImMessage = JSON.parse(event.data)
if (msg.chatType === 'GROUP') {
this.listeners.forEach(l => l.onGroupMessage?.(msg))
} else {
this.listeners.forEach(l => l.onMessage?.(msg))
}
} catch (e) {
this.listeners.forEach(l => l.onError?.('Parse error'))
}
}
this.ws.onclose = (event) => {
this.listeners.forEach(l => l.onDisconnected?.(event.reason))
if (this.shouldReconnect) {
this.reconnectTimer = setTimeout(() => {
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000)
this._connect()
}, this.reconnectDelay)
}
}
this.ws.onerror = () => {
this.listeners.forEach(l => l.onError?.('WebSocket error'))
}
}
sendMessage(toId: string, chatType: ChatType, msgType: MsgType, content: string, mentionedUserIds?: string) {
if (this.ws?.readyState !== WebSocket.OPEN) { if (this.ws?.readyState !== WebSocket.OPEN) {
throw new Error('IM not connected') throw new Error('IM not connected')
} }
this.ws.send(JSON.stringify({
type: 'chat.send', this.sendFrame(
data: { appId: this.appId, toId, chatType, msgType, content, mentionedUserIds }, 'SEND',
})) {
destination: '/app/chat.send',
'content-type': 'application/json',
},
JSON.stringify({
appId: this.appId,
toId: params.toId,
chatType: params.chatType,
msgType: params.msgType,
content: params.content,
mentionedUserIds: params.mentionedUserIds ?? '',
}),
)
}
revoke(messageId: string) {
if (this.ws?.readyState !== WebSocket.OPEN) {
throw new Error('IM not connected')
}
this.sendFrame(
'SEND',
{
destination: '/app/chat.revoke',
'content-type': 'application/json',
},
JSON.stringify({
appId: this.appId,
messageId,
}),
)
}
subscribeGroup(groupId: string) {
this.groupSubscriptions.add(groupId)
if (this.ws?.readyState === WebSocket.OPEN) {
this.subscribe(`/topic/group/${groupId}`, `group-${groupId}`)
}
} }
addListener(listener: ImEventListener) { addListener(listener: ImEventListener) {
@ -70,12 +94,15 @@ export class ImClient {
} }
removeListener(listener: ImEventListener) { removeListener(listener: ImEventListener) {
this.listeners = this.listeners.filter(l => l !== listener) this.listeners = this.listeners.filter(item => item !== listener)
} }
disconnect() { disconnect() {
this.shouldReconnect = false this.shouldReconnect = false
if (this.reconnectTimer) clearTimeout(this.reconnectTimer) if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
if (this.ws?.readyState === WebSocket.OPEN) {
this.sendFrame('DISCONNECT')
}
this.ws?.close(1000, 'User disconnect') this.ws?.close(1000, 'User disconnect')
this.ws = null this.ws = null
} }
@ -83,4 +110,100 @@ export class ImClient {
isConnected(): boolean { isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN return this.ws?.readyState === WebSocket.OPEN
} }
private openSocket() {
this.ws = new WebSocket(this.wsUrl)
this.ws.onopen = () => {
this.sendFrame('CONNECT', {
'accept-version': '1.2',
Authorization: `Bearer ${this.token}`,
'heart-beat': '10000,10000',
})
}
this.ws.onmessage = event => {
try {
const frames = this.parseFrames(String(event.data))
frames.forEach(frame => this.handleFrame(frame))
} catch {
this.listeners.forEach(listener => listener.onError?.('Parse error'))
}
}
this.ws.onclose = event => {
this.listeners.forEach(listener => listener.onDisconnected?.(event.reason))
if (this.shouldReconnect) {
this.reconnectTimer = setTimeout(() => {
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000)
this.openSocket()
}, this.reconnectDelay)
}
}
this.ws.onerror = () => {
this.listeners.forEach(listener => listener.onError?.('WebSocket error'))
}
}
private handleFrame(frame: StompFrame) {
if (frame.command === 'CONNECTED') {
this.reconnectDelay = 3000
this.subscribe('/user/queue/messages', this.subscriptionId)
this.groupSubscriptions.forEach(groupId => {
this.subscribe(`/topic/group/${groupId}`, `group-${groupId}`)
})
this.listeners.forEach(listener => listener.onConnected?.())
return
}
if (frame.command === 'MESSAGE') {
const message: ImMessage = JSON.parse(frame.body)
if (message.chatType === 'GROUP') {
this.listeners.forEach(listener => listener.onGroupMessage?.(message))
return
}
this.listeners.forEach(listener => listener.onMessage?.(message))
return
}
if (frame.command === 'ERROR') {
this.listeners.forEach(listener => listener.onError?.(frame.body || 'WebSocket error'))
}
}
private subscribe(destination: string, id: string) {
this.sendFrame('SUBSCRIBE', { destination, id })
}
private sendFrame(command: string, headers: Record<string, string> = {}, body = '') {
if (!this.ws) return
const headerLines = Object.entries(headers)
.map(([key, value]) => `${key}:${value}`)
.join('\n')
const frame = `${command}\n${headerLines}\n\n${body}\u0000`
this.ws.send(frame)
}
private parseFrames(raw: string): StompFrame[] {
return raw
.split('\u0000')
.map(frame => frame.replace(/^\n+/, '').trim())
.filter(Boolean)
.map(frame => {
const separatorIndex = frame.indexOf('\n\n')
const headerBlock = separatorIndex >= 0 ? frame.slice(0, separatorIndex) : frame
const body = separatorIndex >= 0 ? frame.slice(separatorIndex + 2) : ''
const [command, ...headerLines] = headerBlock.split('\n').filter(Boolean)
const headers = Object.fromEntries(
headerLines
.filter(line => line.includes(':'))
.map(line => {
const index = line.indexOf(':')
return [line.slice(0, index), line.slice(index + 1)]
}),
)
return { command, headers, body }
})
}
} }

查看文件

@ -1,7 +1,7 @@
import { apiRequest, saveToken } from '../core/http'
import { getConfig } from '../core/config' import { getConfig } from '../core/config'
import { apiRequest, getToken, saveToken } from '../core/http'
import { ImClient } from './imClient' import { ImClient } from './imClient'
import type { ChatType, ImEventListener, MsgType } from './types' import type { ChatType, ImEventListener, ImMessage, MsgType } from './types'
let client: ImClient | null = null let client: ImClient | null = null
@ -22,9 +22,53 @@ export const ImSDK = {
client.connect() client.connect()
}, },
sendMessage(toId: string, chatType: ChatType, msgType: MsgType, content: string, mentionedUserIds?: string) { async reconnect(): Promise<void> {
if (!client) throw new Error('ImSDK: not logged in') const config = getConfig()
client.sendMessage(toId, chatType, msgType, content, mentionedUserIds) const token = await getToken()
if (!token) throw new Error('ImSDK: token not found')
client = new ImClient(config.imWsUrl, token, config.appId)
client.connect()
},
async fetchHistory(toId: string, page = 0, size = 20): Promise<ImMessage[]> {
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),
},
})
return Array.isArray(res) ? res : (res.content ?? [])
},
async sendMessage(
toId: string,
chatType: ChatType,
msgType: MsgType,
content: string,
mentionedUserIds?: string,
): Promise<ImMessage> {
const config = getConfig()
return apiRequest<ImMessage>('/api/im/messages/send', {
method: 'POST',
params: { appId: config.appId },
body: {
toId,
chatType,
msgType,
content,
mentionedUserIds: mentionedUserIds ?? '',
},
})
},
async revokeMessage(messageId: string): Promise<ImMessage> {
const config = getConfig()
return apiRequest<ImMessage>(`/api/im/messages/${encodeURIComponent(messageId)}/revoke`, {
method: 'POST',
params: { appId: config.appId },
})
}, },
addListener(listener: ImEventListener) { addListener(listener: ImEventListener) {
@ -40,6 +84,10 @@ export const ImSDK = {
client = null client = null
}, },
subscribeGroup(groupId: string) {
client?.subscribeGroup(groupId)
},
isConnected() { isConnected() {
return client?.isConnected() ?? false return client?.isConnected() ?? false
}, },

查看文件

@ -9,6 +9,9 @@ export type MsgType =
| 'CUSTOM' | 'CUSTOM'
| 'LOCATION' | 'LOCATION'
| 'NOTIFY' | 'NOTIFY'
| 'RICH_TEXT'
| 'CALL_AUDIO'
| 'CALL_VIDEO'
| 'REVOKED' | 'REVOKED'
| 'FORWARD' | 'FORWARD'
@ -34,3 +37,11 @@ export interface ImEventListener {
onGroupMessage?: (msg: ImMessage) => void onGroupMessage?: (msg: ImMessage) => void
onError?: (error: string) => void onError?: (error: string) => void
} }
export interface SendMessageParams {
toId: string
chatType: ChatType
msgType: MsgType
content: string
mentionedUserIds?: string
}

查看文件

@ -1,13 +1,14 @@
export { initSDK, getConfig } from './core/config' export { initSDK, getConfig } from './core/config'
export type { XuqmSDKConfig } from './core/config' export type { XuqmSDKConfig } from './core/config'
export { getToken, saveToken, clearToken } from './core/http' export { getToken, saveToken, clearToken } from './core/http'
export { XuqmSDK } from './core/sdk'
export { ImSDK } from './im/imSDK' export { ImSDK } from './im/imSDK'
export { ImClient } from './im/imClient' export { ImClient } from './im/imClient'
export type { ImMessage, ChatType, MsgType, MsgStatus, ImEventListener } from './im/types' export type { ImMessage, ChatType, MsgType, MsgStatus, ImEventListener, SendMessageParams } from './im/types'
export { PushSDK } from './push/pushSDK' export { PushSDK } from './push/pushSDK'
export type { PushVendor } from './push/pushSDK' export type { PushVendor } from './push/pushSDK'
export { UpdateSDK } from './update/updateSDK' export { UpdateSDK } from './update/updateSDK'
export type { AppUpdateInfo, RnUpdateInfo } from './update/updateSDK' export type { AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from './update/updateSDK'

9
src/types/async-storage.d.ts vendored 普通文件
查看文件

@ -0,0 +1,9 @@
declare module '@react-native-async-storage/async-storage' {
const AsyncStorage: {
getItem(key: string): Promise<string | null>
setItem(key: string, value: string): Promise<void>
removeItem(key: string): Promise<void>
}
export default AsyncStorage
}

查看文件

@ -1,6 +1,7 @@
import { Platform, Linking } from 'react-native' import AsyncStorage from '@react-native-async-storage/async-storage'
import { apiRequest } from '../core/http' import { Linking, Platform } from 'react-native'
import { getConfig } from '../core/config' import { getConfig } from '../core/config'
import { apiRequest } from '../core/http'
export interface AppUpdateInfo { export interface AppUpdateInfo {
needsUpdate: boolean needsUpdate: boolean
@ -22,16 +23,54 @@ export interface RnUpdateInfo {
note: string note: string
} }
export interface CachedRnBundle {
moduleId: string
version: string
md5: string
downloadedAt: string
source: string
}
function getBundleCacheKey(moduleId: string) {
return `@xuqm_sdk_rn_bundle:${moduleId}`
}
function normalizeDownloadUrl(rawUrl?: string) {
if (!rawUrl) return rawUrl
// Compatible with legacy server config where update base URL already includes /api/v1/updates.
if (rawUrl.includes('/api/v1/updates/api/v1/rn/files/')) {
return rawUrl.replace('/api/v1/updates/api/v1/rn/files/', '/api/v1/rn/files/')
}
if (rawUrl.includes('/files/apk/')) {
try {
const url = new URL(rawUrl)
if (url.pathname.startsWith('/files/apk/')) {
return `${url.origin}/api/v1/updates${url.pathname}${url.search}`
}
} catch {
return rawUrl
}
}
return rawUrl
}
export const UpdateSDK = { export const UpdateSDK = {
async checkAppUpdate(currentVersionCode: number): Promise<AppUpdateInfo> { async checkAppUpdate(currentVersionCode: number): Promise<AppUpdateInfo> {
const config = getConfig() const config = getConfig()
return apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', { const result = await apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', {
params: { params: {
appId: config.appId, appId: config.appId,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS', platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersionCode: String(currentVersionCode), currentVersionCode: String(currentVersionCode),
}, },
}) })
return {
...result,
downloadUrl: normalizeDownloadUrl(result.downloadUrl),
}
}, },
async openAppStore(appStoreUrl?: string, marketUrl?: string): Promise<void> { async openAppStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
@ -41,7 +80,7 @@ export const UpdateSDK = {
async checkRnUpdate(moduleId: string, currentVersion: string): Promise<RnUpdateInfo> { async checkRnUpdate(moduleId: string, currentVersion: string): Promise<RnUpdateInfo> {
const config = getConfig() const config = getConfig()
return apiRequest<RnUpdateInfo>('/api/v1/rn/update/check', { const result = await apiRequest<RnUpdateInfo>('/api/v1/rn/update/check', {
params: { params: {
appId: config.appId, appId: config.appId,
moduleId, moduleId,
@ -49,5 +88,34 @@ export const UpdateSDK = {
currentVersion, currentVersion,
}, },
}) })
return {
...result,
downloadUrl: normalizeDownloadUrl(result.downloadUrl) ?? result.downloadUrl,
}
},
async downloadRnBundle(downloadUrl: string): Promise<string> {
const response = await fetch(downloadUrl)
if (!response.ok) {
throw new Error(`Failed to download bundle: ${response.status}`)
}
return response.text()
},
async cacheRnBundle(moduleId: string, version: string, md5: string, source: string): Promise<CachedRnBundle> {
const payload: CachedRnBundle = {
moduleId,
version,
md5,
source,
downloadedAt: new Date().toISOString(),
}
await AsyncStorage.setItem(getBundleCacheKey(moduleId), JSON.stringify(payload))
return payload
},
async getCachedRnBundle(moduleId: string): Promise<CachedRnBundle | null> {
const raw = await AsyncStorage.getItem(getBundleCacheKey(moduleId))
return raw ? JSON.parse(raw) as CachedRnBundle : null
}, },
} }