chore: initial commit
这个提交包含在:
当前提交
ad0a31bd91
10
.gitignore
vendored
普通文件
10
.gitignore
vendored
普通文件
@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.class
|
||||||
|
target/
|
||||||
|
build/
|
||||||
|
.gradle/
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
*.log
|
||||||
26
package.json
普通文件
26
package.json
普通文件
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@xuqm/rn-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "XuqmGroup React Native SDK — IM, Push, Version Management",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"private": false,
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"react-native": ">=0.76.0",
|
||||||
|
"@react-native-async-storage/async-storage": ">=1.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-native": "^0.73.0"
|
||||||
|
},
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
19
src/core/config.ts
普通文件
19
src/core/config.ts
普通文件
@ -0,0 +1,19 @@
|
|||||||
|
export interface XuqmSDKConfig {
|
||||||
|
appId: string
|
||||||
|
appKey: string
|
||||||
|
appSecret: string
|
||||||
|
apiBaseUrl: string
|
||||||
|
imWsUrl: 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
|
||||||
|
}
|
||||||
48
src/core/http.ts
普通文件
48
src/core/http.ts
普通文件
@ -0,0 +1,48 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
import { getConfig } from './config'
|
||||||
|
|
||||||
|
const TOKEN_KEY = '@xuqm_sdk_token'
|
||||||
|
|
||||||
|
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 = config.apiBaseUrl.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
|
||||||
|
}
|
||||||
86
src/im/imClient.ts
普通文件
86
src/im/imClient.ts
普通文件
@ -0,0 +1,86 @@
|
|||||||
|
import type { ChatType, ImMessage, ImEventListener, MsgType } from './types'
|
||||||
|
|
||||||
|
export class ImClient {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
private listeners: ImEventListener[] = []
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private reconnectDelay = 3000
|
||||||
|
private shouldReconnect = true
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly wsUrl: string,
|
||||||
|
private readonly token: string,
|
||||||
|
private readonly appId: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.shouldReconnect = true
|
||||||
|
this._connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
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?.())
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
addListener(listener: ImEventListener) {
|
||||||
|
this.listeners.push(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeListener(listener: ImEventListener) {
|
||||||
|
this.listeners = this.listeners.filter(l => l !== listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.shouldReconnect = false
|
||||||
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||||||
|
this.ws?.close(1000, 'User disconnect')
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.ws?.readyState === WebSocket.OPEN
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/im/imSDK.ts
普通文件
46
src/im/imSDK.ts
普通文件
@ -0,0 +1,46 @@
|
|||||||
|
import { apiRequest, saveToken } from '../core/http'
|
||||||
|
import { getConfig } from '../core/config'
|
||||||
|
import { ImClient } from './imClient'
|
||||||
|
import type { ChatType, ImEventListener, MsgType } from './types'
|
||||||
|
|
||||||
|
let client: ImClient | null = null
|
||||||
|
|
||||||
|
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.appId,
|
||||||
|
userId,
|
||||||
|
...(nickname ? { nickname } : {}),
|
||||||
|
...(avatar ? { avatar } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await saveToken(res.token)
|
||||||
|
client = new ImClient(config.imWsUrl, res.token, config.appId)
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
|
||||||
|
addListener(listener: ImEventListener) {
|
||||||
|
client?.addListener(listener)
|
||||||
|
},
|
||||||
|
|
||||||
|
removeListener(listener: ImEventListener) {
|
||||||
|
client?.removeListener(listener)
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
client?.disconnect()
|
||||||
|
client = null
|
||||||
|
},
|
||||||
|
|
||||||
|
isConnected() {
|
||||||
|
return client?.isConnected() ?? false
|
||||||
|
},
|
||||||
|
}
|
||||||
36
src/im/types.ts
普通文件
36
src/im/types.ts
普通文件
@ -0,0 +1,36 @@
|
|||||||
|
export type ChatType = 'SINGLE' | 'GROUP'
|
||||||
|
|
||||||
|
export type MsgType =
|
||||||
|
| 'TEXT'
|
||||||
|
| 'IMAGE'
|
||||||
|
| 'VIDEO'
|
||||||
|
| 'AUDIO'
|
||||||
|
| 'FILE'
|
||||||
|
| 'CUSTOM'
|
||||||
|
| 'LOCATION'
|
||||||
|
| 'NOTIFY'
|
||||||
|
| '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
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImEventListener {
|
||||||
|
onConnected?: () => void
|
||||||
|
onDisconnected?: (reason?: string) => void
|
||||||
|
onMessage?: (msg: ImMessage) => void
|
||||||
|
onGroupMessage?: (msg: ImMessage) => void
|
||||||
|
onError?: (error: string) => void
|
||||||
|
}
|
||||||
13
src/index.ts
普通文件
13
src/index.ts
普通文件
@ -0,0 +1,13 @@
|
|||||||
|
export { initSDK, getConfig } from './core/config'
|
||||||
|
export type { XuqmSDKConfig } from './core/config'
|
||||||
|
export { getToken, saveToken, clearToken } from './core/http'
|
||||||
|
|
||||||
|
export { ImSDK } from './im/imSDK'
|
||||||
|
export { ImClient } from './im/imClient'
|
||||||
|
export type { ImMessage, ChatType, MsgType, MsgStatus, ImEventListener } 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'
|
||||||
28
src/push/pushSDK.ts
普通文件
28
src/push/pushSDK.ts
普通文件
@ -0,0 +1,28 @@
|
|||||||
|
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.appId,
|
||||||
|
userId,
|
||||||
|
vendor,
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async unregisterToken(userId: string): Promise<void> {
|
||||||
|
const config = getConfig()
|
||||||
|
await apiRequest('/api/push/unregister', {
|
||||||
|
method: 'DELETE',
|
||||||
|
params: { appId: config.appId, userId },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
53
src/update/updateSDK.ts
普通文件
53
src/update/updateSDK.ts
普通文件
@ -0,0 +1,53 @@
|
|||||||
|
import { Platform, Linking } from 'react-native'
|
||||||
|
import { apiRequest } from '../core/http'
|
||||||
|
import { getConfig } from '../core/config'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateSDK = {
|
||||||
|
async checkAppUpdate(currentVersionCode: number): Promise<AppUpdateInfo> {
|
||||||
|
const config = getConfig()
|
||||||
|
return apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', {
|
||||||
|
params: {
|
||||||
|
appId: config.appId,
|
||||||
|
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
||||||
|
currentVersionCode: String(currentVersionCode),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
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): Promise<RnUpdateInfo> {
|
||||||
|
const config = getConfig()
|
||||||
|
return apiRequest<RnUpdateInfo>('/api/v1/rn/update/check', {
|
||||||
|
params: {
|
||||||
|
appId: config.appId,
|
||||||
|
moduleId,
|
||||||
|
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
||||||
|
currentVersion,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
15
tsconfig.json
普通文件
15
tsconfig.json
普通文件
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-native",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户