feat: add rn sdk chat and update support
这个提交包含在:
父节点
61ffd1bc8c
当前提交
b747901cfe
2968
package-lock.json
自动生成的
普通文件
2968
package-lock.json
自动生成的
普通文件
文件差异内容过多而无法显示
加载差异
@ -3,6 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"description": "XuqmGroup React Native SDK — IM, Push, Version Management",
|
||||
"main": "src/index.ts",
|
||||
"react-native": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
|
||||
26
src/core/sdk.ts
普通文件
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 {
|
||||
private ws: WebSocket | null = null
|
||||
@ -6,6 +12,8 @@ export class ImClient {
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectDelay = 3000
|
||||
private shouldReconnect = true
|
||||
private readonly subscriptionId = 'sub-user-queue'
|
||||
private groupSubscriptions = new Set<string>()
|
||||
|
||||
constructor(
|
||||
private readonly wsUrl: string,
|
||||
@ -15,54 +23,70 @@ export class ImClient {
|
||||
|
||||
connect() {
|
||||
this.shouldReconnect = true
|
||||
this._connect()
|
||||
this.openSocket()
|
||||
}
|
||||
|
||||
private _connect() {
|
||||
const url = `${this.wsUrl}?token=${this.token}`
|
||||
this.ws = new WebSocket(url)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectDelay = 3000
|
||||
this.listeners.forEach(l => l.onConnected?.())
|
||||
sendMessage(
|
||||
toId: string,
|
||||
chatType: SendMessageParams['chatType'],
|
||||
msgType: SendMessageParams['msgType'],
|
||||
content: string,
|
||||
mentionedUserIds?: string,
|
||||
) {
|
||||
this.send({
|
||||
toId,
|
||||
chatType,
|
||||
msgType,
|
||||
content,
|
||||
mentionedUserIds,
|
||||
})
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
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) {
|
||||
send(params: SendMessageParams) {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('IM not connected')
|
||||
}
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'chat.send',
|
||||
data: { appId: this.appId, toId, chatType, msgType, content, mentionedUserIds },
|
||||
}))
|
||||
|
||||
this.sendFrame(
|
||||
'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) {
|
||||
@ -70,12 +94,15 @@ export class ImClient {
|
||||
}
|
||||
|
||||
removeListener(listener: ImEventListener) {
|
||||
this.listeners = this.listeners.filter(l => l !== listener)
|
||||
this.listeners = this.listeners.filter(item => item !== listener)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.sendFrame('DISCONNECT')
|
||||
}
|
||||
this.ws?.close(1000, 'User disconnect')
|
||||
this.ws = null
|
||||
}
|
||||
@ -83,4 +110,100 @@ export class ImClient {
|
||||
isConnected(): boolean {
|
||||
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 { apiRequest, getToken, saveToken } from '../core/http'
|
||||
import { ImClient } from './imClient'
|
||||
import type { ChatType, ImEventListener, MsgType } from './types'
|
||||
import type { ChatType, ImEventListener, ImMessage, MsgType } from './types'
|
||||
|
||||
let client: ImClient | null = null
|
||||
|
||||
@ -22,9 +22,53 @@ export const ImSDK = {
|
||||
client.connect()
|
||||
},
|
||||
|
||||
sendMessage(toId: string, chatType: ChatType, msgType: MsgType, content: string, mentionedUserIds?: string) {
|
||||
if (!client) throw new Error('ImSDK: not logged in')
|
||||
client.sendMessage(toId, chatType, msgType, content, mentionedUserIds)
|
||||
async reconnect(): Promise<void> {
|
||||
const config = getConfig()
|
||||
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) {
|
||||
@ -40,6 +84,10 @@ export const ImSDK = {
|
||||
client = null
|
||||
},
|
||||
|
||||
subscribeGroup(groupId: string) {
|
||||
client?.subscribeGroup(groupId)
|
||||
},
|
||||
|
||||
isConnected() {
|
||||
return client?.isConnected() ?? false
|
||||
},
|
||||
|
||||
@ -9,6 +9,9 @@ export type MsgType =
|
||||
| 'CUSTOM'
|
||||
| 'LOCATION'
|
||||
| 'NOTIFY'
|
||||
| 'RICH_TEXT'
|
||||
| 'CALL_AUDIO'
|
||||
| 'CALL_VIDEO'
|
||||
| 'REVOKED'
|
||||
| 'FORWARD'
|
||||
|
||||
@ -34,3 +37,11 @@ export interface ImEventListener {
|
||||
onGroupMessage?: (msg: ImMessage) => 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 type { XuqmSDKConfig } from './core/config'
|
||||
export { getToken, saveToken, clearToken } from './core/http'
|
||||
export { XuqmSDK } from './core/sdk'
|
||||
|
||||
export { ImSDK } from './im/imSDK'
|
||||
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 type { PushVendor } from './push/pushSDK'
|
||||
|
||||
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
普通文件
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 { apiRequest } from '../core/http'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { Linking, Platform } from 'react-native'
|
||||
import { getConfig } from '../core/config'
|
||||
import { apiRequest } from '../core/http'
|
||||
|
||||
export interface AppUpdateInfo {
|
||||
needsUpdate: boolean
|
||||
@ -22,16 +23,54 @@ export interface RnUpdateInfo {
|
||||
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 = {
|
||||
async checkAppUpdate(currentVersionCode: number): Promise<AppUpdateInfo> {
|
||||
const config = getConfig()
|
||||
return apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', {
|
||||
const result = await apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', {
|
||||
params: {
|
||||
appId: config.appId,
|
||||
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
||||
currentVersionCode: String(currentVersionCode),
|
||||
},
|
||||
})
|
||||
return {
|
||||
...result,
|
||||
downloadUrl: normalizeDownloadUrl(result.downloadUrl),
|
||||
}
|
||||
},
|
||||
|
||||
async openAppStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
|
||||
@ -41,7 +80,7 @@ export const UpdateSDK = {
|
||||
|
||||
async checkRnUpdate(moduleId: string, currentVersion: string): Promise<RnUpdateInfo> {
|
||||
const config = getConfig()
|
||||
return apiRequest<RnUpdateInfo>('/api/v1/rn/update/check', {
|
||||
const result = await apiRequest<RnUpdateInfo>('/api/v1/rn/update/check', {
|
||||
params: {
|
||||
appId: config.appId,
|
||||
moduleId,
|
||||
@ -49,5 +88,34 @@ export const UpdateSDK = {
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户