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>
这个提交包含在:
XuqmGroup 2026-04-25 16:41:19 +08:00
父节点 4e821b280b
当前提交 d7e7cd8e2d
共有 15 个文件被更改,包括 725 次插入48 次删除

查看文件

@ -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,
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'

查看文件

@ -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.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,
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 普通文件
查看文件

@ -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,
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 },
})
},
}

查看文件

@ -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'