feat(sdk): 更新 SDK 设计文档和 API 重构
- 添加 expiresAt 和 refreshUserSig 参数支持自动续签 - 修改 PushSDK 初始化方式,自动完成设备注册和厂商初始化 - 调整过期续签策略,从提前 15 分钟改为提前 5 分钟触发 - 重构 RN SDK 文档结构,简化安装和使用方式 - 更新统一登录流程,支持 profile 信息传递 - 添加 IM 数据库自动隔离功能 - 修复 Android 群消息聚合问题 - 补充自动化测试验证和错误处理机制
这个提交包含在:
父节点
7d63672a7a
当前提交
9e11a63144
276
README.md
276
README.md
@ -1,256 +1,40 @@
|
|||||||
# XuqmGroup React Native SDK 文档
|
# XuqmGroup React Native SDK
|
||||||
|
|
||||||
> TypeScript · React Native 0.76+ · 发布至 Nexus npm
|
`rn-sdk` 的稳定入口是 `src/index.ts`,统一登录/登出层在 `src/sdk.ts`。
|
||||||
> `rn-sdk` 为内部基础包,业务方正常接入时使用 `rn-common` 和各业务模块即可。
|
旧的 `src/core`、`src/im`、`src/push`、`src/update` 目录已清理,避免继续引用废弃实现。
|
||||||
|
|
||||||
|
## 当前结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
XuqmGroup-RNSDK/
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # 对外聚合入口
|
||||||
|
│ └── sdk.ts # 统一登录 / 登出封装
|
||||||
|
├── packages/
|
||||||
|
│ ├── common/ # 初始化、网络、设备、Token、基础组件
|
||||||
|
│ ├── im/ # IM、会话、历史、群组、关系链
|
||||||
|
│ ├── push/ # 推送设备注册
|
||||||
|
│ └── update/ # App 更新 / RN 热更新
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
`rn-common` 可以独立使用;`rn-im` / `rn-push` / `rn-update` 会自动带上 `rn-common` 和 `rn-sdk`;`rn-sdk` 是内部基础包,不建议业务方直接引用。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 基础能力
|
yarn add @xuqm/rn-sdk
|
||||||
npm install @xuqm/rn-common
|
yarn add @react-native-async-storage/async-storage
|
||||||
# 或
|
|
||||||
yarn add @xuqm/rn-common
|
|
||||||
|
|
||||||
# 单独接入某个业务模块时,`rn-common` 会作为依赖自动安装
|
# 如需按模块拆分接入,也可以直接安装
|
||||||
npm install @xuqm/rn-im
|
yarn add @xuqm/rn-common @xuqm/rn-im @xuqm/rn-push @xuqm/rn-update
|
||||||
npm install @xuqm/rn-push
|
|
||||||
npm install @xuqm/rn-update
|
|
||||||
|
|
||||||
# 依赖安装(peerDependencies)
|
|
||||||
npm install @react-native-async-storage/async-storage
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 目录结构
|
## 入口
|
||||||
|
|
||||||
```
|
- `XuqmSDK.initialize({ appKey, debug })`
|
||||||
XuqmGroup-RNSDK/src/
|
- `XuqmSDK.login({ userId, userSig, profile, expiresAt, refreshUserSig })`
|
||||||
├── core/
|
- `XuqmSDK.logout()`
|
||||||
│ ├── http.ts # fetch HTTP 客户端,AsyncStorage Token
|
- `ImSDK`
|
||||||
│ └── sdk.ts # XuqmSDK 初始化入口
|
- `PushSDK`
|
||||||
├── im/
|
- `UpdateSDK`
|
||||||
│ └── imClient.ts # WebSocket IM 客户端,指数退避重连
|
|
||||||
├── push/
|
|
||||||
│ └── pushSDK.ts # 推送 Token 注册
|
|
||||||
├── update/
|
|
||||||
│ └── updateSDK.ts # 检查更新 / 下载 Bundle
|
|
||||||
└── index.ts # 统一导出
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
详细用法见 [docs/rn-sdk/README.md](../docs/rn-sdk/README.md)。
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 1. 初始化(App 入口)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { XuqmSDK } from '@xuqm/rn-common'
|
|
||||||
|
|
||||||
await XuqmSDK.init({
|
|
||||||
appKey: 'ak_your_app_key',
|
|
||||||
debug: __DEV__,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 用户登录后设置 Token
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await XuqmSDK.setToken('eyJ...')
|
|
||||||
// 登出时
|
|
||||||
await XuqmSDK.setToken(null)
|
|
||||||
```
|
|
||||||
|
|
||||||
Token 持久化存储于 `AsyncStorage`,App 重启后自动恢复。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## HTTP 客户端
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { apiRequest } from '@xuqm/rn-common'
|
|
||||||
|
|
||||||
// 自动附加 Bearer Token
|
|
||||||
const data = await apiRequest<UserInfo>('/api/user/profile')
|
|
||||||
const result = await apiRequest<LoginResp>('/api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { account, password },
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## IM 模块
|
|
||||||
|
|
||||||
### ImClient
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { ImClient, MsgType, ChatType } from '@xuqm/rn-im'
|
|
||||||
|
|
||||||
const im = new ImClient()
|
|
||||||
|
|
||||||
im.on('connected', () => console.log('IM 已连接'))
|
|
||||||
im.on('disconnected', (code, reason) => console.log('断开', code))
|
|
||||||
im.on('message', (msg) => {
|
|
||||||
console.log('新消息', msg.content)
|
|
||||||
// msg: ImMessage
|
|
||||||
})
|
|
||||||
im.on('revoke', ({ msgId, operatorId }) => {
|
|
||||||
console.log('消息被撤回', msgId)
|
|
||||||
})
|
|
||||||
im.on('error', (err) => console.error(err))
|
|
||||||
|
|
||||||
// 连接
|
|
||||||
im.connect()
|
|
||||||
|
|
||||||
// 发送消息
|
|
||||||
im.send({
|
|
||||||
toId: 'user_002',
|
|
||||||
chatType: ChatType.SINGLE,
|
|
||||||
msgType: MsgType.TEXT,
|
|
||||||
content: 'Hello from RN!',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 撤回
|
|
||||||
im.revoke('msg-uuid')
|
|
||||||
|
|
||||||
// 组件卸载时断开
|
|
||||||
useEffect(() => {
|
|
||||||
im.connect()
|
|
||||||
return () => im.disconnect()
|
|
||||||
}, [])
|
|
||||||
```
|
|
||||||
|
|
||||||
### React Hook 封装示例
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useEffect, useState, useRef } from 'react'
|
|
||||||
import { ImClient, ImMessage } from '@xuqm/rn-im'
|
|
||||||
|
|
||||||
export function useIm() {
|
|
||||||
const [messages, setMessages] = useState<ImMessage[]>([])
|
|
||||||
const [connected, setConnected] = useState(false)
|
|
||||||
const imRef = useRef<ImClient | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const im = new ImClient()
|
|
||||||
imRef.current = im
|
|
||||||
im.on('connected', () => setConnected(true))
|
|
||||||
im.on('disconnected', () => setConnected(false))
|
|
||||||
im.on('message', (msg) => setMessages(prev => [...prev, msg]))
|
|
||||||
im.connect()
|
|
||||||
return () => im.disconnect()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const send = (params: Parameters<ImClient['send']>[0]) =>
|
|
||||||
imRef.current?.send(params)
|
|
||||||
|
|
||||||
return { messages, connected, send }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ImMessage 结构
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ImMessage {
|
|
||||||
id: string
|
|
||||||
fromId: string
|
|
||||||
toId: string
|
|
||||||
chatType: 'SINGLE' | 'GROUP'
|
|
||||||
msgType: MsgType
|
|
||||||
content: string
|
|
||||||
extra?: string
|
|
||||||
revoked: boolean
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 消息类型(MsgType)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
enum MsgType {
|
|
||||||
TEXT = 'TEXT',
|
|
||||||
IMAGE = 'IMAGE',
|
|
||||||
VIDEO = 'VIDEO',
|
|
||||||
AUDIO = 'AUDIO',
|
|
||||||
FILE = 'FILE',
|
|
||||||
CUSTOM = 'CUSTOM',
|
|
||||||
LOCATION = 'LOCATION',
|
|
||||||
NOTIFY = 'NOTIFY',
|
|
||||||
RICH_TEXT = 'RICH_TEXT',
|
|
||||||
CALL_AUDIO = 'CALL_AUDIO',
|
|
||||||
CALL_VIDEO = 'CALL_VIDEO',
|
|
||||||
FORWARD = 'FORWARD',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 自动重连
|
|
||||||
|
|
||||||
断线后指数退避重连:3s → 6s → 12s → ... → 最大 30s。调用 `disconnect()` 后停止。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 推送模块
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { PushSDK } from '@xuqm/rn-push'
|
|
||||||
|
|
||||||
// 在获取到设备推送 Token 后调用(如小米、华为推送回调)
|
|
||||||
await PushSDK.registerToken({
|
|
||||||
appId: 'ak_xxx',
|
|
||||||
userId: 'user_001',
|
|
||||||
vendor: 'XIAOMI', // HUAWEI / XIAOMI / OPPO / VIVO / HONOR / APNS
|
|
||||||
token: 'device_push_token',
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 版本管理模块
|
|
||||||
|
|
||||||
### 检查 App 更新
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { UpdateSDK } from '@xuqm/rn-update'
|
|
||||||
|
|
||||||
const result = await UpdateSDK.checkAppUpdate({
|
|
||||||
appId: 'ak_xxx',
|
|
||||||
platform: 'android', // 'android' | 'ios'
|
|
||||||
currentVersionCode: 10,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.needsUpdate) {
|
|
||||||
console.log(result.versionName, result.downloadUrl, result.forceUpdate)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 检查 RN Bundle 热更新
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const rnResult = await UpdateSDK.checkRnUpdate({
|
|
||||||
appId: 'ak_xxx',
|
|
||||||
moduleId: 'main',
|
|
||||||
platform: 'android',
|
|
||||||
currentVersion: '1.0.0',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (rnResult.needsUpdate && rnResult.info) {
|
|
||||||
const { downloadUrl, md5 } = rnResult.info
|
|
||||||
// 下载 bundle,校验 md5,调用原生模块热加载
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 发版
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 确认 .npmrc 指向 https://nexus.xuqinmin.com/repository/npm-hosted/
|
|
||||||
# 登录 Nexus npm(首次)
|
|
||||||
npm login --registry=https://nexus.xuqinmin.com/repository/npm-hosted/
|
|
||||||
|
|
||||||
# 发版
|
|
||||||
npm publish
|
|
||||||
```
|
|
||||||
|
|
||||||
版本号在 `package.json` 的 `version` 字段维护。
|
|
||||||
|
|||||||
@ -10,7 +10,10 @@
|
|||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
||||||
},
|
},
|
||||||
"scripts": { "typecheck": "tsc --noEmit" },
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "tsc -p tsconfig.test.json && node --test dist-test/tests/runtime.test.js"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xuqm/rn-common": ">=0.2.0",
|
"@xuqm/rn-common": ">=0.2.0",
|
||||||
"@xuqm/rn-sdk": ">=0.2.0"
|
"@xuqm/rn-sdk": ">=0.2.0"
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import type {
|
|||||||
UserProfile,
|
UserProfile,
|
||||||
} from './types'
|
} from './types'
|
||||||
import { uploadFile } from './upload'
|
import { uploadFile } from './upload'
|
||||||
|
import { buildDbName, sortConversations as sortConversationsByRuntime } from './runtime'
|
||||||
|
|
||||||
let client: ImClient | null = null
|
let client: ImClient | null = null
|
||||||
let _currentUserId: string | null = null
|
let _currentUserId: string | null = null
|
||||||
@ -115,19 +116,12 @@ function normalizeConversation(item: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortConversations(conversations: ConversationData[]): ConversationData[] {
|
|
||||||
return [...conversations].sort((a, b) => {
|
|
||||||
if (a.isPinned !== b.isPinned) return Number(b.isPinned) - Number(a.isPinned)
|
|
||||||
return b.lastMsgTime - a.lastMsgTime
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function conversationKey(targetId: string, chatType: ChatType): string {
|
function conversationKey(targetId: string, chatType: ChatType): string {
|
||||||
return `${chatType}:${targetId}`
|
return `${chatType}:${targetId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitConversationMemory(): void {
|
function emitConversationMemory(): void {
|
||||||
const snapshot = sortConversations(conversationMemory)
|
const snapshot = sortConversationsByRuntime(conversationMemory)
|
||||||
conversationMemory = snapshot
|
conversationMemory = snapshot
|
||||||
conversationListeners.forEach(listener => listener(snapshot))
|
conversationListeners.forEach(listener => listener(snapshot))
|
||||||
}
|
}
|
||||||
@ -295,6 +289,7 @@ export const ImSDK = {
|
|||||||
async login(userId: string, nickname?: string, avatar?: string, dbName?: string): Promise<void> {
|
async login(userId: string, nickname?: string, avatar?: string, dbName?: string): Promise<void> {
|
||||||
const config = getConfig()
|
const config = getConfig()
|
||||||
const device = await getDeviceInfo()
|
const device = await getDeviceInfo()
|
||||||
|
client?.disconnect()
|
||||||
const res = await apiRequest<{ token: string }>('/api/im/auth/login', {
|
const res = await apiRequest<{ token: string }>('/api/im/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
skipAuth: true,
|
skipAuth: true,
|
||||||
@ -314,9 +309,7 @@ export const ImSDK = {
|
|||||||
_currentUserId = userId
|
_currentUserId = userId
|
||||||
setCommonUserId(userId)
|
setCommonUserId(userId)
|
||||||
|
|
||||||
if (dbName !== undefined || ImDatabase.isInitialized()) {
|
ImDatabase.init(dbName ?? buildDbName(config.appKey, userId))
|
||||||
ImDatabase.init(dbName ?? 'xuqm_im')
|
|
||||||
}
|
|
||||||
|
|
||||||
client = new ImClient(config.imWsUrl, res.token, config.appId)
|
client = new ImClient(config.imWsUrl, res.token, config.appId)
|
||||||
client.addListener({
|
client.addListener({
|
||||||
@ -333,13 +326,12 @@ export const ImSDK = {
|
|||||||
*/
|
*/
|
||||||
async loginWithToken(userId: string, token: string, dbName?: string): Promise<void> {
|
async loginWithToken(userId: string, token: string, dbName?: string): Promise<void> {
|
||||||
const config = getConfig()
|
const config = getConfig()
|
||||||
|
client?.disconnect()
|
||||||
await _saveToken(token)
|
await _saveToken(token)
|
||||||
_currentUserId = userId
|
_currentUserId = userId
|
||||||
setCommonUserId(userId)
|
setCommonUserId(userId)
|
||||||
|
|
||||||
if (dbName !== undefined || ImDatabase.isInitialized()) {
|
ImDatabase.init(dbName ?? buildDbName(config.appKey, userId))
|
||||||
ImDatabase.init(dbName ?? 'xuqm_im')
|
|
||||||
}
|
|
||||||
|
|
||||||
client = new ImClient(config.imWsUrl, token, config.appId)
|
client = new ImClient(config.imWsUrl, token, config.appId)
|
||||||
client.addListener({
|
client.addListener({
|
||||||
@ -350,6 +342,10 @@ export const ImSDK = {
|
|||||||
void client.connect()
|
void client.connect()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loginWithUserSig(userId: string, userSig: string, dbName?: string): Promise<void> {
|
||||||
|
return ImSDK.loginWithToken(userId, userSig, dbName)
|
||||||
|
},
|
||||||
|
|
||||||
async reconnect(): Promise<void> {
|
async reconnect(): Promise<void> {
|
||||||
const config = getConfig()
|
const config = getConfig()
|
||||||
const token = await _getToken()
|
const token = await _getToken()
|
||||||
@ -1044,7 +1040,7 @@ export const ImSDK = {
|
|||||||
subscribeConversations(callback: (conversations: ConversationData[]) => void): () => void {
|
subscribeConversations(callback: (conversations: ConversationData[]) => void): () => void {
|
||||||
if (!ImDatabase.isInitialized()) {
|
if (!ImDatabase.isInitialized()) {
|
||||||
conversationListeners.add(callback)
|
conversationListeners.add(callback)
|
||||||
callback(sortConversations(conversationMemory))
|
callback(sortConversationsByRuntime(conversationMemory))
|
||||||
return () => {
|
return () => {
|
||||||
conversationListeners.delete(callback)
|
conversationListeners.delete(callback)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { MessageModel } from './MessageModel'
|
|||||||
import type { ImMessage } from '../types'
|
import type { ImMessage } from '../types'
|
||||||
|
|
||||||
let _db: Database | null = null
|
let _db: Database | null = null
|
||||||
|
let _dbName: string | null = null
|
||||||
const draftStore = new Map<string, string>()
|
const draftStore = new Map<string, string>()
|
||||||
|
|
||||||
function getDb(): Database {
|
function getDb(): Database {
|
||||||
@ -36,7 +37,7 @@ export interface MessageSearchParams {
|
|||||||
|
|
||||||
export const ImDatabase = {
|
export const ImDatabase = {
|
||||||
init(dbName = 'xuqm_im') {
|
init(dbName = 'xuqm_im') {
|
||||||
if (_db) return
|
if (_db && _dbName === dbName) return
|
||||||
const adapter = new SQLiteAdapter({
|
const adapter = new SQLiteAdapter({
|
||||||
schema: imDbSchema,
|
schema: imDbSchema,
|
||||||
dbName,
|
dbName,
|
||||||
@ -47,6 +48,7 @@ export const ImDatabase = {
|
|||||||
adapter,
|
adapter,
|
||||||
modelClasses: [ConversationModel, MessageModel],
|
modelClasses: [ConversationModel, MessageModel],
|
||||||
})
|
})
|
||||||
|
_dbName = dbName
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveMessage(msg: ImMessage, currentUserId: string): Promise<void> {
|
async saveMessage(msg: ImMessage, currentUserId: string): Promise<void> {
|
||||||
|
|||||||
28
packages/im/src/runtime.ts
普通文件
28
packages/im/src/runtime.ts
普通文件
@ -0,0 +1,28 @@
|
|||||||
|
import type { ConversationData, ImMessage } from './types'
|
||||||
|
|
||||||
|
export function buildDbName(appKey: string, userId: string): string {
|
||||||
|
return `xuqm_im_${appKey}_${userId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortMessages(messages: ImMessage[]): ImMessage[] {
|
||||||
|
const deduped = new Map<string, ImMessage>()
|
||||||
|
messages.forEach((message) => {
|
||||||
|
const existing = deduped.get(message.id)
|
||||||
|
if (!existing || message.createdAt >= existing.createdAt) {
|
||||||
|
deduped.set(message.id, message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return [...deduped.values()]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt
|
||||||
|
return b.id.localeCompare(a.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortConversations(conversations: ConversationData[]): ConversationData[] {
|
||||||
|
return [...conversations].sort((a, b) => {
|
||||||
|
if (a.isPinned !== b.isPinned) return Number(b.isPinned) - Number(a.isPinned)
|
||||||
|
if (b.lastMsgTime !== a.lastMsgTime) return b.lastMsgTime - a.lastMsgTime
|
||||||
|
return `${a.chatType}:${a.targetId}`.localeCompare(`${b.chatType}:${b.targetId}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -24,7 +24,7 @@ export async function uploadFile(
|
|||||||
const config = getConfig()
|
const config = getConfig()
|
||||||
const token = await _getToken()
|
const token = await _getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('[uploadFile] No active session — call ImSDK.login() first.')
|
throw new Error('[uploadFile] No active session — call ImSDK.loginWithToken() first.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { buildDbName, sortConversations, sortMessages } from '../src/runtime'
|
||||||
|
|
||||||
|
test('buildDbName derives a stable per-user database name', () => {
|
||||||
|
assert.equal(buildDbName('ak_demo_chat', 'demo_alice'), 'xuqm_im_ak_demo_chat_demo_alice')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sortConversations keeps pinned conversations first and then sorts by time', () => {
|
||||||
|
const conversations = sortConversations([
|
||||||
|
{ targetId: 'c', chatType: 'SINGLE', lastMsgTime: 100, unreadCount: 0, isMuted: false, isPinned: false },
|
||||||
|
{ targetId: 'a', chatType: 'SINGLE', lastMsgTime: 80, unreadCount: 0, isMuted: false, isPinned: true },
|
||||||
|
{ targetId: 'b', chatType: 'GROUP', lastMsgTime: 120, unreadCount: 0, isMuted: false, isPinned: false },
|
||||||
|
{ targetId: 'd', chatType: 'GROUP', lastMsgTime: 120, unreadCount: 0, isMuted: false, isPinned: false },
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
conversations.map((item) => `${item.chatType}:${item.targetId}`),
|
||||||
|
['SINGLE:a', 'GROUP:b', 'GROUP:d', 'SINGLE:c'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sortMessages deduplicates by message id and sorts newest first', () => {
|
||||||
|
const messages = sortMessages([
|
||||||
|
{ id: 'm2', appId: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'old', status: 'SENT', createdAt: 100 },
|
||||||
|
{ id: 'm1', appId: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'latest', status: 'SENT', createdAt: 200 },
|
||||||
|
{ id: 'm1', appId: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'duplicate', status: 'READ', createdAt: 180 },
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
messages.map((item) => `${item.id}:${item.createdAt}:${item.content}`),
|
||||||
|
['m1:200:latest', 'm2:100:old'],
|
||||||
|
)
|
||||||
|
})
|
||||||
16
packages/im/tsconfig.test.json
普通文件
16
packages/im/tsconfig.test.json
普通文件
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist-test",
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"sourceMap": false
|
||||||
|
},
|
||||||
|
"include": ["src/runtime.ts", "src/types.ts", "tests/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "dist-test"]
|
||||||
|
}
|
||||||
@ -1,9 +1,48 @@
|
|||||||
import { apiRequest, getConfig, getDeviceInfo } from '@xuqm/rn-common'
|
import { apiRequest, getConfig, getDeviceInfo, getUserId as getCommonUserId } from '@xuqm/rn-common'
|
||||||
import type { PushVendor } from '@xuqm/rn-common'
|
import type { PushVendor } from '@xuqm/rn-common'
|
||||||
|
|
||||||
export type { PushVendor }
|
export type { PushVendor }
|
||||||
|
|
||||||
|
type PendingDeviceToken = {
|
||||||
|
token: string
|
||||||
|
vendor?: PushVendor
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentUserId: string | null = null
|
||||||
|
let pendingToken: PendingDeviceToken | null = null
|
||||||
|
|
||||||
|
async function registerPendingToken(): Promise<void> {
|
||||||
|
const userId = currentUserId ?? getCommonUserId()
|
||||||
|
if (!userId || !pendingToken) return
|
||||||
|
const device = await getDeviceInfo()
|
||||||
|
const config = getConfig()
|
||||||
|
await apiRequest('/api/push/register', {
|
||||||
|
method: 'POST',
|
||||||
|
params: {
|
||||||
|
appId: config.appId,
|
||||||
|
userId,
|
||||||
|
vendor: pendingToken.vendor ?? device.pushVendor,
|
||||||
|
token: pendingToken.token,
|
||||||
|
platform: device.platform,
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
brand: device.brand,
|
||||||
|
model: device.model,
|
||||||
|
osVersion: device.osVersion,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const PushSDK = {
|
export const PushSDK = {
|
||||||
|
async initialize(userId?: string): Promise<void> {
|
||||||
|
currentUserId = userId ?? getCommonUserId()
|
||||||
|
await registerPendingToken()
|
||||||
|
},
|
||||||
|
|
||||||
|
async setDeviceToken(token: string, vendor?: PushVendor): Promise<void> {
|
||||||
|
pendingToken = { token, vendor }
|
||||||
|
await registerPendingToken()
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a push device token for the given user.
|
* Register a push device token for the given user.
|
||||||
* If vendor is omitted, it is auto-detected from the device brand.
|
* If vendor is omitted, it is auto-detected from the device brand.
|
||||||
@ -13,6 +52,8 @@ export const PushSDK = {
|
|||||||
* @param vendor - Optional; auto-detected when not provided
|
* @param vendor - Optional; auto-detected when not provided
|
||||||
*/
|
*/
|
||||||
async registerToken(userId: string, token: string, vendor?: PushVendor): Promise<void> {
|
async registerToken(userId: string, token: string, vendor?: PushVendor): Promise<void> {
|
||||||
|
currentUserId = userId
|
||||||
|
pendingToken = { token, vendor }
|
||||||
const config = getConfig()
|
const config = getConfig()
|
||||||
const device = await getDeviceInfo()
|
const device = await getDeviceInfo()
|
||||||
await apiRequest('/api/push/register', {
|
await apiRequest('/api/push/register', {
|
||||||
@ -38,5 +79,13 @@ export const PushSDK = {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
params: { appId: config.appId, userId, deviceId },
|
params: { appId: config.appId, userId, deviceId },
|
||||||
})
|
})
|
||||||
|
if (currentUserId === userId) currentUserId = null
|
||||||
|
if (pendingToken && currentUserId === null) pendingToken = null
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout(userId?: string): Promise<void> {
|
||||||
|
const targetUserId = userId ?? currentUserId ?? getCommonUserId()
|
||||||
|
if (!targetUserId) return
|
||||||
|
await PushSDK.unregisterToken(targetUserId)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
export interface XuqmSDKConfig {
|
|
||||||
appKey: string
|
|
||||||
appSecret: string
|
|
||||||
debug?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
let _config: XuqmSDKConfig | null = null
|
|
||||||
|
|
||||||
export function initSDK(config: XuqmSDKConfig) {
|
|
||||||
_config = config
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getConfig(): XuqmSDKConfig {
|
|
||||||
if (!_config) throw new Error('XuqmSDK not initialized. Call initSDK() first.')
|
|
||||||
return _config
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
||||||
import { getConfig } from './config'
|
|
||||||
|
|
||||||
const TOKEN_KEY = '@xuqm_sdk_token'
|
|
||||||
const DEFAULT_API_URL = 'https://dev.xuqinmin.com'
|
|
||||||
|
|
||||||
export async function getToken(): Promise<string | null> {
|
|
||||||
return AsyncStorage.getItem(TOKEN_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveToken(token: string): Promise<void> {
|
|
||||||
return AsyncStorage.setItem(TOKEN_KEY, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearToken(): Promise<void> {
|
|
||||||
return AsyncStorage.removeItem(TOKEN_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiRequest<T>(
|
|
||||||
path: string,
|
|
||||||
options: { method?: string; body?: unknown; params?: Record<string, string> } = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const config = getConfig()
|
|
||||||
const token = await getToken()
|
|
||||||
|
|
||||||
let url = DEFAULT_API_URL.replace(/\/$/, '') + path
|
|
||||||
if (options.params) {
|
|
||||||
const qs = new URLSearchParams(options.params).toString()
|
|
||||||
url += (url.includes('?') ? '&' : '?') + qs
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: options.method ?? 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
|
||||||
throw new Error(err.message ?? `HTTP ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json()
|
|
||||||
return json.data ?? json
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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,209 +0,0 @@
|
|||||||
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
|
|
||||||
private listeners: ImEventListener[] = []
|
|
||||||
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,
|
|
||||||
private readonly token: string,
|
|
||||||
private readonly appId: string,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.shouldReconnect = true
|
|
||||||
this.openSocket()
|
|
||||||
}
|
|
||||||
|
|
||||||
sendMessage(
|
|
||||||
toId: string,
|
|
||||||
chatType: SendMessageParams['chatType'],
|
|
||||||
msgType: SendMessageParams['msgType'],
|
|
||||||
content: string,
|
|
||||||
mentionedUserIds?: string,
|
|
||||||
) {
|
|
||||||
this.send({
|
|
||||||
toId,
|
|
||||||
chatType,
|
|
||||||
msgType,
|
|
||||||
content,
|
|
||||||
mentionedUserIds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
send(params: SendMessageParams) {
|
|
||||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
||||||
throw new Error('IM not connected')
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
this.listeners.push(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
removeListener(listener: ImEventListener) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
136
src/im/imSDK.ts
136
src/im/imSDK.ts
@ -1,136 +0,0 @@
|
|||||||
import { getConfig } from '../core/config'
|
|
||||||
import { apiRequest, getToken, saveToken } from '../core/http'
|
|
||||||
import { ImClient } from './imClient'
|
|
||||||
import type { ChatType, ImEventListener, ImGroup, ImMessage, MsgType } from './types'
|
|
||||||
|
|
||||||
let client: ImClient | null = null
|
|
||||||
const DEFAULT_IM_WS_URL = 'wss://dev.xuqinmin.com/ws/im'
|
|
||||||
|
|
||||||
export const ImSDK = {
|
|
||||||
async login(userId: string, nickname?: string, avatar?: string): Promise<void> {
|
|
||||||
const config = getConfig()
|
|
||||||
const res = await apiRequest<{ token: string }>('/api/im/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
params: {
|
|
||||||
appId: config.appKey,
|
|
||||||
userId,
|
|
||||||
...(nickname ? { nickname } : {}),
|
|
||||||
...(avatar ? { avatar } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await saveToken(res.token)
|
|
||||||
client = new ImClient(DEFAULT_IM_WS_URL, res.token, config.appKey)
|
|
||||||
client.connect()
|
|
||||||
},
|
|
||||||
|
|
||||||
async reconnect(): Promise<void> {
|
|
||||||
const config = getConfig()
|
|
||||||
const token = await getToken()
|
|
||||||
if (!token) throw new Error('ImSDK: token not found')
|
|
||||||
client = new ImClient(DEFAULT_IM_WS_URL, token, config.appKey)
|
|
||||||
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.appKey,
|
|
||||||
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.appKey },
|
|
||||||
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.appKey },
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async editMessage(messageId: string, content: string): Promise<ImMessage> {
|
|
||||||
const config = getConfig()
|
|
||||||
return apiRequest<ImMessage>(`/api/im/messages/${encodeURIComponent(messageId)}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
params: { appId: config.appKey },
|
|
||||||
body: { content },
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
addListener(listener: ImEventListener) {
|
|
||||||
client?.addListener(listener)
|
|
||||||
},
|
|
||||||
|
|
||||||
removeListener(listener: ImEventListener) {
|
|
||||||
client?.removeListener(listener)
|
|
||||||
},
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
client?.disconnect()
|
|
||||||
client = null
|
|
||||||
},
|
|
||||||
|
|
||||||
subscribeGroup(groupId: string) {
|
|
||||||
client?.subscribeGroup(groupId)
|
|
||||||
},
|
|
||||||
|
|
||||||
isConnected() {
|
|
||||||
return client?.isConnected() ?? false
|
|
||||||
},
|
|
||||||
|
|
||||||
async createGroup(name: string, memberIds: string[]): Promise<ImGroup> {
|
|
||||||
const config = getConfig()
|
|
||||||
return apiRequest<ImGroup>('/api/im/groups', {
|
|
||||||
method: 'POST',
|
|
||||||
params: { appId: config.appKey },
|
|
||||||
body: { name, memberIds },
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async listGroups(): Promise<ImGroup[]> {
|
|
||||||
const config = getConfig()
|
|
||||||
const res = await apiRequest<ImGroup[] | { content?: ImGroup[] }>('/api/im/groups', {
|
|
||||||
params: { appId: config.appKey },
|
|
||||||
})
|
|
||||||
return Array.isArray(res) ? res : (res.content ?? [])
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchGroupHistory(groupId: string, page = 0, size = 50): Promise<ImMessage[]> {
|
|
||||||
const config = getConfig()
|
|
||||||
const res = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>(
|
|
||||||
`/api/im/messages/history/${encodeURIComponent(groupId)}`,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
appId: config.appKey,
|
|
||||||
page: String(page),
|
|
||||||
size: String(size),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return Array.isArray(res) ? res : (res.content ?? [])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
export type ChatType = 'SINGLE' | 'GROUP'
|
|
||||||
|
|
||||||
export type MsgType =
|
|
||||||
| 'TEXT'
|
|
||||||
| 'IMAGE'
|
|
||||||
| 'VIDEO'
|
|
||||||
| 'AUDIO'
|
|
||||||
| 'FILE'
|
|
||||||
| 'CUSTOM'
|
|
||||||
| 'LOCATION'
|
|
||||||
| 'NOTIFY'
|
|
||||||
| 'RICH_TEXT'
|
|
||||||
| 'CALL_AUDIO'
|
|
||||||
| 'CALL_VIDEO'
|
|
||||||
| 'REVOKED'
|
|
||||||
| 'FORWARD'
|
|
||||||
|
|
||||||
export type MsgStatus = 'SENT' | 'DELIVERED' | 'READ' | 'REVOKED'
|
|
||||||
|
|
||||||
export interface ImMessage {
|
|
||||||
id: string
|
|
||||||
appId: string
|
|
||||||
fromUserId: string
|
|
||||||
toId: string
|
|
||||||
chatType: ChatType
|
|
||||||
msgType: MsgType
|
|
||||||
content: string
|
|
||||||
status: MsgStatus
|
|
||||||
mentionedUserIds?: string
|
|
||||||
createdAt: string
|
|
||||||
editedAt?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImEventListener {
|
|
||||||
onConnected?: () => void
|
|
||||||
onDisconnected?: (reason?: string) => void
|
|
||||||
onMessage?: (msg: ImMessage) => void
|
|
||||||
onGroupMessage?: (msg: ImMessage) => void
|
|
||||||
onError?: (error: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendMessageParams {
|
|
||||||
toId: string
|
|
||||||
chatType: ChatType
|
|
||||||
msgType: MsgType
|
|
||||||
content: string
|
|
||||||
mentionedUserIds?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImGroup {
|
|
||||||
id: string
|
|
||||||
appId: string
|
|
||||||
name: string
|
|
||||||
creatorId: string
|
|
||||||
memberIds: string
|
|
||||||
adminIds: string
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
28
src/index.ts
28
src/index.ts
@ -1,9 +1,27 @@
|
|||||||
// @xuqm/rn-sdk — hidden core package.
|
export { XuqmSDK } from './sdk'
|
||||||
// App code should import @xuqm/rn-common directly; this package is installed
|
export type { UnifiedLoginOptions, UnifiedLoginRefreshResult } from './sdk'
|
||||||
// transitively by @xuqm/rn-im / @xuqm/rn-push / @xuqm/rn-update.
|
|
||||||
|
|
||||||
export { XuqmSDK } from '@xuqm/rn-common'
|
|
||||||
export type { XuqmInitOptions, DeviceInfo } from '@xuqm/rn-common'
|
export type { XuqmInitOptions, DeviceInfo } from '@xuqm/rn-common'
|
||||||
export { getDeviceId, getDeviceInfo, detectPushVendor, setUserId, getUserId } from '@xuqm/rn-common'
|
export { getDeviceId, getDeviceInfo, detectPushVendor, setUserId, getUserId } from '@xuqm/rn-common'
|
||||||
export { ScaledImage } from '@xuqm/rn-common'
|
export { ScaledImage } from '@xuqm/rn-common'
|
||||||
export { apiRequest } from '@xuqm/rn-common'
|
export { apiRequest } from '@xuqm/rn-common'
|
||||||
|
export { ImSDK, ImClient, ImDatabase, uploadFile } from '@xuqm/rn-im'
|
||||||
|
export type {
|
||||||
|
ImMessage,
|
||||||
|
ImGroup,
|
||||||
|
ChatType,
|
||||||
|
MsgType,
|
||||||
|
MsgStatus,
|
||||||
|
ImEventListener,
|
||||||
|
SendMessageParams,
|
||||||
|
ConversationData,
|
||||||
|
HistoryQuery,
|
||||||
|
PageResult,
|
||||||
|
FriendRequest,
|
||||||
|
GroupJoinRequest,
|
||||||
|
BlacklistEntry,
|
||||||
|
UserProfile,
|
||||||
|
} from '@xuqm/rn-im'
|
||||||
|
export { PushSDK } from '@xuqm/rn-push'
|
||||||
|
export type { PushVendor } from '@xuqm/rn-push'
|
||||||
|
export { UpdateSDK } from '@xuqm/rn-update'
|
||||||
|
export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from '@xuqm/rn-update'
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
import { Platform } from 'react-native'
|
|
||||||
import { apiRequest } from '../core/http'
|
|
||||||
import { getConfig } from '../core/config'
|
|
||||||
|
|
||||||
export type PushVendor = 'HUAWEI' | 'XIAOMI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APNS' | 'FCM'
|
|
||||||
|
|
||||||
export const PushSDK = {
|
|
||||||
async registerToken(userId: string, vendor: PushVendor, token: string): Promise<void> {
|
|
||||||
const config = getConfig()
|
|
||||||
await apiRequest('/api/push/register', {
|
|
||||||
method: 'POST',
|
|
||||||
params: {
|
|
||||||
appId: config.appKey,
|
|
||||||
userId,
|
|
||||||
vendor,
|
|
||||||
token,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async unregisterToken(userId: string): Promise<void> {
|
|
||||||
const config = getConfig()
|
|
||||||
await apiRequest('/api/push/unregister', {
|
|
||||||
method: 'DELETE',
|
|
||||||
params: { appId: config.appKey, userId },
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
117
src/sdk.ts
普通文件
117
src/sdk.ts
普通文件
@ -0,0 +1,117 @@
|
|||||||
|
import { _clearToken, setUserId as setCommonUserId, getUserId as getCommonUserId, XuqmSDK as CommonXuqmSDK } from '@xuqm/rn-common'
|
||||||
|
import { ImSDK } from '@xuqm/rn-im'
|
||||||
|
import { PushSDK } from '@xuqm/rn-push'
|
||||||
|
import type { UserProfile } from '@xuqm/rn-im'
|
||||||
|
|
||||||
|
export interface UnifiedLoginRefreshResult {
|
||||||
|
userSig: string
|
||||||
|
expiresAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedLoginOptions {
|
||||||
|
userId: string
|
||||||
|
userSig: string
|
||||||
|
profile?: Pick<UserProfile, 'nickname' | 'avatar' | 'gender'>
|
||||||
|
expiresAt?: number
|
||||||
|
refreshUserSig?: () => Promise<UnifiedLoginRefreshResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionState = UnifiedLoginOptions
|
||||||
|
|
||||||
|
const REFRESH_GRACE_MS = 5 * 60 * 1000
|
||||||
|
const REFRESH_RETRY_MS = 30 * 1000
|
||||||
|
|
||||||
|
let currentSession: SessionState | null = null
|
||||||
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let refreshInFlight = false
|
||||||
|
|
||||||
|
function clearRefreshTimer(): void {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearTimeout(refreshTimer)
|
||||||
|
refreshTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLoginSession(session: SessionState): Promise<void> {
|
||||||
|
clearRefreshTimer()
|
||||||
|
setCommonUserId(session.userId)
|
||||||
|
try {
|
||||||
|
await ImSDK.loginWithUserSig(session.userId, session.userSig)
|
||||||
|
} catch (error) {
|
||||||
|
setCommonUserId(null)
|
||||||
|
currentSession = null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
currentSession = session
|
||||||
|
try {
|
||||||
|
await PushSDK.initialize(session.userId)
|
||||||
|
} catch (error) {
|
||||||
|
void error
|
||||||
|
}
|
||||||
|
scheduleRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSession(): Promise<void> {
|
||||||
|
const session = currentSession
|
||||||
|
if (refreshInFlight || !session?.refreshUserSig) return
|
||||||
|
refreshInFlight = true
|
||||||
|
try {
|
||||||
|
const refreshed = await session.refreshUserSig()
|
||||||
|
if (currentSession !== session) return
|
||||||
|
await applyLoginSession({
|
||||||
|
...session,
|
||||||
|
userSig: refreshed.userSig,
|
||||||
|
expiresAt: refreshed.expiresAt ?? session.expiresAt,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
clearRefreshTimer()
|
||||||
|
refreshTimer = setTimeout(() => {
|
||||||
|
void refreshSession()
|
||||||
|
}, REFRESH_RETRY_MS)
|
||||||
|
if (CommonXuqmSDK.getUserId() && process.env.NODE_ENV !== 'production') {
|
||||||
|
console.warn('[XuqmSDK] userSig refresh failed, will retry', error)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
refreshInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRefresh(): void {
|
||||||
|
clearRefreshTimer()
|
||||||
|
const session = currentSession
|
||||||
|
if (!session?.refreshUserSig || !session.expiresAt) return
|
||||||
|
const delayMs = Math.max(session.expiresAt - Date.now() - REFRESH_GRACE_MS, 0)
|
||||||
|
refreshTimer = setTimeout(() => {
|
||||||
|
void refreshSession()
|
||||||
|
}, delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(options: UnifiedLoginOptions): Promise<void> {
|
||||||
|
if (currentSession) {
|
||||||
|
await logout()
|
||||||
|
}
|
||||||
|
await applyLoginSession(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout(): Promise<void> {
|
||||||
|
clearRefreshTimer()
|
||||||
|
const userId = currentSession?.userId ?? getCommonUserId()
|
||||||
|
currentSession = null
|
||||||
|
refreshInFlight = false
|
||||||
|
setCommonUserId(null)
|
||||||
|
if (userId) {
|
||||||
|
try {
|
||||||
|
await PushSDK.logout(userId)
|
||||||
|
} catch (error) {
|
||||||
|
void error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImSDK.disconnect()
|
||||||
|
await _clearToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const XuqmSDK = {
|
||||||
|
...CommonXuqmSDK,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
19
src/shims/async-storage.ts
普通文件
19
src/shims/async-storage.ts
普通文件
@ -0,0 +1,19 @@
|
|||||||
|
const AsyncStorage = {
|
||||||
|
async getItem(_key: string): Promise<string | null> {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
async setItem(_key: string, _value: string): Promise<void> {
|
||||||
|
return
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeItem(_key: string): Promise<void> {
|
||||||
|
return
|
||||||
|
},
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
return
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AsyncStorage
|
||||||
67
src/shims/watermelondb.ts
普通文件
67
src/shims/watermelondb.ts
普通文件
@ -0,0 +1,67 @@
|
|||||||
|
export class Model {
|
||||||
|
static table = ''
|
||||||
|
|
||||||
|
async update(updater: (record: this) => void | Promise<void>): Promise<this> {
|
||||||
|
await updater(this)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroyPermanently(): Promise<void> {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Database {
|
||||||
|
constructor(_config: { adapter: unknown; modelClasses: Array<new (...args: unknown[]) => unknown> }) {}
|
||||||
|
|
||||||
|
async write<T>(action: () => Promise<T>): Promise<T> {
|
||||||
|
return action()
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(_tableName: string): any {
|
||||||
|
return {
|
||||||
|
query: (..._queryParts: unknown[]) => ({
|
||||||
|
fetch: async () => [] as T[],
|
||||||
|
extend: () => ({
|
||||||
|
fetch: async () => [] as T[],
|
||||||
|
observe: () => ({ subscribe: () => undefined }),
|
||||||
|
}),
|
||||||
|
observe: () => ({ subscribe: () => undefined }),
|
||||||
|
}),
|
||||||
|
create: async (_builder: (record: T) => void | Promise<void>) => ({} as T),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Q: any = {
|
||||||
|
where: () => undefined,
|
||||||
|
take: () => undefined,
|
||||||
|
skip: () => undefined,
|
||||||
|
sortBy: () => undefined,
|
||||||
|
oneOf: () => undefined,
|
||||||
|
like: () => undefined,
|
||||||
|
gte: () => undefined,
|
||||||
|
lte: () => undefined,
|
||||||
|
sanitizeLikeString: (value: string) => value,
|
||||||
|
desc: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appSchema(input: unknown): any {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tableSchema(input: unknown): any {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
export function field(_name: string): any {
|
||||||
|
return () => undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function date(_name: string): any {
|
||||||
|
return () => undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SQLiteAdapter {
|
||||||
|
constructor(_config: { schema: unknown; dbName: string; jsi?: boolean; onSetUpError?: (err: unknown) => void }) {}
|
||||||
|
}
|
||||||
4
src/types/async-storage.d.ts
vendored
4
src/types/async-storage.d.ts
vendored
@ -1,9 +1,11 @@
|
|||||||
declare module '@react-native-async-storage/async-storage' {
|
declare module '@react-native-async-storage/async-storage' {
|
||||||
const AsyncStorage: {
|
export interface AsyncStorageStatic {
|
||||||
getItem(key: string): Promise<string | null>
|
getItem(key: string): Promise<string | null>
|
||||||
setItem(key: string, value: string): Promise<void>
|
setItem(key: string, value: string): Promise<void>
|
||||||
removeItem(key: string): Promise<void>
|
removeItem(key: string): Promise<void>
|
||||||
|
clear(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AsyncStorage: AsyncStorageStatic
|
||||||
export default AsyncStorage
|
export default AsyncStorage
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/types/watermelondb.d.ts
vendored
57
src/types/watermelondb.d.ts
vendored
@ -1,61 +1,30 @@
|
|||||||
declare module '@nozbe/watermelondb' {
|
declare module '@nozbe/watermelondb' {
|
||||||
export class Model {
|
export class Model {
|
||||||
static table: string
|
static table: string
|
||||||
update(mutator: (record: this) => void | Promise<void>): Promise<this>
|
constructor(...args: unknown[])
|
||||||
|
update(updater: (record: this) => void | Promise<void>): Promise<this>
|
||||||
destroyPermanently(): Promise<void>
|
destroyPermanently(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Database {
|
export class Database {
|
||||||
constructor(options: unknown)
|
constructor(config: { adapter: unknown; modelClasses: Array<new (...args: unknown[]) => unknown> })
|
||||||
write<T>(work: () => Promise<T> | T): Promise<T>
|
write<T>(action: () => Promise<T>): Promise<T>
|
||||||
get<T extends Model>(table: string): Collection<T>
|
get<T>(tableName: string): any
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Collection<T extends Model> {
|
export const Q: any
|
||||||
query(...conditions: unknown[]): Query<T>
|
|
||||||
create(builder: (record: T) => void | Promise<void>): Promise<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Query<T extends Model> {
|
export function appSchema(input: unknown): any
|
||||||
fetch(): Promise<T[]>
|
export function tableSchema(input: unknown): any
|
||||||
extend(...conditions: unknown[]): Query<T>
|
}
|
||||||
observe(): Observable<T[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Observable<T> {
|
declare module '@nozbe/watermelondb/decorators' {
|
||||||
subscribe(callback: (value: T) => void): Subscription
|
export const field: any
|
||||||
}
|
export const date: any
|
||||||
|
|
||||||
export interface Subscription {
|
|
||||||
unsubscribe(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Q: {
|
|
||||||
and(...conditions: unknown[]): unknown
|
|
||||||
or(...conditions: unknown[]): unknown
|
|
||||||
where(column: string, value: unknown): unknown
|
|
||||||
sortBy(column: string, order?: unknown): unknown
|
|
||||||
take(count: number): unknown
|
|
||||||
skip(count: number): unknown
|
|
||||||
oneOf(values: unknown[]): unknown
|
|
||||||
like(pattern: string): unknown
|
|
||||||
gte(value: unknown): unknown
|
|
||||||
lte(value: unknown): unknown
|
|
||||||
sanitizeLikeString(value: string): string
|
|
||||||
desc: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appSchema(schema: unknown): unknown
|
|
||||||
export function tableSchema(schema: unknown): unknown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@nozbe/watermelondb/adapters/sqlite' {
|
declare module '@nozbe/watermelondb/adapters/sqlite' {
|
||||||
export default class SQLiteAdapter {
|
export default class SQLiteAdapter {
|
||||||
constructor(options: unknown)
|
constructor(config: { schema: unknown; dbName: string; jsi?: boolean; onSetUpError?: (err: unknown) => void })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@nozbe/watermelondb/decorators' {
|
|
||||||
export function field(columnName: string): any
|
|
||||||
export function date(columnName: string): any
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,130 +0,0 @@
|
|||||||
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
|
|
||||||
versionName?: string
|
|
||||||
versionCode?: number
|
|
||||||
downloadUrl?: string
|
|
||||||
changeLog?: string
|
|
||||||
forceUpdate?: boolean
|
|
||||||
appStoreUrl?: string
|
|
||||||
marketUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RnUpdateInfo {
|
|
||||||
needsUpdate: boolean
|
|
||||||
latestVersion: string
|
|
||||||
downloadUrl: string
|
|
||||||
md5: string
|
|
||||||
minCommonVersion: string
|
|
||||||
note: string
|
|
||||||
packageName?: string
|
|
||||||
packageMatched?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
const result = await apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', {
|
|
||||||
params: {
|
|
||||||
appId: config.appKey,
|
|
||||||
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
|
||||||
currentVersionCode: String(currentVersionCode),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
downloadUrl: normalizeDownloadUrl(result.downloadUrl),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async openAppStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
|
|
||||||
const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl
|
|
||||||
if (url) await Linking.openURL(url)
|
|
||||||
},
|
|
||||||
|
|
||||||
async checkRnUpdate(moduleId: string, currentVersion: string, packageName?: string): Promise<RnUpdateInfo> {
|
|
||||||
const config = getConfig()
|
|
||||||
const result = await apiRequest<RnUpdateInfo>('/api/v1/rn/update/check', {
|
|
||||||
params: {
|
|
||||||
appId: config.appKey,
|
|
||||||
moduleId,
|
|
||||||
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
|
||||||
currentVersion,
|
|
||||||
...(packageName ? { packageName } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (packageName && result.packageMatched === false) {
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
needsUpdate: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -5,12 +5,17 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@react-native-async-storage/async-storage": ["src/shims/async-storage.ts"],
|
||||||
|
"@nozbe/watermelondb": ["src/shims/watermelondb.ts"],
|
||||||
|
"@nozbe/watermelondb/decorators": ["src/shims/watermelondb.ts"],
|
||||||
|
"@nozbe/watermelondb/adapters/sqlite": ["src/shims/watermelondb.ts"],
|
||||||
"@xuqm/rn-common": ["packages/common/src"],
|
"@xuqm/rn-common": ["packages/common/src"],
|
||||||
"@xuqm/rn-im": ["packages/im/src"],
|
"@xuqm/rn-im": ["packages/im/src"],
|
||||||
"@xuqm/rn-push": ["packages/push/src"],
|
"@xuqm/rn-push": ["packages/push/src"],
|
||||||
"@xuqm/rn-update": ["packages/update/src"]
|
"@xuqm/rn-update": ["packages/update/src"]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
"jsx": "react-native",
|
"jsx": "react-native",
|
||||||
"lib": ["ES2020"],
|
"lib": ["ES2020"],
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户