From ad0a31bd9156c033497a7b399690af487f640fac Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 21 Apr 2026 22:07:29 +0800 Subject: [PATCH] chore: initial commit --- .gitignore | 10 +++++ .npmrc | 1 + package.json | 26 +++++++++++++ src/core/config.ts | 19 +++++++++ src/core/http.ts | 48 +++++++++++++++++++++++ src/im/imClient.ts | 86 +++++++++++++++++++++++++++++++++++++++++ src/im/imSDK.ts | 46 ++++++++++++++++++++++ src/im/types.ts | 36 +++++++++++++++++ src/index.ts | 13 +++++++ src/push/pushSDK.ts | 28 ++++++++++++++ src/update/updateSDK.ts | 53 +++++++++++++++++++++++++ tsconfig.json | 15 +++++++ 12 files changed, 381 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 package.json create mode 100644 src/core/config.ts create mode 100644 src/core/http.ts create mode 100644 src/im/imClient.ts create mode 100644 src/im/imSDK.ts create mode 100644 src/im/types.ts create mode 100644 src/index.ts create mode 100644 src/push/pushSDK.ts create mode 100644 src/update/updateSDK.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10dfc90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.DS_Store +*.class +target/ +build/ +.gradle/ +*.iml +.idea/ +*.log diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..e21d136 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +registry=https://nexus.xuqinmin.com/repository/npm-hosted/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..b4b9ca0 --- /dev/null +++ b/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": {} +} diff --git a/src/core/config.ts b/src/core/config.ts new file mode 100644 index 0000000..ec229f6 --- /dev/null +++ b/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 +} diff --git a/src/core/http.ts b/src/core/http.ts new file mode 100644 index 0000000..f499231 --- /dev/null +++ b/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 { + return AsyncStorage.getItem(TOKEN_KEY) +} + +export async function saveToken(token: string): Promise { + return AsyncStorage.setItem(TOKEN_KEY, token) +} + +export async function clearToken(): Promise { + return AsyncStorage.removeItem(TOKEN_KEY) +} + +export async function apiRequest( + path: string, + options: { method?: string; body?: unknown; params?: Record } = {}, +): Promise { + 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 +} diff --git a/src/im/imClient.ts b/src/im/imClient.ts new file mode 100644 index 0000000..a3f1514 --- /dev/null +++ b/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 | 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 + } +} diff --git a/src/im/imSDK.ts b/src/im/imSDK.ts new file mode 100644 index 0000000..7c84e71 --- /dev/null +++ b/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 { + 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 + }, +} diff --git a/src/im/types.ts b/src/im/types.ts new file mode 100644 index 0000000..6bf21a6 --- /dev/null +++ b/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 +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..eb05fd6 --- /dev/null +++ b/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' diff --git a/src/push/pushSDK.ts b/src/push/pushSDK.ts new file mode 100644 index 0000000..21c1d7a --- /dev/null +++ b/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 { + const config = getConfig() + await apiRequest('/api/push/register', { + method: 'POST', + params: { + appId: config.appId, + userId, + vendor, + token, + }, + }) + }, + + async unregisterToken(userId: string): Promise { + const config = getConfig() + await apiRequest('/api/push/unregister', { + method: 'DELETE', + params: { appId: config.appId, userId }, + }) + }, +} diff --git a/src/update/updateSDK.ts b/src/update/updateSDK.ts new file mode 100644 index 0000000..954a8e5 --- /dev/null +++ b/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 { + const config = getConfig() + return apiRequest('/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 { + const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl + if (url) await Linking.openURL(url) + }, + + async checkRnUpdate(moduleId: string, currentVersion: string): Promise { + const config = getConfig() + return apiRequest('/api/v1/rn/update/check', { + params: { + appId: config.appId, + moduleId, + platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS', + currentVersion, + }, + }) + }, +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4a81949 --- /dev/null +++ b/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"] +}