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'