feat: async remote init, file upload in SDK, device info, ScaledImage, DB v2
- XuqmSDK.initialize(appId, serverUrl): fetches config from tenant platform - ImSDK: sendImageMessage/sendVideoMessage/sendAudioMessage/sendFileMessage - upload.ts: FormData upload to file-service with Bearer auth - device.ts: deviceId (UUID), brand→pushVendor detection, platform info - ScaledImage component: aspect-ratio bounded image rendering - WatermelonDB schema v2: is_muted, is_pinned on conversations - subscribeConversations: reactive WatermelonDB observable - searchMessages: keyword/date/chat-type/target filtering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
4e821b280b
当前提交
d7e7cd8e2d
@ -19,12 +19,7 @@
|
|||||||
"react-native": ">=0.76.0",
|
"react-native": ">=0.76.0",
|
||||||
"@react-native-async-storage/async-storage": ">=1.21.0"
|
"@react-native-async-storage/async-storage": ">=1.21.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {},
|
||||||
"@xuqm/rn-common": "*",
|
|
||||||
"@xuqm/rn-im": "*",
|
|
||||||
"@xuqm/rn-push": "*",
|
|
||||||
"@xuqm/rn-update": "*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
@ -0,0 +1,116 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Image, View, StyleSheet } from 'react-native'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
uri: string
|
||||||
|
originalWidth?: number
|
||||||
|
originalHeight?: number
|
||||||
|
minWidth?: number
|
||||||
|
maxWidth?: number
|
||||||
|
minHeight?: number
|
||||||
|
maxHeight?: number
|
||||||
|
borderRadius?: number
|
||||||
|
style?: object
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDimensions(
|
||||||
|
srcWidth: number,
|
||||||
|
srcHeight: number,
|
||||||
|
minWidth: number,
|
||||||
|
maxWidth: number,
|
||||||
|
minHeight: number,
|
||||||
|
maxHeight: number,
|
||||||
|
): { width: number; height: number } {
|
||||||
|
if (srcWidth <= 0 || srcHeight <= 0) {
|
||||||
|
return { width: minWidth, height: minHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspectRatio = srcWidth / srcHeight
|
||||||
|
|
||||||
|
// Start from maxWidth and compute height
|
||||||
|
let width = maxWidth
|
||||||
|
let height = width / aspectRatio
|
||||||
|
|
||||||
|
// If height exceeds maxHeight, clamp by height
|
||||||
|
if (height > maxHeight) {
|
||||||
|
height = maxHeight
|
||||||
|
width = height * aspectRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce minimums
|
||||||
|
if (width < minWidth) {
|
||||||
|
width = minWidth
|
||||||
|
height = width / aspectRatio
|
||||||
|
}
|
||||||
|
if (height < minHeight) {
|
||||||
|
height = minHeight
|
||||||
|
width = height * aspectRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final clamp to maximums after minimum adjustments
|
||||||
|
width = Math.min(width, maxWidth)
|
||||||
|
height = Math.min(height, maxHeight)
|
||||||
|
|
||||||
|
return { width: Math.round(width), height: Math.round(height) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScaledImage(props: Props): JSX.Element {
|
||||||
|
const {
|
||||||
|
uri,
|
||||||
|
originalWidth,
|
||||||
|
originalHeight,
|
||||||
|
minWidth = 120,
|
||||||
|
maxWidth = 240,
|
||||||
|
minHeight = 80,
|
||||||
|
maxHeight = 320,
|
||||||
|
borderRadius,
|
||||||
|
style,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(() => {
|
||||||
|
if (originalWidth !== undefined && originalHeight !== undefined) {
|
||||||
|
return computeDimensions(originalWidth, originalHeight, minWidth, maxWidth, minHeight, maxHeight)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (originalWidth !== undefined && originalHeight !== undefined) {
|
||||||
|
setDimensions(
|
||||||
|
computeDimensions(originalWidth, originalHeight, minWidth, maxWidth, minHeight, maxHeight),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Image.getSize to determine dimensions at runtime
|
||||||
|
Image.getSize(
|
||||||
|
uri,
|
||||||
|
(w, h) => {
|
||||||
|
setDimensions(computeDimensions(w, h, minWidth, maxWidth, minHeight, maxHeight))
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Fallback to min dimensions on error
|
||||||
|
setDimensions({ width: minWidth, height: minHeight })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, [uri, originalWidth, originalHeight, minWidth, maxWidth, minHeight, maxHeight])
|
||||||
|
|
||||||
|
if (!dimensions) {
|
||||||
|
// Placeholder while size is being resolved
|
||||||
|
return <View style={[styles.placeholder, { width: minWidth, height: minHeight, borderRadius }, style]} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={[{ width: dimensions.width, height: dimensions.height, borderRadius }, style]}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
placeholder: {
|
||||||
|
backgroundColor: '#e0e0e0',
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { API_BASE_URL, IM_WS_URL } from './constants'
|
|
||||||
|
|
||||||
export interface XuqmInitOptions {
|
export interface XuqmInitOptions {
|
||||||
appId: string
|
appId: string
|
||||||
|
serverUrl: string // e.g. "https://sentry.xuqinmin.com" — SDK fetches config from here
|
||||||
appKey?: string
|
appKey?: string
|
||||||
debug?: boolean
|
debug?: boolean
|
||||||
}
|
}
|
||||||
@ -9,25 +8,32 @@ export interface XuqmInitOptions {
|
|||||||
export interface XuqmConfig {
|
export interface XuqmConfig {
|
||||||
appId: string
|
appId: string
|
||||||
appKey: string
|
appKey: string
|
||||||
apiBaseUrl: string
|
serverUrl: string
|
||||||
imWsUrl: string
|
apiBaseUrl: string // im-service base URL
|
||||||
|
imWsUrl: string // fetched from remote config
|
||||||
|
fileServiceUrl: string // fetched from remote config
|
||||||
debug: boolean
|
debug: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let _config: XuqmConfig | null = null
|
let _config: XuqmConfig | null = null
|
||||||
|
|
||||||
export function initConfig(options: XuqmInitOptions): void {
|
export function initConfigFromRemote(
|
||||||
|
options: XuqmInitOptions,
|
||||||
|
remote: { imWsUrl: string; fileServiceUrl: string; apiBaseUrl: string },
|
||||||
|
): void {
|
||||||
_config = {
|
_config = {
|
||||||
appId: options.appId,
|
appId: options.appId,
|
||||||
appKey: options.appKey ?? options.appId,
|
appKey: options.appKey ?? options.appId,
|
||||||
apiBaseUrl: API_BASE_URL,
|
serverUrl: options.serverUrl,
|
||||||
imWsUrl: IM_WS_URL,
|
apiBaseUrl: remote.apiBaseUrl,
|
||||||
|
imWsUrl: remote.imWsUrl,
|
||||||
|
fileServiceUrl: remote.fileServiceUrl,
|
||||||
debug: options.debug ?? false,
|
debug: options.debug ?? false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConfig(): XuqmConfig {
|
export function getConfig(): XuqmConfig {
|
||||||
if (!_config) throw new Error('[XuqmSDK] Not initialized — call XuqmSDK.init() first.')
|
if (!_config) throw new Error('[XuqmSDK] Not initialized — call XuqmSDK.initialize() first.')
|
||||||
return _config
|
return _config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @deprecated These hardcoded URLs are no longer used by the SDK.
|
||||||
|
* The SDK now fetches configuration from the tenant platform via
|
||||||
|
* XuqmSDK.initialize(). These constants are kept only as fallback
|
||||||
|
* references and for backward compatibility.
|
||||||
|
*/
|
||||||
export const API_BASE_URL = 'https://sentry.xuqinmin.com'
|
export const API_BASE_URL = 'https://sentry.xuqinmin.com'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated See API_BASE_URL.
|
||||||
|
*/
|
||||||
export const IM_WS_URL = 'wss://sentry.xuqinmin.com/ws/im'
|
export const IM_WS_URL = 'wss://sentry.xuqinmin.com/ws/im'
|
||||||
|
|||||||
82
packages/common/src/device.ts
普通文件
82
packages/common/src/device.ts
普通文件
@ -0,0 +1,82 @@
|
|||||||
|
import { Platform } from 'react-native'
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
|
||||||
|
const DEVICE_ID_KEY = '@xuqm:deviceId'
|
||||||
|
|
||||||
|
function uuid(): string {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0
|
||||||
|
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let _cachedId: string | null = null
|
||||||
|
|
||||||
|
/** Returns a stable per-install UUID stored in AsyncStorage. */
|
||||||
|
export async function getDeviceId(): Promise<string> {
|
||||||
|
if (_cachedId) return _cachedId
|
||||||
|
let id = await AsyncStorage.getItem(DEVICE_ID_KEY)
|
||||||
|
if (!id) {
|
||||||
|
id = uuid()
|
||||||
|
await AsyncStorage.setItem(DEVICE_ID_KEY, id)
|
||||||
|
}
|
||||||
|
_cachedId = id
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PushVendor = 'HUAWEI' | 'XIAOMI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APNS' | 'FCM'
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
deviceId: string
|
||||||
|
platform: 'ANDROID' | 'IOS'
|
||||||
|
brand: string
|
||||||
|
model: string
|
||||||
|
osVersion: string
|
||||||
|
pushVendor: PushVendor
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRAND_MAP: Record<string, PushVendor> = {
|
||||||
|
xiaomi: 'XIAOMI',
|
||||||
|
redmi: 'XIAOMI',
|
||||||
|
huawei: 'HUAWEI',
|
||||||
|
honor: 'HONOR',
|
||||||
|
oppo: 'OPPO',
|
||||||
|
realme: 'OPPO',
|
||||||
|
vivo: 'VIVO',
|
||||||
|
iqoo: 'VIVO',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectPushVendor(brand: string): PushVendor {
|
||||||
|
if (Platform.OS === 'ios') return 'APNS'
|
||||||
|
const b = brand.toLowerCase()
|
||||||
|
for (const [key, v] of Object.entries(BRAND_MAP)) {
|
||||||
|
if (b.includes(key)) return v
|
||||||
|
}
|
||||||
|
return 'FCM'
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const C = Platform.constants as any
|
||||||
|
|
||||||
|
export async function getDeviceInfo(): Promise<DeviceInfo> {
|
||||||
|
const deviceId = await getDeviceId()
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
return {
|
||||||
|
deviceId,
|
||||||
|
platform: 'IOS',
|
||||||
|
brand: 'APPLE',
|
||||||
|
model: String(C.systemName ?? 'iPhone'),
|
||||||
|
osVersion: String(C.osVersion ?? Platform.Version),
|
||||||
|
pushVendor: 'APNS',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const brand = String(C.Brand ?? '')
|
||||||
|
return {
|
||||||
|
deviceId,
|
||||||
|
platform: 'ANDROID',
|
||||||
|
brand: brand.toUpperCase(),
|
||||||
|
model: String(C.Model ?? ''),
|
||||||
|
osVersion: String(C.Release ?? Platform.Version),
|
||||||
|
pushVendor: detectPushVendor(brand),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
export { XuqmSDK } from './sdk'
|
export { XuqmSDK } from './sdk'
|
||||||
export type { XuqmInitOptions } from './config'
|
export type { XuqmInitOptions, XuqmConfig } from './config'
|
||||||
export { getConfig, isInitialized } from './config'
|
export { getConfig, isInitialized } from './config'
|
||||||
export { apiRequest, _getToken, _saveToken, _clearToken } from './http'
|
export { apiRequest, _getToken, _saveToken, _clearToken } from './http'
|
||||||
export { API_BASE_URL, IM_WS_URL } from './constants'
|
export { API_BASE_URL, IM_WS_URL } from './constants'
|
||||||
|
export { getDeviceId, getDeviceInfo, detectPushVendor } from './device'
|
||||||
|
export type { DeviceInfo, PushVendor } from './device'
|
||||||
|
export { ScaledImage } from './components/ScaledImage'
|
||||||
|
|||||||
@ -1,15 +1,53 @@
|
|||||||
import { initConfig, isInitialized, type XuqmInitOptions } from './config'
|
import { initConfigFromRemote, isInitialized, type XuqmInitOptions } from './config'
|
||||||
|
|
||||||
export const XuqmSDK = {
|
export const XuqmSDK = {
|
||||||
/**
|
/**
|
||||||
* Initialize the SDK. Must be called once before using any module.
|
* Async initialize — fetches SDK config from the tenant platform.
|
||||||
|
* Recommended for production use.
|
||||||
*
|
*
|
||||||
* @param options.appId - Your application ID (from the tenant platform)
|
* @param options.appId - Your application ID (from the tenant platform)
|
||||||
|
* @param options.serverUrl - Base URL of the tenant platform, e.g. "https://sentry.xuqinmin.com"
|
||||||
|
* @param options.appKey - Optional; defaults to appId
|
||||||
|
* @param options.debug - Enable verbose logging
|
||||||
|
*/
|
||||||
|
async initialize(options: XuqmInitOptions): Promise<void> {
|
||||||
|
if (isInitialized()) return
|
||||||
|
const configUrl = `${options.serverUrl}/api/sdk/config?appId=${options.appId}`
|
||||||
|
try {
|
||||||
|
const res = await fetch(configUrl)
|
||||||
|
const json = await res.json()
|
||||||
|
const remote = json.data ?? json
|
||||||
|
initConfigFromRemote(options, {
|
||||||
|
imWsUrl: remote.imWsUrl,
|
||||||
|
fileServiceUrl: remote.fileServiceUrl,
|
||||||
|
apiBaseUrl: remote.imApiUrl ?? options.serverUrl,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: construct URLs from serverUrl
|
||||||
|
initConfigFromRemote(options, {
|
||||||
|
imWsUrl: options.serverUrl.replace('https://', 'wss://').replace('http://', 'ws://') + '/ws/im',
|
||||||
|
fileServiceUrl: options.serverUrl,
|
||||||
|
apiBaseUrl: options.serverUrl,
|
||||||
|
})
|
||||||
|
if (options.debug) console.warn('[XuqmSDK] Config fetch failed, using fallback URLs', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync initialize — uses fallback URL construction from serverUrl.
|
||||||
|
* Kept for backward compatibility; prefer initialize() for production use.
|
||||||
|
*
|
||||||
|
* @param options.appId - Your application ID (from the tenant platform)
|
||||||
|
* @param options.serverUrl - Base URL of the tenant platform, e.g. "https://sentry.xuqinmin.com"
|
||||||
* @param options.appKey - Optional; defaults to appId
|
* @param options.appKey - Optional; defaults to appId
|
||||||
* @param options.debug - Enable verbose logging
|
* @param options.debug - Enable verbose logging
|
||||||
*/
|
*/
|
||||||
init(options: XuqmInitOptions): void {
|
init(options: XuqmInitOptions): void {
|
||||||
if (isInitialized()) return
|
if (isInitialized()) return
|
||||||
initConfig(options)
|
initConfigFromRemote(options, {
|
||||||
|
imWsUrl: options.serverUrl.replace('https://', 'wss://').replace('http://', 'ws://') + '/ws/im',
|
||||||
|
fileServiceUrl: options.serverUrl,
|
||||||
|
apiBaseUrl: options.serverUrl,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,69 @@
|
|||||||
import { apiRequest, _getToken, _saveToken, getConfig } from '@xuqm/rn-common'
|
import { apiRequest, _getToken, _saveToken, getConfig, getDeviceInfo } from '@xuqm/rn-common'
|
||||||
import { ImClient } from './ImClient'
|
import { ImClient } from './ImClient'
|
||||||
import { ImDatabase } from './db/ImDatabase'
|
import { ImDatabase } from './db/ImDatabase'
|
||||||
|
import type { MessageSearchParams } from './db/ImDatabase'
|
||||||
import type { ChatType, ImEventListener, ImGroup, ImMessage, MsgType } from './types'
|
import type { ChatType, ImEventListener, ImGroup, ImMessage, MsgType } from './types'
|
||||||
|
import { uploadFile } from './upload'
|
||||||
|
|
||||||
let client: ImClient | null = null
|
let client: ImClient | null = null
|
||||||
let _currentUserId: string | null = null
|
let _currentUserId: string | null = null
|
||||||
|
|
||||||
|
export interface ConversationData {
|
||||||
|
targetId: string
|
||||||
|
chatType: 'SINGLE' | 'GROUP'
|
||||||
|
lastMsgContent: string
|
||||||
|
lastMsgType: string
|
||||||
|
lastMsgTime: number
|
||||||
|
unreadCount: number
|
||||||
|
isMuted: boolean
|
||||||
|
isPinned: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _syncHistoryForAllConversations(): Promise<void> {
|
||||||
|
const config = getConfig()
|
||||||
|
if (!ImDatabase.isInitialized() || !_currentUserId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch top 20 conversations from server
|
||||||
|
const res = await apiRequest<ImMessage[] | { content?: ImMessage[] }>(
|
||||||
|
'/api/im/conversations',
|
||||||
|
{ params: { appId: config.appId, page: '0', size: '20' } },
|
||||||
|
)
|
||||||
|
const serverConversations = Array.isArray(res) ? res : (res.content ?? [])
|
||||||
|
|
||||||
|
// For each conversation, fetch last 30 messages and save locally
|
||||||
|
// serverConversations items may be conversation summaries; we use the common
|
||||||
|
// history endpoint that accepts a targetId. If the response is ImMessage[]
|
||||||
|
// we extract unique toIds.
|
||||||
|
const targetIds: string[] = []
|
||||||
|
for (const item of serverConversations as any[]) {
|
||||||
|
const targetId: string | undefined = item.targetId ?? item.toId ?? item.id
|
||||||
|
if (targetId && !targetIds.includes(targetId)) {
|
||||||
|
targetIds.push(targetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
targetIds.map(async (targetId) => {
|
||||||
|
try {
|
||||||
|
const msgRes = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>(
|
||||||
|
`/api/im/messages/history/${encodeURIComponent(targetId)}`,
|
||||||
|
{ params: { appId: config.appId, page: '0', size: '30' } },
|
||||||
|
)
|
||||||
|
const messages = Array.isArray(msgRes) ? msgRes : (msgRes.content ?? [])
|
||||||
|
if (messages.length > 0 && _currentUserId) {
|
||||||
|
await ImDatabase.bulkSave(messages, _currentUserId)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently skip conversations that fail to sync
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Sync is best-effort; do not throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const ImSDK = {
|
export const ImSDK = {
|
||||||
/**
|
/**
|
||||||
* Login to IM service. Fetches a token internally and opens the WebSocket connection.
|
* Login to IM service. Fetches a token internally and opens the WebSocket connection.
|
||||||
@ -13,12 +71,18 @@ 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 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,
|
||||||
params: {
|
params: {
|
||||||
appId: config.appId,
|
appId: config.appId,
|
||||||
userId,
|
userId,
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
platform: device.platform,
|
||||||
|
brand: device.brand,
|
||||||
|
model: device.model,
|
||||||
|
osVersion: device.osVersion,
|
||||||
...(nickname ? { nickname } : {}),
|
...(nickname ? { nickname } : {}),
|
||||||
...(avatar ? { avatar } : {}),
|
...(avatar ? { avatar } : {}),
|
||||||
},
|
},
|
||||||
@ -31,6 +95,33 @@ export const ImSDK = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client = new ImClient(config.imWsUrl, res.token, config.appId)
|
client = new ImClient(config.imWsUrl, res.token, config.appId)
|
||||||
|
client.addListener({
|
||||||
|
onConnected: () => {
|
||||||
|
_syncHistoryForAllConversations().catch(() => {})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
client.connect()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with a pre-obtained IM token (e.g. from demo-service).
|
||||||
|
* Sets up the IM connection without calling the auth endpoint.
|
||||||
|
*/
|
||||||
|
async loginWithToken(userId: string, token: string, dbName?: string): Promise<void> {
|
||||||
|
const config = getConfig()
|
||||||
|
await _saveToken(token)
|
||||||
|
_currentUserId = userId
|
||||||
|
|
||||||
|
if (dbName !== undefined || ImDatabase.isInitialized()) {
|
||||||
|
ImDatabase.init(dbName ?? 'xuqm_im')
|
||||||
|
}
|
||||||
|
|
||||||
|
client = new ImClient(config.imWsUrl, token, config.appId)
|
||||||
|
client.addListener({
|
||||||
|
onConnected: () => {
|
||||||
|
_syncHistoryForAllConversations().catch(() => {})
|
||||||
|
},
|
||||||
|
})
|
||||||
client.connect()
|
client.connect()
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -169,6 +260,32 @@ export const ImSDK = {
|
|||||||
return ImDatabase.getConversations(config.appId)
|
return ImDatabase.getConversations(config.appId)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to conversation list changes.
|
||||||
|
* Fires immediately with current data, then on every change (new message, read, mute, pin, etc.).
|
||||||
|
* Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
subscribeConversations(callback: (conversations: ConversationData[]) => void): () => void {
|
||||||
|
if (!ImDatabase.isInitialized()) {
|
||||||
|
// Return no-op if DB not ready
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
const config = getConfig()
|
||||||
|
return ImDatabase.subscribeConversations(config.appId, (models) => {
|
||||||
|
const data: ConversationData[] = models.map(c => ({
|
||||||
|
targetId: c.targetId,
|
||||||
|
chatType: c.chatType as 'SINGLE' | 'GROUP',
|
||||||
|
lastMsgContent: c.lastMsgContent ?? '',
|
||||||
|
lastMsgType: c.lastMsgType ?? '',
|
||||||
|
lastMsgTime: c.lastMsgTime,
|
||||||
|
unreadCount: c.unreadCount,
|
||||||
|
isMuted: c.isMuted,
|
||||||
|
isPinned: c.isPinned,
|
||||||
|
}))
|
||||||
|
callback(data)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
/** Mark a conversation as read (clears unread count). */
|
/** Mark a conversation as read (clears unread count). */
|
||||||
async markRead(targetId: string): Promise<void> {
|
async markRead(targetId: string): Promise<void> {
|
||||||
if (!ImDatabase.isInitialized()) return
|
if (!ImDatabase.isInitialized()) return
|
||||||
@ -176,6 +293,25 @@ export const ImSDK = {
|
|||||||
await ImDatabase.markRead(config.appId, targetId)
|
await ImDatabase.markRead(config.appId, targetId)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch last page of messages for each known conversation from server and save locally.
|
||||||
|
* Called automatically after connection is established via login/loginWithToken.
|
||||||
|
*/
|
||||||
|
async syncHistoryForAllConversations(): Promise<void> {
|
||||||
|
await _syncHistoryForAllConversations()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search messages in local DB.
|
||||||
|
* appId defaults to the configured appId if not provided.
|
||||||
|
*/
|
||||||
|
async searchMessages(params: MessageSearchParams & { appId?: string }) {
|
||||||
|
if (!ImDatabase.isInitialized()) return []
|
||||||
|
const config = getConfig()
|
||||||
|
const { appId, ...rest } = params
|
||||||
|
return ImDatabase.searchMessages(appId ?? config.appId, rest)
|
||||||
|
},
|
||||||
|
|
||||||
addListener(listener: ImEventListener): void {
|
addListener(listener: ImEventListener): void {
|
||||||
client?.addListener({
|
client?.addListener({
|
||||||
...listener,
|
...listener,
|
||||||
@ -198,9 +334,118 @@ export const ImSDK = {
|
|||||||
subscribeGroup(groupId: string): void { client?.subscribeGroup(groupId) },
|
subscribeGroup(groupId: string): void { client?.subscribeGroup(groupId) },
|
||||||
isConnected(): boolean { return client?.isConnected() ?? false },
|
isConnected(): boolean { return client?.isConnected() ?? false },
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a local image and send it as an IMAGE message.
|
||||||
|
* The file-service generates a thumbnail automatically for image/* content.
|
||||||
|
*/
|
||||||
|
async sendImageMessage(
|
||||||
|
toId: string,
|
||||||
|
chatType: ChatType,
|
||||||
|
localUri: string,
|
||||||
|
width?: number,
|
||||||
|
height?: number,
|
||||||
|
): Promise<ImMessage> {
|
||||||
|
const filename = localUri.split('/').pop() ?? 'image.jpg'
|
||||||
|
const mimeType = resolveMimeTypeFromUri(localUri, 'image/jpeg')
|
||||||
|
const result = await uploadFile(localUri, mimeType, filename)
|
||||||
|
const content = JSON.stringify({
|
||||||
|
url: result.url,
|
||||||
|
thumbnailUrl: result.thumbnailUrl,
|
||||||
|
width: width ?? 0,
|
||||||
|
height: height ?? 0,
|
||||||
|
size: result.size,
|
||||||
|
name: result.originalName,
|
||||||
|
})
|
||||||
|
return ImSDK.sendMessage(toId, chatType, 'IMAGE', content)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a local video (with optional pre-generated thumbnail) and send as a VIDEO message.
|
||||||
|
*/
|
||||||
|
async sendVideoMessage(
|
||||||
|
toId: string,
|
||||||
|
chatType: ChatType,
|
||||||
|
localUri: string,
|
||||||
|
thumbnailUri?: string,
|
||||||
|
duration?: number,
|
||||||
|
): Promise<ImMessage> {
|
||||||
|
const filename = localUri.split('/').pop() ?? 'video.mp4'
|
||||||
|
const mimeType = resolveMimeTypeFromUri(localUri, 'video/mp4')
|
||||||
|
const result = await uploadFile(localUri, mimeType, filename, thumbnailUri)
|
||||||
|
const content = JSON.stringify({
|
||||||
|
url: result.url,
|
||||||
|
thumbnailUrl: result.thumbnailUrl,
|
||||||
|
duration: duration ?? 0,
|
||||||
|
size: result.size,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
})
|
||||||
|
return ImSDK.sendMessage(toId, chatType, 'VIDEO', content)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a local audio file and send as an AUDIO message.
|
||||||
|
*/
|
||||||
|
async sendAudioMessage(
|
||||||
|
toId: string,
|
||||||
|
chatType: ChatType,
|
||||||
|
localUri: string,
|
||||||
|
duration: number,
|
||||||
|
): Promise<ImMessage> {
|
||||||
|
const filename = localUri.split('/').pop() ?? 'audio.m4a'
|
||||||
|
const mimeType = resolveMimeTypeFromUri(localUri, 'audio/mp4')
|
||||||
|
const result = await uploadFile(localUri, mimeType, filename)
|
||||||
|
const content = JSON.stringify({
|
||||||
|
url: result.url,
|
||||||
|
duration,
|
||||||
|
size: result.size,
|
||||||
|
})
|
||||||
|
return ImSDK.sendMessage(toId, chatType, 'AUDIO', content)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a local file and send as a FILE message.
|
||||||
|
*/
|
||||||
|
async sendFileMessage(
|
||||||
|
toId: string,
|
||||||
|
chatType: ChatType,
|
||||||
|
localUri: string,
|
||||||
|
filename: string,
|
||||||
|
size: number,
|
||||||
|
): Promise<ImMessage> {
|
||||||
|
const mimeType = resolveMimeTypeFromUri(localUri, 'application/octet-stream')
|
||||||
|
const result = await uploadFile(localUri, mimeType, filename)
|
||||||
|
const content = JSON.stringify({
|
||||||
|
url: result.url,
|
||||||
|
name: filename,
|
||||||
|
size,
|
||||||
|
mimeType: result.mimeType,
|
||||||
|
})
|
||||||
|
return ImSDK.sendMessage(toId, chatType, 'FILE', content)
|
||||||
|
},
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
client?.disconnect()
|
client?.disconnect()
|
||||||
client = null
|
client = null
|
||||||
_currentUserId = null
|
_currentUserId = null
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function resolveMimeTypeFromUri(uri: string, fallback: string): string {
|
||||||
|
const lower = uri.toLowerCase().split('?')[0]
|
||||||
|
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'
|
||||||
|
if (lower.endsWith('.png')) return 'image/png'
|
||||||
|
if (lower.endsWith('.gif')) return 'image/gif'
|
||||||
|
if (lower.endsWith('.webp')) return 'image/webp'
|
||||||
|
if (lower.endsWith('.mp4')) return 'video/mp4'
|
||||||
|
if (lower.endsWith('.mov')) return 'video/quicktime'
|
||||||
|
if (lower.endsWith('.m4a')) return 'audio/mp4'
|
||||||
|
if (lower.endsWith('.mp3')) return 'audio/mpeg'
|
||||||
|
if (lower.endsWith('.aac')) return 'audio/aac'
|
||||||
|
if (lower.endsWith('.pdf')) return 'application/pdf'
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|||||||
@ -12,5 +12,7 @@ export class ConversationModel extends Model {
|
|||||||
@field('last_msg_type') lastMsgType!: string | null
|
@field('last_msg_type') lastMsgType!: string | null
|
||||||
@field('last_msg_time') lastMsgTime!: number
|
@field('last_msg_time') lastMsgTime!: number
|
||||||
@field('unread_count') unreadCount!: number
|
@field('unread_count') unreadCount!: number
|
||||||
|
@field('is_muted') isMuted!: boolean
|
||||||
|
@field('is_pinned') isPinned!: boolean
|
||||||
@date('updated_at') updatedAt!: Date
|
@date('updated_at') updatedAt!: Date
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,15 @@ function conversationId(appId: string, userId: string, targetId: string, chatTyp
|
|||||||
return `${appId}:S:${a}:${b}`
|
return `${appId}:S:${a}:${b}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageSearchParams {
|
||||||
|
keyword?: string
|
||||||
|
startTime?: number
|
||||||
|
endTime?: number
|
||||||
|
msgTypes?: string[]
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
export const ImDatabase = {
|
export const ImDatabase = {
|
||||||
init(dbName = 'xuqm_im') {
|
init(dbName = 'xuqm_im') {
|
||||||
if (_db) return
|
if (_db) return
|
||||||
@ -68,7 +77,7 @@ export const ImDatabase = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// upsert conversation
|
// upsert conversation — preserve isMuted/isPinned on update
|
||||||
const convs = await db
|
const convs = await db
|
||||||
.get<ConversationModel>('im_conversations')
|
.get<ConversationModel>('im_conversations')
|
||||||
.query(Q.where('app_id', msg.appId), Q.where('target_id', msg.toId))
|
.query(Q.where('app_id', msg.appId), Q.where('target_id', msg.toId))
|
||||||
@ -85,6 +94,8 @@ export const ImDatabase = {
|
|||||||
c.lastMsgType = msg.msgType
|
c.lastMsgType = msg.msgType
|
||||||
c.lastMsgTime = msgTime
|
c.lastMsgTime = msgTime
|
||||||
c.unreadCount = msg.fromUserId !== currentUserId ? 1 : 0
|
c.unreadCount = msg.fromUserId !== currentUserId ? 1 : 0
|
||||||
|
c.isMuted = false
|
||||||
|
c.isPinned = false
|
||||||
c.updatedAt = new Date()
|
c.updatedAt = new Date()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@ -98,6 +109,7 @@ export const ImDatabase = {
|
|||||||
if (msg.fromUserId !== currentUserId) {
|
if (msg.fromUserId !== currentUserId) {
|
||||||
c.unreadCount = c.unreadCount + 1
|
c.unreadCount = c.unreadCount + 1
|
||||||
}
|
}
|
||||||
|
// isMuted and isPinned are intentionally preserved (not overwritten)
|
||||||
c.updatedAt = new Date()
|
c.updatedAt = new Date()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -147,6 +159,82 @@ export const ImDatabase = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async searchMessages(appId: string, params: MessageSearchParams): Promise<MessageModel[]> {
|
||||||
|
const db = getDb()
|
||||||
|
const conditions: Parameters<typeof Q.and>[0][] = [
|
||||||
|
Q.where('app_id', appId),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (params.keyword) {
|
||||||
|
conditions.push(Q.where('content', Q.like(`%${Q.sanitizeLikeString(params.keyword)}%`)))
|
||||||
|
}
|
||||||
|
if (params.startTime !== undefined) {
|
||||||
|
conditions.push(Q.where('server_created_at', Q.gte(params.startTime)))
|
||||||
|
}
|
||||||
|
if (params.endTime !== undefined) {
|
||||||
|
conditions.push(Q.where('server_created_at', Q.lte(params.endTime)))
|
||||||
|
}
|
||||||
|
if (params.msgTypes && params.msgTypes.length > 0) {
|
||||||
|
conditions.push(Q.where('msg_type', Q.oneOf(params.msgTypes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = db
|
||||||
|
.get<MessageModel>('im_messages')
|
||||||
|
.query(...conditions)
|
||||||
|
|
||||||
|
if (params.limit !== undefined) {
|
||||||
|
query = query.extend(Q.take(params.limit))
|
||||||
|
}
|
||||||
|
if (params.offset !== undefined) {
|
||||||
|
query = query.extend(Q.skip(params.offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.fetch()
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribeConversations(
|
||||||
|
appId: string,
|
||||||
|
callback: (conversations: ConversationModel[]) => void,
|
||||||
|
): () => void {
|
||||||
|
const db = getDb()
|
||||||
|
const query = db
|
||||||
|
.get<ConversationModel>('im_conversations')
|
||||||
|
.query(
|
||||||
|
Q.where('app_id', appId),
|
||||||
|
Q.sortBy('is_pinned', Q.desc),
|
||||||
|
Q.sortBy('last_msg_time', Q.desc),
|
||||||
|
)
|
||||||
|
|
||||||
|
const subscription = query.observe().subscribe(callback)
|
||||||
|
return () => subscription.unsubscribe()
|
||||||
|
},
|
||||||
|
|
||||||
|
async setConversationMuted(appId: string, targetId: string, muted: boolean): Promise<void> {
|
||||||
|
const db = getDb()
|
||||||
|
const convs = await db
|
||||||
|
.get<ConversationModel>('im_conversations')
|
||||||
|
.query(Q.where('app_id', appId), Q.where('target_id', targetId))
|
||||||
|
.fetch()
|
||||||
|
if (convs.length > 0) {
|
||||||
|
await db.write(async () => {
|
||||||
|
await convs[0].update(c => { c.isMuted = muted })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setConversationPinned(appId: string, targetId: string, pinned: boolean): Promise<void> {
|
||||||
|
const db = getDb()
|
||||||
|
const convs = await db
|
||||||
|
.get<ConversationModel>('im_conversations')
|
||||||
|
.query(Q.where('app_id', appId), Q.where('target_id', targetId))
|
||||||
|
.fetch()
|
||||||
|
if (convs.length > 0) {
|
||||||
|
await db.write(async () => {
|
||||||
|
await convs[0].update(c => { c.isPinned = pinned })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
isInitialized(): boolean {
|
isInitialized(): boolean {
|
||||||
return _db !== null
|
return _db !== null
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { appSchema, tableSchema } from '@nozbe/watermelondb'
|
import { appSchema, tableSchema } from '@nozbe/watermelondb'
|
||||||
|
|
||||||
export const imDbSchema = appSchema({
|
export const imDbSchema = appSchema({
|
||||||
version: 1,
|
version: 2,
|
||||||
tables: [
|
tables: [
|
||||||
tableSchema({
|
tableSchema({
|
||||||
name: 'im_conversations',
|
name: 'im_conversations',
|
||||||
@ -14,6 +14,8 @@ export const imDbSchema = appSchema({
|
|||||||
{ name: 'last_msg_type', type: 'string', isOptional: true },
|
{ name: 'last_msg_type', type: 'string', isOptional: true },
|
||||||
{ name: 'last_msg_time', type: 'number' },
|
{ name: 'last_msg_time', type: 'number' },
|
||||||
{ name: 'unread_count', type: 'number' },
|
{ name: 'unread_count', type: 'number' },
|
||||||
|
{ name: 'is_muted', type: 'boolean' },
|
||||||
|
{ name: 'is_pinned', type: 'boolean' },
|
||||||
{ name: 'updated_at', type: 'number' },
|
{ name: 'updated_at', type: 'number' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
export { ImSDK } from './ImSDK'
|
export { ImSDK } from './ImSDK'
|
||||||
|
export type { ConversationData } from './ImSDK'
|
||||||
export { ImClient } from './ImClient'
|
export { ImClient } from './ImClient'
|
||||||
export { ImDatabase } from './db/ImDatabase'
|
export { ImDatabase } from './db/ImDatabase'
|
||||||
|
export type { MessageSearchParams } from './db/ImDatabase'
|
||||||
export type {
|
export type {
|
||||||
ImMessage, ImGroup, ChatType, MsgType, MsgStatus,
|
ImMessage, ImGroup, ChatType, MsgType, MsgStatus,
|
||||||
ImEventListener, SendMessageParams,
|
ImEventListener, SendMessageParams,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
export { uploadFile } from './upload'
|
||||||
|
export type { UploadResult } from './upload'
|
||||||
|
|||||||
74
packages/im/src/upload.ts
普通文件
74
packages/im/src/upload.ts
普通文件
@ -0,0 +1,74 @@
|
|||||||
|
import { getConfig, _getToken } from '@xuqm/rn-common'
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
url: string
|
||||||
|
thumbnailUrl: string | null
|
||||||
|
hash: string
|
||||||
|
size: number
|
||||||
|
originalName: string
|
||||||
|
mimeType: string
|
||||||
|
ext?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to the file-service using multipart/form-data.
|
||||||
|
* Optionally attach a pre-generated thumbnail (for video files).
|
||||||
|
* Requires an active IM session (Bearer token) for authentication.
|
||||||
|
*/
|
||||||
|
export async function uploadFile(
|
||||||
|
localUri: string,
|
||||||
|
mimeType: string,
|
||||||
|
filename: string,
|
||||||
|
thumbnailUri?: string,
|
||||||
|
): Promise<UploadResult> {
|
||||||
|
const config = getConfig()
|
||||||
|
const token = await _getToken()
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('[uploadFile] No active session — call ImSDK.login() first.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
|
||||||
|
// React Native FormData accepts an object with uri/name/type for file parts
|
||||||
|
form.append('file', {
|
||||||
|
uri: localUri,
|
||||||
|
name: filename,
|
||||||
|
type: mimeType,
|
||||||
|
} as unknown as Blob)
|
||||||
|
|
||||||
|
if (thumbnailUri) {
|
||||||
|
form.append('thumbnail', {
|
||||||
|
uri: thumbnailUri,
|
||||||
|
name: 'thumbnail.jpg',
|
||||||
|
type: 'image/jpeg',
|
||||||
|
} as unknown as Blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${config.fileServiceUrl}/api/file/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
// Do NOT set Content-Type — let fetch set it with the correct boundary
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '')
|
||||||
|
throw new Error(`[uploadFile] Upload failed (${response.status}): ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
|
||||||
|
// Server wraps result in ApiResponse { code, data, message }
|
||||||
|
const data = json?.data ?? json
|
||||||
|
return {
|
||||||
|
url: data.url,
|
||||||
|
thumbnailUrl: data.thumbnailUrl ?? null,
|
||||||
|
hash: data.hash,
|
||||||
|
size: data.size,
|
||||||
|
originalName: data.originalName,
|
||||||
|
mimeType: data.mimeType,
|
||||||
|
ext: data.ext,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,36 +1,42 @@
|
|||||||
import { Platform } from 'react-native'
|
import { apiRequest, getConfig, getDeviceInfo } from '@xuqm/rn-common'
|
||||||
import { apiRequest, getConfig } from '@xuqm/rn-common'
|
import type { PushVendor } from '@xuqm/rn-common'
|
||||||
|
|
||||||
export type PushVendor = 'HUAWEI' | 'XIAOMI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APNS' | 'FCM'
|
export type { PushVendor }
|
||||||
|
|
||||||
export const PushSDK = {
|
export const PushSDK = {
|
||||||
/**
|
/**
|
||||||
* Register a push device token for the given user.
|
* Register a push device token for the given user.
|
||||||
* Call this after obtaining a vendor push token (e.g. via Firebase or APNS callbacks).
|
* If vendor is omitted, it is auto-detected from the device brand.
|
||||||
*
|
*
|
||||||
* @param userId - The logged-in user's ID
|
* @param userId - The logged-in user's ID
|
||||||
* @param vendor - Push vendor (e.g. HUAWEI, XIAOMI, APNS)
|
* @param token - The device push token from the vendor SDK
|
||||||
* @param token - The device push token from the vendor
|
* @param vendor - Optional; auto-detected when not provided
|
||||||
*/
|
*/
|
||||||
async registerToken(userId: string, vendor: PushVendor, token: string): Promise<void> {
|
async registerToken(userId: string, token: string, vendor?: PushVendor): Promise<void> {
|
||||||
const config = getConfig()
|
const config = getConfig()
|
||||||
|
const device = await getDeviceInfo()
|
||||||
await apiRequest('/api/push/register', {
|
await apiRequest('/api/push/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
params: {
|
params: {
|
||||||
appId: config.appId,
|
appId: config.appId,
|
||||||
userId,
|
userId,
|
||||||
vendor,
|
vendor: vendor ?? device.pushVendor,
|
||||||
token,
|
token,
|
||||||
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
platform: device.platform,
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
brand: device.brand,
|
||||||
|
model: device.model,
|
||||||
|
osVersion: device.osVersion,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async unregisterToken(userId: string): Promise<void> {
|
async unregisterToken(userId: string): Promise<void> {
|
||||||
const config = getConfig()
|
const config = getConfig()
|
||||||
|
const { deviceId } = await getDeviceInfo()
|
||||||
await apiRequest('/api/push/unregister', {
|
await apiRequest('/api/push/unregister', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
params: { appId: config.appId, userId },
|
params: { appId: config.appId, userId, deviceId },
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/index.ts
24
src/index.ts
@ -2,18 +2,24 @@
|
|||||||
// For tree-shaking and smaller bundles, import from individual packages:
|
// For tree-shaking and smaller bundles, import from individual packages:
|
||||||
// @xuqm/rn-common | @xuqm/rn-im | @xuqm/rn-push | @xuqm/rn-update
|
// @xuqm/rn-common | @xuqm/rn-im | @xuqm/rn-push | @xuqm/rn-update
|
||||||
|
|
||||||
export { XuqmSDK } from '@xuqm/rn-common'
|
export { XuqmSDK } from '../packages/common/src'
|
||||||
export type { XuqmInitOptions } from '@xuqm/rn-common'
|
export type { XuqmInitOptions, DeviceInfo } from '../packages/common/src'
|
||||||
|
export { getDeviceId, getDeviceInfo, detectPushVendor } from '../packages/common/src'
|
||||||
|
export { ScaledImage } from '../packages/common/src'
|
||||||
|
|
||||||
export { ImSDK } from '@xuqm/rn-im'
|
export { ImSDK } from '../packages/im/src'
|
||||||
export { ImClient } from '@xuqm/rn-im'
|
export { ImClient } from '../packages/im/src'
|
||||||
|
export { uploadFile } from '../packages/im/src'
|
||||||
export type {
|
export type {
|
||||||
ImMessage, ImGroup, ChatType, MsgType, MsgStatus,
|
ImMessage, ImGroup, ChatType, MsgType, MsgStatus,
|
||||||
ImEventListener, SendMessageParams,
|
ImEventListener, SendMessageParams,
|
||||||
} from '@xuqm/rn-im'
|
ConversationData,
|
||||||
|
MessageSearchParams,
|
||||||
|
UploadResult,
|
||||||
|
} from '../packages/im/src'
|
||||||
|
|
||||||
export { PushSDK } from '@xuqm/rn-push'
|
export { PushSDK } from '../packages/push/src'
|
||||||
export type { PushVendor } from '@xuqm/rn-push'
|
export type { PushVendor } from '../packages/push/src'
|
||||||
|
|
||||||
export { UpdateSDK } from '@xuqm/rn-update'
|
export { UpdateSDK } from '../packages/update/src'
|
||||||
export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from '@xuqm/rn-update'
|
export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from '../packages/update/src'
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户