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-async-storage/async-storage": ">=1.21.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xuqm/rn-common": "*",
|
||||
"@xuqm/rn-im": "*",
|
||||
"@xuqm/rn-push": "*",
|
||||
"@xuqm/rn-update": "*"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"@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 {
|
||||
appId: string
|
||||
serverUrl: string // e.g. "https://sentry.xuqinmin.com" — SDK fetches config from here
|
||||
appKey?: string
|
||||
debug?: boolean
|
||||
}
|
||||
@ -9,25 +8,32 @@ export interface XuqmInitOptions {
|
||||
export interface XuqmConfig {
|
||||
appId: string
|
||||
appKey: string
|
||||
apiBaseUrl: string
|
||||
imWsUrl: string
|
||||
serverUrl: string
|
||||
apiBaseUrl: string // im-service base URL
|
||||
imWsUrl: string // fetched from remote config
|
||||
fileServiceUrl: string // fetched from remote config
|
||||
debug: boolean
|
||||
}
|
||||
|
||||
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 = {
|
||||
appId: options.appId,
|
||||
appKey: options.appKey ?? options.appId,
|
||||
apiBaseUrl: API_BASE_URL,
|
||||
imWsUrl: IM_WS_URL,
|
||||
debug: options.debug ?? false,
|
||||
appId: options.appId,
|
||||
appKey: options.appKey ?? options.appId,
|
||||
serverUrl: options.serverUrl,
|
||||
apiBaseUrl: remote.apiBaseUrl,
|
||||
imWsUrl: remote.imWsUrl,
|
||||
fileServiceUrl: remote.fileServiceUrl,
|
||||
debug: options.debug ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
/**
|
||||
* @deprecated See API_BASE_URL.
|
||||
*/
|
||||
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 type { XuqmInitOptions } from './config'
|
||||
export type { XuqmInitOptions, XuqmConfig } from './config'
|
||||
export { getConfig, isInitialized } from './config'
|
||||
export { apiRequest, _getToken, _saveToken, _clearToken } from './http'
|
||||
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 = {
|
||||
/**
|
||||
* 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.appKey - Optional; defaults to appId
|
||||
* @param options.debug - Enable verbose logging
|
||||
* @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.debug - Enable verbose logging
|
||||
*/
|
||||
init(options: XuqmInitOptions): void {
|
||||
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 { ImDatabase } from './db/ImDatabase'
|
||||
import type { MessageSearchParams } from './db/ImDatabase'
|
||||
import type { ChatType, ImEventListener, ImGroup, ImMessage, MsgType } from './types'
|
||||
import { uploadFile } from './upload'
|
||||
|
||||
let client: ImClient | 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 = {
|
||||
/**
|
||||
* 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> {
|
||||
const config = getConfig()
|
||||
const device = await getDeviceInfo()
|
||||
const res = await apiRequest<{ token: string }>('/api/im/auth/login', {
|
||||
method: 'POST',
|
||||
skipAuth: true,
|
||||
params: {
|
||||
appId: config.appId,
|
||||
appId: config.appId,
|
||||
userId,
|
||||
deviceId: device.deviceId,
|
||||
platform: device.platform,
|
||||
brand: device.brand,
|
||||
model: device.model,
|
||||
osVersion: device.osVersion,
|
||||
...(nickname ? { nickname } : {}),
|
||||
...(avatar ? { avatar } : {}),
|
||||
},
|
||||
@ -31,6 +95,33 @@ export const ImSDK = {
|
||||
}
|
||||
|
||||
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()
|
||||
},
|
||||
|
||||
@ -169,6 +260,32 @@ export const ImSDK = {
|
||||
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). */
|
||||
async markRead(targetId: string): Promise<void> {
|
||||
if (!ImDatabase.isInitialized()) return
|
||||
@ -176,6 +293,25 @@ export const ImSDK = {
|
||||
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 {
|
||||
client?.addListener({
|
||||
...listener,
|
||||
@ -198,9 +334,118 @@ export const ImSDK = {
|
||||
subscribeGroup(groupId: string): void { client?.subscribeGroup(groupId) },
|
||||
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 {
|
||||
client?.disconnect()
|
||||
client = 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_time') lastMsgTime!: number
|
||||
@field('unread_count') unreadCount!: number
|
||||
@field('is_muted') isMuted!: boolean
|
||||
@field('is_pinned') isPinned!: boolean
|
||||
@date('updated_at') updatedAt!: Date
|
||||
}
|
||||
|
||||
@ -18,6 +18,15 @@ function conversationId(appId: string, userId: string, targetId: string, chatTyp
|
||||
return `${appId}:S:${a}:${b}`
|
||||
}
|
||||
|
||||
export interface MessageSearchParams {
|
||||
keyword?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
msgTypes?: string[]
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export const ImDatabase = {
|
||||
init(dbName = 'xuqm_im') {
|
||||
if (_db) return
|
||||
@ -68,7 +77,7 @@ export const ImDatabase = {
|
||||
})
|
||||
}
|
||||
|
||||
// upsert conversation
|
||||
// upsert conversation — preserve isMuted/isPinned on update
|
||||
const convs = await db
|
||||
.get<ConversationModel>('im_conversations')
|
||||
.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.lastMsgTime = msgTime
|
||||
c.unreadCount = msg.fromUserId !== currentUserId ? 1 : 0
|
||||
c.isMuted = false
|
||||
c.isPinned = false
|
||||
c.updatedAt = new Date()
|
||||
})
|
||||
} else {
|
||||
@ -98,6 +109,7 @@ export const ImDatabase = {
|
||||
if (msg.fromUserId !== currentUserId) {
|
||||
c.unreadCount = c.unreadCount + 1
|
||||
}
|
||||
// isMuted and isPinned are intentionally preserved (not overwritten)
|
||||
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 {
|
||||
return _db !== null
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { appSchema, tableSchema } from '@nozbe/watermelondb'
|
||||
|
||||
export const imDbSchema = appSchema({
|
||||
version: 1,
|
||||
version: 2,
|
||||
tables: [
|
||||
tableSchema({
|
||||
name: 'im_conversations',
|
||||
@ -14,6 +14,8 @@ export const imDbSchema = appSchema({
|
||||
{ name: 'last_msg_type', type: 'string', isOptional: true },
|
||||
{ name: 'last_msg_time', type: 'number' },
|
||||
{ name: 'unread_count', type: 'number' },
|
||||
{ name: 'is_muted', type: 'boolean' },
|
||||
{ name: 'is_pinned', type: 'boolean' },
|
||||
{ name: 'updated_at', type: 'number' },
|
||||
],
|
||||
}),
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
export { ImSDK } from './ImSDK'
|
||||
export type { ConversationData } from './ImSDK'
|
||||
export { ImClient } from './ImClient'
|
||||
export { ImDatabase } from './db/ImDatabase'
|
||||
export type { MessageSearchParams } from './db/ImDatabase'
|
||||
export type {
|
||||
ImMessage, ImGroup, ChatType, MsgType, MsgStatus,
|
||||
ImEventListener, SendMessageParams,
|
||||
} 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 } from '@xuqm/rn-common'
|
||||
import { apiRequest, getConfig, getDeviceInfo } 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 = {
|
||||
/**
|
||||
* 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 vendor - Push vendor (e.g. HUAWEI, XIAOMI, APNS)
|
||||
* @param token - The device push token from the vendor
|
||||
* @param token - The device push token from the vendor SDK
|
||||
* @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 device = await getDeviceInfo()
|
||||
await apiRequest('/api/push/register', {
|
||||
method: 'POST',
|
||||
params: {
|
||||
appId: config.appId,
|
||||
appId: config.appId,
|
||||
userId,
|
||||
vendor,
|
||||
vendor: vendor ?? device.pushVendor,
|
||||
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> {
|
||||
const config = getConfig()
|
||||
const { deviceId } = await getDeviceInfo()
|
||||
await apiRequest('/api/push/unregister', {
|
||||
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:
|
||||
// @xuqm/rn-common | @xuqm/rn-im | @xuqm/rn-push | @xuqm/rn-update
|
||||
|
||||
export { XuqmSDK } from '@xuqm/rn-common'
|
||||
export type { XuqmInitOptions } from '@xuqm/rn-common'
|
||||
export { XuqmSDK } from '../packages/common/src'
|
||||
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 { ImClient } from '@xuqm/rn-im'
|
||||
export { ImSDK } from '../packages/im/src'
|
||||
export { ImClient } from '../packages/im/src'
|
||||
export { uploadFile } from '../packages/im/src'
|
||||
export type {
|
||||
ImMessage, ImGroup, ChatType, MsgType, MsgStatus,
|
||||
ImEventListener, SendMessageParams,
|
||||
} from '@xuqm/rn-im'
|
||||
ConversationData,
|
||||
MessageSearchParams,
|
||||
UploadResult,
|
||||
} from '../packages/im/src'
|
||||
|
||||
export { PushSDK } from '@xuqm/rn-push'
|
||||
export type { PushVendor } from '@xuqm/rn-push'
|
||||
export { PushSDK } from '../packages/push/src'
|
||||
export type { PushVendor } from '../packages/push/src'
|
||||
|
||||
export { UpdateSDK } from '@xuqm/rn-update'
|
||||
export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from '@xuqm/rn-update'
|
||||
export { UpdateSDK } from '../packages/update/src'
|
||||
export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from '../packages/update/src'
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户