diff --git a/package.json b/package.json
index b85b8f4..9ad31db 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/common/src/components/ScaledImage.tsx b/packages/common/src/components/ScaledImage.tsx
new file mode 100644
index 0000000..887a223
--- /dev/null
+++ b/packages/common/src/components/ScaledImage.tsx
@@ -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
+ }
+
+ return (
+
+ )
+}
+
+const styles = StyleSheet.create({
+ placeholder: {
+ backgroundColor: '#e0e0e0',
+ },
+})
diff --git a/packages/common/src/config.ts b/packages/common/src/config.ts
index 400fc62..5d626d0 100644
--- a/packages/common/src/config.ts
+++ b/packages/common/src/config.ts
@@ -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
}
diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts
index c5fca8e..9401452 100644
--- a/packages/common/src/constants.ts
+++ b/packages/common/src/constants.ts
@@ -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'
diff --git a/packages/common/src/device.ts b/packages/common/src/device.ts
new file mode 100644
index 0000000..db47c8b
--- /dev/null
+++ b/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 {
+ 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 = {
+ 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 {
+ 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),
+ }
+}
diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts
index 56de25a..90ae1e5 100644
--- a/packages/common/src/index.ts
+++ b/packages/common/src/index.ts
@@ -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'
diff --git a/packages/common/src/sdk.ts b/packages/common/src/sdk.ts
index 6ceb053..3c1a459 100644
--- a/packages/common/src/sdk.ts
+++ b/packages/common/src/sdk.ts
@@ -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 {
+ 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,
+ })
},
}
diff --git a/packages/im/src/ImSDK.ts b/packages/im/src/ImSDK.ts
index 15c50ff..caa7d1f 100644
--- a/packages/im/src/ImSDK.ts
+++ b/packages/im/src/ImSDK.ts
@@ -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 {
+ const config = getConfig()
+ if (!ImDatabase.isInitialized() || !_currentUserId) return
+
+ try {
+ // Fetch top 20 conversations from server
+ const res = await apiRequest(
+ '/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 {
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 {
+ 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 {
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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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
+}
diff --git a/packages/im/src/db/ConversationModel.ts b/packages/im/src/db/ConversationModel.ts
index e0fa120..18e48bc 100644
--- a/packages/im/src/db/ConversationModel.ts
+++ b/packages/im/src/db/ConversationModel.ts
@@ -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
}
diff --git a/packages/im/src/db/ImDatabase.ts b/packages/im/src/db/ImDatabase.ts
index 5714ba7..808f2ff 100644
--- a/packages/im/src/db/ImDatabase.ts
+++ b/packages/im/src/db/ImDatabase.ts
@@ -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('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 {
+ const db = getDb()
+ const conditions: Parameters[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('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('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 {
+ const db = getDb()
+ const convs = await db
+ .get('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 {
+ const db = getDb()
+ const convs = await db
+ .get('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
},
diff --git a/packages/im/src/db/schema.ts b/packages/im/src/db/schema.ts
index 40f76fe..9904ea1 100644
--- a/packages/im/src/db/schema.ts
+++ b/packages/im/src/db/schema.ts
@@ -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' },
],
}),
diff --git a/packages/im/src/index.ts b/packages/im/src/index.ts
index e27b23d..8db9a2d 100644
--- a/packages/im/src/index.ts
+++ b/packages/im/src/index.ts
@@ -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'
diff --git a/packages/im/src/upload.ts b/packages/im/src/upload.ts
new file mode 100644
index 0000000..aad1892
--- /dev/null
+++ b/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 {
+ 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,
+ }
+}
diff --git a/packages/push/src/PushSDK.ts b/packages/push/src/PushSDK.ts
index 8820eb7..abd01aa 100644
--- a/packages/push/src/PushSDK.ts
+++ b/packages/push/src/PushSDK.ts
@@ -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 {
+ async registerToken(userId: string, token: string, vendor?: PushVendor): Promise {
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 {
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 },
})
},
}
diff --git a/src/index.ts b/src/index.ts
index 257c5d5..7bfaeeb 100644
--- a/src/index.ts
+++ b/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'