diff --git a/README.md b/README.md index 3222f8f..fa99e55 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ XuqmGroup-RNSDK/ │ ├── common/ # 初始化、网络、设备、Token、基础组件 │ ├── im/ # IM、会话、历史、群组、关系链 │ ├── push/ # 推送设备注册 -│ └── update/ # App 更新 / RN 热更新 +│ ├── update/ # App 更新 / RN 热更新 +│ └── xwebview/ # 内置 WebView 浏览器 └── README.md ``` @@ -25,7 +26,7 @@ yarn add @xuqm/rn-sdk yarn add @react-native-async-storage/async-storage # 如需按模块拆分接入,也可以直接安装 -yarn add @xuqm/rn-common @xuqm/rn-im @xuqm/rn-push @xuqm/rn-update +yarn add @xuqm/rn-common @xuqm/rn-im @xuqm/rn-push @xuqm/rn-update @xuqm/rn-xwebview ``` ## 入口 @@ -36,5 +37,7 @@ yarn add @xuqm/rn-common @xuqm/rn-im @xuqm/rn-push @xuqm/rn-update - `ImSDK` - `PushSDK` - `UpdateSDK` +- `XWebViewScreen` +- `XWebViewView` 详细用法见 [docs/rn-sdk/README.md](../docs/rn-sdk/README.md)。 diff --git a/package.json b/package.json index 693d8a7..0775d21 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "@xuqm/rn-common": ">=0.2.0", "@xuqm/rn-im": ">=0.2.0", "@xuqm/rn-push": ">=0.2.0", - "@xuqm/rn-update": ">=0.2.0" + "@xuqm/rn-update": ">=0.2.0", + "@xuqm/rn-xwebview": ">=0.2.0" }, "devDependencies": { "typescript": "^5.9.3", diff --git a/packages/common/package.json b/packages/common/package.json index 7033303..033f30e 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@xuqm/rn-common", - "version": "0.2.0", + "version": "0.2.2", "description": "XuqmGroup RN SDK — core: init, network, token management", "license": "UNLICENSED", "main": "src/index.ts", diff --git a/packages/common/src/config.ts b/packages/common/src/config.ts index b24ec34..3be5091 100644 --- a/packages/common/src/config.ts +++ b/packages/common/src/config.ts @@ -4,7 +4,6 @@ export interface XuqmInitOptions { } export interface XuqmConfig { - appId: string appKey: string apiUrl: string imWsUrl: string @@ -20,7 +19,6 @@ export function initConfigFromRemote( remote: { imWsUrl: string; fileServiceUrl: string; apiUrl: string }, ): void { _config = { - appId: options.appKey, appKey: options.appKey, apiUrl: remote.apiUrl, imWsUrl: remote.imWsUrl, diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 71f71e6..57d3a22 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -6,3 +6,20 @@ export { DEFAULT_TENANT_PLATFORM_URL, DEFAULT_IM_WS_URL } from './constants' export { getDeviceId, getDeviceInfo, detectPushVendor } from './device' export type { DeviceInfo, PushVendor } from './device' export { ScaledImage } from './components/ScaledImage' +export { + XWebViewControl, + getXWebViewConfig, + openXWebView, + setXWebViewController, +} from './xwebview/XWebViewBridge' +export type { + XWebViewClickMenu, + XWebViewConfig, + XWebViewControllerAPI, + XWebViewDownloadDecision, + XWebViewDownloadProgress, + XWebViewDownloadRequest, + XWebViewDownloadResult, + XWebViewMessageEvent, + XWebViewPermissionRequest, +} from './xwebview/types' diff --git a/packages/common/src/sdk.ts b/packages/common/src/sdk.ts index e0095c4..9c1fdc7 100644 --- a/packages/common/src/sdk.ts +++ b/packages/common/src/sdk.ts @@ -8,7 +8,7 @@ export const XuqmSDK = { */ async initialize(options: XuqmInitOptions): Promise { if (isInitialized()) return - const configUrl = `${DEFAULT_TENANT_PLATFORM_URL}/api/sdk/config?appId=${options.appKey}` + const configUrl = `${DEFAULT_TENANT_PLATFORM_URL}/api/sdk/config?appKey=${options.appKey}` try { const res = await fetch(configUrl) const json = await res.json() diff --git a/packages/common/src/xwebview/XWebViewBridge.ts b/packages/common/src/xwebview/XWebViewBridge.ts new file mode 100644 index 0000000..e3f7b24 --- /dev/null +++ b/packages/common/src/xwebview/XWebViewBridge.ts @@ -0,0 +1,42 @@ +import type { XWebViewConfig } from './types' + +export type XWebViewControllerAPI = { + refresh: () => void + close: () => void + goBack: () => void + goForward: () => void + copyUrl: () => void + postMessageToWeb: (jsString: string) => void + getTitle: () => string +} + +let _config: XWebViewConfig = {} +let _controller: XWebViewControllerAPI | null = null + +export function openXWebView( + navigate: (pluginId: string) => void, + config: XWebViewConfig, +) { + _config = { ...config } + navigate('xwebview') +} + +export function getXWebViewConfig(): XWebViewConfig { + return _config +} + +export function setXWebViewController( + controller: XWebViewControllerAPI | null, +) { + _controller = controller +} + +export const XWebViewControl: XWebViewControllerAPI = { + refresh: () => _controller?.refresh(), + close: () => _controller?.close(), + goBack: () => _controller?.goBack(), + goForward: () => _controller?.goForward(), + copyUrl: () => _controller?.copyUrl(), + postMessageToWeb: js => _controller?.postMessageToWeb(js), + getTitle: () => _controller?.getTitle() ?? '', +} diff --git a/packages/common/src/xwebview/types.ts b/packages/common/src/xwebview/types.ts new file mode 100644 index 0000000..1e71ce4 --- /dev/null +++ b/packages/common/src/xwebview/types.ts @@ -0,0 +1,82 @@ +import type React from 'react' + +export type XWebViewClickMenu = { + view?: React.ReactNode + onClick: () => void +} + +export type XWebViewMessageEvent = { + nativeEvent: { data: string } +} + +export type XWebViewPermissionRequest = { + origin: string + resources: string[] + grant: (resources?: string[]) => void + deny: () => void +} + +export type XWebViewDownloadRequest = { + url: string + suggestedFilename: string + mimeType?: string + fileSize?: number +} + +export type XWebViewDownloadDecision = { + allowed: boolean + filename?: string + savePath?: string +} + +export type XWebViewDownloadProgress = { + url: string + filename: string + received: number + total: number + percentage: number +} + +export type XWebViewDownloadResult = { + url: string + filename: string + filePath: string + fileSize: number +} + +export type XWebViewControllerAPI = { + refresh: () => void + close: () => void + goBack: () => void + goForward: () => void + copyUrl: () => void + postMessageToWeb: (jsString: string) => void + getTitle: () => string +} + +export type XWebViewConfig = { + showTopBar?: boolean + showStatusBar?: boolean + doubleBackExit?: boolean + title?: string + showTitle?: boolean + autoTitle?: boolean + showMenu?: boolean + openForBrowser?: boolean + clickMenu?: XWebViewClickMenu + url?: string + content?: string + onMessage?: (event: XWebViewMessageEvent) => void + injectedJavaScript?: string + onPermissionRequest?: (request: XWebViewPermissionRequest) => void + autoDownload?: boolean + onDownloadStart?: (request: XWebViewDownloadRequest) => void + onDownloadProgress?: (progress: XWebViewDownloadProgress) => void + onDownloadComplete?: (result: XWebViewDownloadResult) => void + onDownloadError?: (url: string, error: string) => void + onDownloadDecide?: ( + request: XWebViewDownloadRequest, + ) => XWebViewDownloadDecision | Promise + downloadConflict?: 'rename' | 'overwrite' + onClose?: () => void +} diff --git a/packages/im/package.json b/packages/im/package.json index ddcbb76..75e2498 100644 --- a/packages/im/package.json +++ b/packages/im/package.json @@ -1,6 +1,6 @@ { "name": "@xuqm/rn-im", - "version": "0.2.0", + "version": "0.2.1", "description": "XuqmGroup RN SDK — IM module (single chat, group chat, 13 message types)", "license": "UNLICENSED", "main": "src/index.ts", @@ -15,8 +15,7 @@ "test": "tsc -p tsconfig.test.json && node --test dist-test/tests/runtime.test.js" }, "dependencies": { - "@xuqm/rn-common": ">=0.2.0", - "@xuqm/rn-sdk": ">=0.2.0" + "@xuqm/rn-common": ">=0.2.2" }, "peerDependencies": { "@nozbe/watermelondb": ">=0.27.0", diff --git a/packages/im/src/ImClient.ts b/packages/im/src/ImClient.ts index 893eedf..5dbe517 100644 --- a/packages/im/src/ImClient.ts +++ b/packages/im/src/ImClient.ts @@ -17,19 +17,19 @@ export class ImClient { private groupSubscriptions = new Set() private activeWsUrl: string | null = null private activeToken: string | null = null - private activeAppId: string | null = null + private activeAppKey: string | null = null constructor( private readonly wsUrl?: string, private readonly token?: string, - private readonly appId?: string, + private readonly appKey?: string, ) {} async connect() { this.shouldReconnect = true this.activeWsUrl = this.wsUrl ?? getConfig().imWsUrl this.activeToken = this.token ?? null - this.activeAppId = this.appId ?? getConfig().appId + this.activeAppKey = this.appKey ?? getConfig().appKey if (!this.activeToken) { this.activeToken = await _getToken() } @@ -61,8 +61,8 @@ export class ImClient { } send(params: SendMessageParams): ImMessage { - if (!this.activeAppId) { - throw new Error('IM appId not configured') + if (!this.activeAppKey) { + throw new Error('IM appKey not configured') } const outgoing = this.buildOutgoingMessage(params) if (this.ws?.readyState !== WebSocket.OPEN) { @@ -76,7 +76,7 @@ export class ImClient { 'content-type': 'application/json', }, JSON.stringify({ - appId: this.activeAppId, + appKey: this.activeAppKey, messageId: outgoing.id, toId: params.toId, chatType: params.chatType, @@ -93,8 +93,8 @@ export class ImClient { if (this.ws?.readyState !== WebSocket.OPEN) { throw new Error('IM not connected') } - if (!this.activeAppId) { - throw new Error('IM appId not configured') + if (!this.activeAppKey) { + throw new Error('IM appKey not configured') } this.sendFrame( @@ -104,7 +104,7 @@ export class ImClient { 'content-type': 'application/json', }, JSON.stringify({ - appId: this.activeAppId, + appKey: this.activeAppKey, messageId, }), ) @@ -112,14 +112,14 @@ export class ImClient { sync() { if (this.ws?.readyState !== WebSocket.OPEN) return - if (!this.activeAppId) return + if (!this.activeAppKey) return this.sendFrame( 'SEND', { destination: '/app/chat.sync', 'content-type': 'application/json', }, - JSON.stringify({ appId: this.activeAppId }), + JSON.stringify({ appKey: this.activeAppKey }), ) } @@ -292,7 +292,7 @@ export class ImClient { const messageId = params.messageId ?? this.generateMessageId() return { id: messageId, - appId: this.activeAppId ?? getConfig().appId, + appKey: this.activeAppKey ?? getConfig().appKey, fromUserId: userId, fromId: userId, toId: params.toId, @@ -318,7 +318,7 @@ export class ImClient { ...message, fromId: message.fromId ?? message.fromUserId, revoked: message.revoked ?? message.status === 'REVOKED', - appId: message.appId ?? (this.activeAppId ?? getConfig().appId), + appKey: message.appKey ?? (this.activeAppKey ?? getConfig().appKey), } } } diff --git a/packages/im/src/ImSDK.ts b/packages/im/src/ImSDK.ts index 219302b..deb7427 100644 --- a/packages/im/src/ImSDK.ts +++ b/packages/im/src/ImSDK.ts @@ -23,6 +23,7 @@ import { buildDbName, sortConversations as sortConversationsByRuntime } from './ let client: ImClient | null = null let _currentUserId: string | null = null +let _currentUserSig: string | null = null const draftStore = new Map() const listenerMap = new WeakMap() let conversationMemory: ConversationData[] = [] @@ -40,7 +41,7 @@ function normalizeMessage(msg: ImMessage, fallback?: Partial): ImMess return { ...fallback, ...msg, - appId: msg.appId ?? fallback?.appId ?? getConfig().appId, + appKey: msg.appKey ?? fallback?.appKey ?? getConfig().appKey, fromId: msg.fromId ?? fallback?.fromId ?? msg.fromUserId, revoked: msg.revoked ?? msg.status === 'REVOKED', } @@ -59,7 +60,7 @@ function buildOutgoingMessage(params: { const id = params.messageId ?? generateMessageId() return { id, - appId: config.appId, + appKey: config.appKey, fromUserId: fromId, fromId, toId: params.toId, @@ -301,14 +302,18 @@ export const ImSDK = { */ async login(userId: string, userSig: string): Promise { const config = getConfig() + if (client && _currentUserId === userId && _currentUserSig === userSig) { + return + } client?.disconnect() await _saveToken(userSig) _currentUserId = userId + _currentUserSig = userSig setCommonUserId(userId) ImDatabase.init(buildDbName(config.appKey, userId)) - client = new ImClient(config.imWsUrl, userSig, config.appId) + client = new ImClient(config.imWsUrl, userSig, config.appKey) client.addListener({ onConnected: () => { _syncHistoryForAllConversations().catch(() => {}) @@ -332,7 +337,7 @@ export const ImSDK = { const config = getConfig() const token = await _getToken() if (!token) throw new Error('[ImSDK] No active session — call login() first.') - client = new ImClient(config.imWsUrl, token, config.appId) + client = new ImClient(config.imWsUrl, token, config.appKey) void client.connect() }, @@ -343,11 +348,11 @@ export const ImSDK = { ): Promise { const config = getConfig() if (ImDatabase.isInitialized() && page === 0 && _currentUserId) { - const local = await ImDatabase.getMessages(config.appId, toId, 'SINGLE', _currentUserId, size) + const local = await ImDatabase.getMessages(config.appKey, toId, 'SINGLE', _currentUserId, size) if (local.length > 0) { return local.map(model => ({ id: model.serverId, - appId: model.appId, + appKey: model.appKey, fromUserId: model.fromUserId, toId: model.toId, chatType: model.chatType as ChatType, @@ -380,7 +385,7 @@ export const ImSDK = { `/api/im/messages/history/${encodeURIComponent(toId)}`, { params: { - appId: config.appId, + appKey: config.appKey, page: String(params.page ?? 0), size: String(params.size ?? 20), ...(params.msgType ? { msgType: String(params.msgType) } : {}), @@ -404,11 +409,11 @@ export const ImSDK = { ): Promise { const config = getConfig() if (ImDatabase.isInitialized() && page === 0 && _currentUserId) { - const local = await ImDatabase.getMessages(config.appId, groupId, 'GROUP', _currentUserId, size) + const local = await ImDatabase.getMessages(config.appKey, groupId, 'GROUP', _currentUserId, size) if (local.length > 0) { return local.map(model => ({ id: model.serverId, - appId: model.appId, + appKey: model.appKey, fromUserId: model.fromUserId, toId: model.toId, chatType: model.chatType as ChatType, @@ -441,7 +446,7 @@ export const ImSDK = { `/api/im/messages/group-history/${encodeURIComponent(groupId)}`, { params: { - appId: config.appId, + appKey: config.appKey, page: String(params.page ?? 0), size: String(params.size ?? 50), ...(params.msgType ? { msgType: String(params.msgType) } : {}), @@ -508,7 +513,7 @@ export const ImSDK = { try { const msg = await apiRequest('/api/im/messages/send', { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body: { toId, chatType, @@ -685,11 +690,11 @@ export const ImSDK = { const config = getConfig() const msg = await apiRequest(`/api/im/messages/${encodeURIComponent(messageId)}/revoke`, { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) if (ImDatabase.isInitialized() && _currentUserId) { await ImDatabase.saveMessage(normalizeMessage(msg), _currentUserId) - await ImDatabase.revokeMessage(config.appId, messageId) + await ImDatabase.revokeMessage(config.appKey, messageId) } else { applyMessageToMemory(msg) applyRevokeToMemory(messageId) @@ -701,7 +706,7 @@ export const ImSDK = { const config = getConfig() const msg = await apiRequest(`/api/im/messages/${encodeURIComponent(messageId)}`, { method: 'PUT', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body: { content }, }) if (ImDatabase.isInitialized() && _currentUserId) { @@ -716,7 +721,7 @@ export const ImSDK = { const config = getConfig() const res = await apiRequest('/api/im/messages/offline', { method: 'POST', - params: { appId: config.appId, maxCount: String(maxCount) }, + params: { appKey: config.appKey, maxCount: String(maxCount) }, }) const messages = Array.isArray(res) ? res : (res.data ?? []) if (ImDatabase.isInitialized() && _currentUserId) { @@ -730,7 +735,7 @@ export const ImSDK = { async offlineMessageCount(): Promise { const config = getConfig() const res = await apiRequest<{ count: number }>('/api/im/messages/offline/count', { - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) return res.count ?? 0 }, @@ -739,7 +744,7 @@ export const ImSDK = { const config = getConfig() return apiRequest('/api/im/groups', { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body: { name, memberIds, groupType }, }) }, @@ -747,7 +752,7 @@ export const ImSDK = { async listGroups(): Promise { const config = getConfig() const res = await apiRequest('/api/im/groups', { - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) return Array.isArray(res) ? res : (res.content ?? []) }, @@ -756,7 +761,7 @@ export const ImSDK = { const config = getConfig() const res = await apiRequest('/api/im/groups/public', { params: { - appId: config.appId, + appKey: config.appKey, ...(keyword ? { keyword } : {}), }, }) @@ -770,7 +775,7 @@ export const ImSDK = { async listGroupMembers(groupId: string): Promise { const config = getConfig() const res = await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/members`, { - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) return Array.isArray(res) ? res : (res.content ?? []) }, @@ -779,7 +784,7 @@ export const ImSDK = { const config = getConfig() const res = await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/members/search`, { params: { - appId: config.appId, + appKey: config.appKey, keyword, size: String(size), }, @@ -857,7 +862,7 @@ export const ImSDK = { return apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/join-requests`, { method: 'POST', params: { - appId: config.appId, + appKey: config.appKey, ...(remark ? { remark } : {}), }, }) @@ -868,7 +873,7 @@ export const ImSDK = { const res = await apiRequest( `/api/im/groups/${encodeURIComponent(groupId)}/join-requests`, { - params: { appId: config.appId }, + params: { appKey: config.appKey }, }, ) return Array.isArray(res) ? res : (res.content ?? []) @@ -880,7 +885,7 @@ export const ImSDK = { `/api/im/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/accept`, { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, }, ) }, @@ -891,7 +896,7 @@ export const ImSDK = { `/api/im/groups/${encodeURIComponent(groupId)}/join-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, }, ) }, @@ -899,7 +904,7 @@ export const ImSDK = { async listFriends(): Promise { const config = getConfig() const res = await apiRequest<{ data?: string[] } | string[]>('/api/im/friends', { - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) return Array.isArray(res) ? res : (res.data ?? []) }, @@ -908,7 +913,7 @@ export const ImSDK = { const config = getConfig() await apiRequest('/api/im/friends', { method: 'POST', - params: { appId: config.appId, friendId }, + params: { appKey: config.appKey, friendId }, }) }, @@ -916,7 +921,7 @@ export const ImSDK = { const config = getConfig() await apiRequest(`/api/im/friends/${encodeURIComponent(friendId)}`, { method: 'DELETE', - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) }, @@ -924,7 +929,7 @@ export const ImSDK = { const config = getConfig() await apiRequest('/api/im/friends', { method: 'DELETE', - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) }, @@ -933,7 +938,7 @@ export const ImSDK = { await apiRequest(`/api/im/friends/${encodeURIComponent(friendId)}/group`, { method: 'PUT', params: { - appId: config.appId, + appKey: config.appKey, ...(groupName ? { groupName } : {}), }, }) @@ -942,7 +947,7 @@ export const ImSDK = { async listFriendGroups(): Promise { const config = getConfig() const res = await apiRequest('/api/im/friends/groups', { - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) return Array.isArray(res) ? res : (res.content ?? []) }, @@ -951,7 +956,7 @@ export const ImSDK = { const config = getConfig() const res = await apiRequest( `/api/im/friends/groups/${encodeURIComponent(groupName)}`, - { params: { appId: config.appId } }, + { params: { appKey: config.appKey } }, ) return Array.isArray(res) ? res : (res.content ?? []) }, @@ -960,7 +965,7 @@ export const ImSDK = { const config = getConfig() const res = await apiRequest('/api/im/friend-requests', { params: { - appId: config.appId, + appKey: config.appKey, direction, }, }) @@ -972,7 +977,7 @@ export const ImSDK = { return apiRequest('/api/im/friend-requests', { method: 'POST', params: { - appId: config.appId, + appKey: config.appKey, toUserId, ...(remark ? { remark } : {}), }, @@ -983,7 +988,7 @@ export const ImSDK = { const config = getConfig() return apiRequest(`/api/im/friend-requests/${encodeURIComponent(requestId)}/accept`, { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) }, @@ -991,14 +996,14 @@ export const ImSDK = { const config = getConfig() return apiRequest(`/api/im/friend-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) }, async listBlacklist(): Promise { const config = getConfig() const res = await apiRequest('/api/im/blacklist', { - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) return Array.isArray(res) ? res : (res.content ?? []) }, @@ -1007,7 +1012,7 @@ export const ImSDK = { const config = getConfig() return apiRequest('/api/im/blacklist', { method: 'POST', - params: { appId: config.appId, blockedUserId }, + params: { appKey: config.appKey, blockedUserId }, }) }, @@ -1015,21 +1020,21 @@ export const ImSDK = { const config = getConfig() await apiRequest('/api/im/blacklist', { method: 'DELETE', - params: { appId: config.appId, blockedUserId }, + params: { appKey: config.appKey, blockedUserId }, }) }, async checkBlacklist(targetUserId: string): Promise { const config = getConfig() return apiRequest('/api/im/blacklist/check', { - params: { appId: config.appId, targetUserId }, + params: { appKey: config.appKey, targetUserId }, }) }, async getProfile(userId: string): Promise { const config = getConfig() return apiRequest(`/api/im/accounts/${encodeURIComponent(userId)}`, { - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) }, @@ -1043,7 +1048,7 @@ export const ImSDK = { return apiRequest(`/api/im/accounts/${encodeURIComponent(userId)}`, { method: 'PUT', params: { - appId: config.appId, + appKey: config.appKey, ...(nickname ? { nickname } : {}), ...(avatar ? { avatar } : {}), ...(gender ? { gender } : {}), @@ -1055,7 +1060,7 @@ export const ImSDK = { const config = getConfig() const res = await apiRequest('/api/im/accounts/search', { params: { - appId: config.appId, + appKey: config.appKey, keyword, size: String(size), }, @@ -1067,7 +1072,7 @@ export const ImSDK = { const config = getConfig() const res = await apiRequest('/api/im/groups/search', { params: { - appId: config.appId, + appKey: config.appKey, keyword, size: String(size), }, @@ -1078,7 +1083,7 @@ export const ImSDK = { async listConversations(): Promise { const config = getConfig() if (ImDatabase.isInitialized()) { - const models = await ImDatabase.getConversations(config.appId) + const models = await ImDatabase.getConversations(config.appKey) if (models.length > 0) { const conversations = models.map(model => normalizeConversation({ targetId: model.targetId, @@ -1098,7 +1103,7 @@ export const ImSDK = { } const res = await apiRequest('/api/im/conversations', { - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) const conversations = Array.isArray(res) ? res : (res.content ?? []) const normalized = conversations.map(normalizeConversation) @@ -1122,7 +1127,7 @@ export const ImSDK = { } } const config = getConfig() - return ImDatabase.subscribeConversations(config.appId, (models) => { + return ImDatabase.subscribeConversations(config.appKey, (models) => { const data: ConversationData[] = models.map(c => normalizeConversation({ targetId: c.targetId, chatType: c.chatType, @@ -1142,10 +1147,10 @@ export const ImSDK = { const config = getConfig() await apiRequest(`/api/im/conversations/${encodeURIComponent(targetId)}/read`, { method: 'PUT', - params: { appId: config.appId, chatType }, + params: { appKey: config.appKey, chatType }, }) if (ImDatabase.isInitialized()) { - await ImDatabase.markRead(config.appId, targetId) + await ImDatabase.markRead(config.appKey, targetId) } else { markConversationReadMemory(targetId, chatType) } @@ -1156,13 +1161,13 @@ export const ImSDK = { await apiRequest(`/api/im/conversations/${encodeURIComponent(targetId)}/muted`, { method: 'PUT', params: { - appId: config.appId, + appKey: config.appKey, chatType, muted: String(muted), }, }) if (ImDatabase.isInitialized()) { - await ImDatabase.setConversationMuted(config.appId, targetId, muted) + await ImDatabase.setConversationMuted(config.appKey, targetId, muted) } else { setConversationMutedMemory(targetId, chatType, muted) } @@ -1173,13 +1178,13 @@ export const ImSDK = { await apiRequest(`/api/im/conversations/${encodeURIComponent(targetId)}/pinned`, { method: 'PUT', params: { - appId: config.appId, + appKey: config.appKey, chatType, pinned: String(pinned), }, }) if (ImDatabase.isInitialized()) { - await ImDatabase.setConversationPinned(config.appId, targetId, pinned) + await ImDatabase.setConversationPinned(config.appKey, targetId, pinned) } else { setConversationPinnedMemory(targetId, chatType, pinned) } @@ -1187,10 +1192,10 @@ export const ImSDK = { async setDraft(targetId: string, chatType: ChatType, draft: string): Promise { const config = getConfig() - const draftKey = `${config.appId}:${chatType}:${targetId}` + const draftKey = `${config.appKey}:${chatType}:${targetId}` draftStore.set(draftKey, draft) if (ImDatabase.isInitialized()) { - await ImDatabase.setDraft(config.appId, targetId, chatType, draft) + await ImDatabase.setDraft(config.appKey, targetId, chatType, draft) } }, @@ -1199,7 +1204,7 @@ export const ImSDK = { await apiRequest(`/api/im/conversations/${encodeURIComponent(targetId)}/hidden`, { method: 'PUT', params: { - appId: config.appId, + appKey: config.appKey, chatType, hidden: String(hidden), }, @@ -1214,7 +1219,7 @@ export const ImSDK = { await apiRequest(`/api/im/conversations/${encodeURIComponent(targetId)}/group`, { method: 'PUT', params: { - appId: config.appId, + appKey: config.appKey, chatType, ...(groupName ? { groupName } : {}), }, @@ -1225,7 +1230,7 @@ export const ImSDK = { async listConversationGroups(): Promise { const config = getConfig() const res = await apiRequest('/api/im/conversation-groups', { - params: { appId: config.appId }, + params: { appKey: config.appKey }, }) return Array.isArray(res) ? res : (res.content ?? []) }, @@ -1234,7 +1239,7 @@ export const ImSDK = { const config = getConfig() const res = await apiRequest( `/api/im/conversation-groups/${encodeURIComponent(groupName)}`, - { params: { appId: config.appId } }, + { params: { appKey: config.appKey } }, ) return Array.isArray(res) ? res : (res.content ?? []) }, @@ -1245,7 +1250,7 @@ export const ImSDK = { `/api/im/admin/groups/${encodeURIComponent(groupId)}/read-receipts`, { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body: { messageIds }, }, ) @@ -1256,7 +1261,7 @@ export const ImSDK = { const config = getConfig() await apiRequest('/api/im/friends/batch', { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body: { friendIds }, }) }, @@ -1265,7 +1270,7 @@ export const ImSDK = { const config = getConfig() await apiRequest('/api/im/friends/batch/remove', { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body: { friendIds }, }) }, @@ -1274,7 +1279,7 @@ export const ImSDK = { const config = getConfig() await apiRequest('/api/im/friend-requests/batch/accept', { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body: { requestIds }, }) }, @@ -1283,7 +1288,7 @@ export const ImSDK = { const config = getConfig() await apiRequest('/api/im/friend-requests/batch/reject', { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body: { requestIds }, }) }, @@ -1292,7 +1297,7 @@ export const ImSDK = { const config = getConfig() await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/members/batch`, { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body: { userIds }, }) }, @@ -1301,7 +1306,7 @@ export const ImSDK = { const config = getConfig() await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/members/batch/remove`, { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body: { userIds }, }) }, @@ -1310,7 +1315,7 @@ export const ImSDK = { const config = getConfig() await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/join-requests/batch/accept`, { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body: { requestIds }, }) }, @@ -1319,7 +1324,7 @@ export const ImSDK = { const config = getConfig() await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/join-requests/batch/reject`, { method: 'POST', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body: { requestIds }, }) }, @@ -1331,7 +1336,7 @@ export const ImSDK = { if (role !== undefined) body.role = role await apiRequest(`/api/im/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(userId)}/info`, { method: 'PUT', - params: { appId: config.appId }, + params: { appKey: config.appKey }, body, }) }, @@ -1339,12 +1344,12 @@ export const ImSDK = { async getDraft(targetId: string, chatType: ChatType): Promise { const config = getConfig() if (ImDatabase.isInitialized()) { - const draft = await ImDatabase.getConversationDraft(config.appId, targetId, chatType) + const draft = await ImDatabase.getConversationDraft(config.appKey, targetId, chatType) if (draft !== null) { return draft } } - const draftKey = `${config.appId}:${chatType}:${targetId}` + const draftKey = `${config.appKey}:${chatType}:${targetId}` return draftStore.get(draftKey) ?? '' }, @@ -1353,12 +1358,12 @@ export const ImSDK = { await apiRequest(`/api/im/conversations/${encodeURIComponent(targetId)}`, { method: 'DELETE', params: { - appId: config.appId, + appKey: config.appKey, chatType, }, }) if (ImDatabase.isInitialized() && _currentUserId) { - await ImDatabase.deleteConversation(config.appId, targetId, chatType, _currentUserId) + await ImDatabase.deleteConversation(config.appKey, targetId, chatType, _currentUserId) } else { deleteConversationMemory(targetId, chatType) } @@ -1373,11 +1378,11 @@ export const ImSDK = { await _syncHistoryForAllConversations() }, - async searchMessages(params: MessageSearchParams & { appId?: string }) { + async searchMessages(params: MessageSearchParams & { appKey?: string }) { if (!ImDatabase.isInitialized()) return [] const config = getConfig() - const { appId, ...rest } = params - return ImDatabase.searchMessages(appId ?? config.appId, rest) + const { appKey, ...rest } = params + return ImDatabase.searchMessages(appKey ?? config.appKey, rest) }, addListener(listener: ImEventListener): void { @@ -1417,7 +1422,7 @@ export const ImSDK = { }, onRevoke: async (data) => { if (ImDatabase.isInitialized()) { - await ImDatabase.revokeMessage(getConfig().appId, data.msgId) + await ImDatabase.revokeMessage(getConfig().appKey, data.msgId) } else { applyRevokeToMemory(data.msgId) } @@ -1450,6 +1455,7 @@ export const ImSDK = { client?.disconnect() client = null _currentUserId = null + _currentUserSig = null setCommonUserId(null) }, } diff --git a/packages/im/src/db/ConversationModel.ts b/packages/im/src/db/ConversationModel.ts index e4fb260..4faa8f4 100644 --- a/packages/im/src/db/ConversationModel.ts +++ b/packages/im/src/db/ConversationModel.ts @@ -4,7 +4,7 @@ import { field, date } from '@nozbe/watermelondb/decorators' export class ConversationModel extends Model { static table = 'im_conversations' - @field('app_id') appId!: string + @field('app_id') appKey!: string @field('target_id') targetId!: string @field('chat_type') chatType!: string @field('last_msg_id') lastMsgId!: string | null diff --git a/packages/im/src/db/ImDatabase.ts b/packages/im/src/db/ImDatabase.ts index f646eea..250aa17 100644 --- a/packages/im/src/db/ImDatabase.ts +++ b/packages/im/src/db/ImDatabase.ts @@ -14,14 +14,14 @@ function getDb(): Database { return _db } -function conversationId(appId: string, userId: string, targetId: string, chatType: string): string { - if (chatType === 'GROUP') return `${appId}:G:${targetId}` +function conversationId(appKey: string, userId: string, targetId: string, chatType: string): string { + if (chatType === 'GROUP') return `${appKey}:G:${targetId}` const [a, b] = [userId, targetId].sort() - return `${appId}:S:${a}:${b}` + return `${appKey}:S:${a}:${b}` } -function draftKey(appId: string, targetId: string, chatType: string): string { - return `${appId}:${chatType}:${targetId}` +function draftKey(appKey: string, targetId: string, chatType: string): string { + return `${appKey}:${chatType}:${targetId}` } export interface MessageSearchParams { @@ -53,7 +53,7 @@ export const ImDatabase = { async saveMessage(msg: ImMessage, currentUserId: string): Promise { const db = getDb() - const convId = conversationId(msg.appId, currentUserId, msg.toId, msg.chatType) + const convId = conversationId(msg.appKey, currentUserId, msg.toId, msg.chatType) const now = Date.now() await db.write(async () => { @@ -66,7 +66,7 @@ export const ImDatabase = { if (existing.length === 0) { await db.get('im_messages').create((m: MessageModel) => { m.serverId = msg.id - m.appId = msg.appId + m.appKey = msg.appKey m.conversationId = convId m.fromUserId = msg.fromUserId m.toId = msg.toId @@ -91,13 +91,13 @@ export const ImDatabase = { // 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)) + .query(Q.where('app_id', msg.appKey), Q.where('target_id', msg.toId)) .fetch() const msgTime = new Date(msg.createdAt).getTime() if (convs.length === 0) { await db.get('im_conversations').create((c: ConversationModel) => { - c.appId = msg.appId + c.appKey = msg.appKey c.targetId = msg.toId c.chatType = msg.chatType c.lastMsgId = msg.id @@ -128,11 +128,11 @@ export const ImDatabase = { }) }, - async revokeMessage(appId: string, messageId: string): Promise { + async revokeMessage(appKey: string, messageId: string): Promise { const db = getDb() const messages = await db .get('im_messages') - .query(Q.where('app_id', appId), Q.where('server_id', messageId)) + .query(Q.where('app_id', appKey), Q.where('server_id', messageId)) .fetch() if (messages.length === 0) return @@ -149,7 +149,7 @@ export const ImDatabase = { const conversations = await db .get('im_conversations') - .query(Q.where('app_id', appId), Q.where('target_id', message.toId)) + .query(Q.where('app_id', appKey), Q.where('target_id', message.toId)) .fetch() if (conversations.length > 0) { @@ -164,9 +164,9 @@ export const ImDatabase = { }) }, - async getMessages(appId: string, targetId: string, chatType: string, currentUserId: string, limit = 50): Promise { + async getMessages(appKey: string, targetId: string, chatType: string, currentUserId: string, limit = 50): Promise { const db = getDb() - const convId = conversationId(appId, currentUserId, targetId, chatType) + const convId = conversationId(appKey, currentUserId, targetId, chatType) return db .get('im_messages') .query( @@ -177,22 +177,22 @@ export const ImDatabase = { .fetch() }, - async getConversations(appId: string): Promise { + async getConversations(appKey: string): Promise { const db = getDb() return db .get('im_conversations') .query( - Q.where('app_id', appId), + Q.where('app_id', appKey), Q.sortBy('last_msg_time', Q.desc), ) .fetch() }, - async markRead(appId: string, targetId: string): Promise { + async markRead(appKey: string, targetId: string): Promise { const db = getDb() const convs = await db .get('im_conversations') - .query(Q.where('app_id', appId), Q.where('target_id', targetId)) + .query(Q.where('app_id', appKey), Q.where('target_id', targetId)) .fetch() if (convs.length > 0) { await db.write(async () => { @@ -201,12 +201,12 @@ export const ImDatabase = { } }, - async setDraft(appId: string, targetId: string, chatType: string, draft: string): Promise { - draftStore.set(draftKey(appId, targetId, chatType), draft) + async setDraft(appKey: string, targetId: string, chatType: string, draft: string): Promise { + draftStore.set(draftKey(appKey, targetId, chatType), draft) const db = getDb() const convs = await db .get('im_conversations') - .query(Q.where('app_id', appId), Q.where('target_id', targetId)) + .query(Q.where('app_id', appKey), Q.where('target_id', targetId)) .fetch() if (convs.length > 0) { await db.write(async () => { @@ -224,10 +224,10 @@ export const ImDatabase = { } }, - async searchMessages(appId: string, params: MessageSearchParams): Promise { + async searchMessages(appKey: string, params: MessageSearchParams): Promise { const db = getDb() const conditions: any[] = [ - Q.where('app_id', appId), + Q.where('app_id', appKey), ] if (params.toId) { @@ -264,14 +264,14 @@ export const ImDatabase = { }, subscribeConversations( - appId: string, + appKey: string, callback: (conversations: ConversationModel[]) => void, ): () => void { const db = getDb() const query = db .get('im_conversations') .query( - Q.where('app_id', appId), + Q.where('app_id', appKey), Q.sortBy('is_pinned', Q.desc), Q.sortBy('last_msg_time', Q.desc), ) @@ -280,11 +280,11 @@ export const ImDatabase = { return () => subscription.unsubscribe() }, - async setConversationMuted(appId: string, targetId: string, muted: boolean): Promise { + async setConversationMuted(appKey: 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)) + .query(Q.where('app_id', appKey), Q.where('target_id', targetId)) .fetch() if (convs.length > 0) { await db.write(async () => { @@ -293,11 +293,11 @@ export const ImDatabase = { } }, - async setConversationPinned(appId: string, targetId: string, pinned: boolean): Promise { + async setConversationPinned(appKey: 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)) + .query(Q.where('app_id', appKey), Q.where('target_id', targetId)) .fetch() if (convs.length > 0) { await db.write(async () => { @@ -306,26 +306,26 @@ export const ImDatabase = { } }, - async getConversationDraft(appId: string, targetId: string, chatType: string): Promise { - const memoryDraft = draftStore.get(draftKey(appId, targetId, chatType)) + async getConversationDraft(appKey: string, targetId: string, chatType: string): Promise { + const memoryDraft = draftStore.get(draftKey(appKey, targetId, chatType)) if (memoryDraft !== undefined) { return memoryDraft } const db = getDb() const convs = await db .get('im_conversations') - .query(Q.where('app_id', appId), Q.where('target_id', targetId)) + .query(Q.where('app_id', appKey), Q.where('target_id', targetId)) .fetch() if (convs.length === 0) return null return convs[0].draft }, - async deleteConversation(appId: string, targetId: string, chatType: string, currentUserId: string): Promise { + async deleteConversation(appKey: string, targetId: string, chatType: string, currentUserId: string): Promise { const db = getDb() - const convId = conversationId(appId, currentUserId, targetId, chatType) + const convId = conversationId(appKey, currentUserId, targetId, chatType) const convs = await db .get('im_conversations') - .query(Q.where('app_id', appId), Q.where('target_id', targetId)) + .query(Q.where('app_id', appKey), Q.where('target_id', targetId)) .fetch() const messages = await db .get('im_messages') diff --git a/packages/im/src/db/MessageModel.ts b/packages/im/src/db/MessageModel.ts index e3a7ea1..13c4ddb 100644 --- a/packages/im/src/db/MessageModel.ts +++ b/packages/im/src/db/MessageModel.ts @@ -5,7 +5,7 @@ export class MessageModel extends Model { static table = 'im_messages' @field('server_id') serverId!: string - @field('app_id') appId!: string + @field('app_id') appKey!: string @field('conversation_id') conversationId!: string @field('from_user_id') fromUserId!: string @field('to_id') toId!: string diff --git a/packages/im/src/types.ts b/packages/im/src/types.ts index 2b58a34..9f3299e 100644 --- a/packages/im/src/types.ts +++ b/packages/im/src/types.ts @@ -21,7 +21,7 @@ export type MsgStatus = 'SENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED' | ' export interface ImMessage { id: string - appId: string + appKey: string fromUserId: string fromId?: string toId: string @@ -58,7 +58,7 @@ export interface SendMessageParams { export interface ImGroup { id: string - appId: string + appKey: string name: string groupType?: string creatorId: string @@ -111,7 +111,7 @@ export interface PageResult { export interface FriendRequest { id: string - appId: string + appKey: string fromUserId: string toUserId: string remark?: string | null @@ -122,7 +122,7 @@ export interface FriendRequest { export interface GroupJoinRequest { id: string - appId: string + appKey: string groupId: string requesterId: string remark?: string | null @@ -133,7 +133,7 @@ export interface GroupJoinRequest { export interface BlacklistEntry { id: string - appId: string + appKey: string userId: string blockedUserId: string createdAt: number @@ -156,7 +156,7 @@ export interface GroupReadReceiptSummary { export interface UserProfile { id?: string - appId?: string + appKey?: string userId: string nickname?: string | null avatar?: string | null diff --git a/packages/im/tests/runtime.test.ts b/packages/im/tests/runtime.test.ts index 1eb24f6..f5c7792 100644 --- a/packages/im/tests/runtime.test.ts +++ b/packages/im/tests/runtime.test.ts @@ -22,9 +22,9 @@ test('sortConversations keeps pinned conversations first and then sorts by time' test('sortMessages deduplicates by message id and sorts newest first', () => { const messages = sortMessages([ - { id: 'm2', appId: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'old', status: 'SENT', createdAt: 100 }, - { id: 'm1', appId: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'latest', status: 'SENT', createdAt: 200 }, - { id: 'm1', appId: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'duplicate', status: 'READ', createdAt: 180 }, + { id: 'm2', appKey: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'old', status: 'SENT', createdAt: 100 }, + { id: 'm1', appKey: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'latest', status: 'SENT', createdAt: 200 }, + { id: 'm1', appKey: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'duplicate', status: 'READ', createdAt: 180 }, ]) assert.deepEqual( diff --git a/packages/push/package.json b/packages/push/package.json index f93aba8..e9ebc45 100644 --- a/packages/push/package.json +++ b/packages/push/package.json @@ -1,6 +1,6 @@ { "name": "@xuqm/rn-push", - "version": "0.2.0", + "version": "0.2.1", "description": "XuqmGroup RN SDK — Push module (device token registration)", "license": "UNLICENSED", "main": "src/index.ts", @@ -12,8 +12,7 @@ }, "scripts": { "typecheck": "tsc --noEmit" }, "dependencies": { - "@xuqm/rn-common": ">=0.2.0", - "@xuqm/rn-sdk": ">=0.2.0" + "@xuqm/rn-common": ">=0.2.2" }, "peerDependencies": { "react-native": ">=0.76.0" diff --git a/packages/push/src/PushSDK.ts b/packages/push/src/PushSDK.ts index 55520b4..f137bfe 100644 --- a/packages/push/src/PushSDK.ts +++ b/packages/push/src/PushSDK.ts @@ -10,6 +10,7 @@ type PendingDeviceToken = { } let currentUserId: string | null = null +let currentInitializedUserId: string | null = null let pendingToken: PendingDeviceToken | null = null let _tokenUnsubscribe: (() => void) | null = null @@ -21,7 +22,7 @@ async function registerPendingToken(): Promise { await apiRequest('/api/push/register', { method: 'POST', params: { - appId: config.appId, + appKey: config.appKey, userId, vendor: pendingToken.vendor ?? device.pushVendor, token: pendingToken.token, @@ -36,7 +37,13 @@ async function registerPendingToken(): Promise { export const PushSDK = { async initialize(userId?: string): Promise { - currentUserId = userId ?? getCommonUserId() + const nextUserId = userId ?? getCommonUserId() + if (currentInitializedUserId === nextUserId) { + currentUserId = nextUserId + return + } + currentInitializedUserId = nextUserId + currentUserId = nextUserId await registerPendingToken() }, @@ -106,7 +113,7 @@ export const PushSDK = { await apiRequest('/api/push/register', { method: 'POST', params: { - appId: config.appId, + appKey: config.appKey, userId, vendor: vendor ?? device.pushVendor, token, @@ -125,9 +132,10 @@ export const PushSDK = { const { deviceId } = await getDeviceInfo() await apiRequest('/api/push/unregister', { method: 'DELETE', - params: { appId: config.appId, userId, deviceId }, + params: { appKey: config.appKey, userId, deviceId }, }) if (currentUserId === userId) currentUserId = null + if (currentInitializedUserId === userId) currentInitializedUserId = null if (pendingToken && currentUserId === null) pendingToken = null }, diff --git a/packages/update/android/build.gradle b/packages/update/android/build.gradle new file mode 100644 index 0000000..25652e4 --- /dev/null +++ b/packages/update/android/build.gradle @@ -0,0 +1,30 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.1.0") + } +} + +apply plugin: "com.android.library" + +android { + compileSdkVersion 34 + namespace "com.xuqm.update" + + defaultConfig { + minSdkVersion 24 + targetSdkVersion 34 + } +} + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation "com.facebook.react:react-android" +} diff --git a/packages/update/android/src/main/AndroidManifest.xml b/packages/update/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/packages/update/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/update/android/src/main/java/com/xuqm/update/XuqmUpdatePackage.java b/packages/update/android/src/main/java/com/xuqm/update/XuqmUpdatePackage.java new file mode 100644 index 0000000..0bb8c20 --- /dev/null +++ b/packages/update/android/src/main/java/com/xuqm/update/XuqmUpdatePackage.java @@ -0,0 +1,23 @@ +package com.xuqm.update; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class XuqmUpdatePackage implements ReactPackage { + + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + return Arrays.asList(new XuqmVersionModule(reactContext)); + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } +} diff --git a/packages/update/package.json b/packages/update/package.json index 261a059..cda2987 100644 --- a/packages/update/package.json +++ b/packages/update/package.json @@ -1,6 +1,6 @@ { "name": "@xuqm/rn-update", - "version": "0.2.0", + "version": "0.2.3", "description": "XuqmGroup RN SDK — Update module (App update, RN plugin hot-update)", "license": "UNLICENSED", "main": "src/index.ts", @@ -12,8 +12,7 @@ }, "scripts": { "typecheck": "tsc --noEmit" }, "dependencies": { - "@xuqm/rn-common": ">=0.2.0", - "@xuqm/rn-sdk": ">=0.2.0" + "@xuqm/rn-common": ">=0.2.1" }, "peerDependencies": { "react-native": ">=0.76.0", diff --git a/packages/update/react-native.config.js b/packages/update/react-native.config.js new file mode 100644 index 0000000..4364b62 --- /dev/null +++ b/packages/update/react-native.config.js @@ -0,0 +1,11 @@ +module.exports = { + dependency: { + platforms: { + android: { + sourceDir: './android', + packageImportPath: 'import com.xuqm.update.XuqmUpdatePackage;', + packageInstance: 'new XuqmUpdatePackage()', + }, + }, + }, +} diff --git a/packages/update/scripts/xuqm_release.mjs b/packages/update/scripts/xuqm_release.mjs index ad1e40e..6078297 100644 --- a/packages/update/scripts/xuqm_release.mjs +++ b/packages/update/scripts/xuqm_release.mjs @@ -64,7 +64,7 @@ async function apiFetch(path, opts = {}) { async function uploadBundle(moduleId, platform, bundleFile, version, minVersion, note, packageName) { const form = new FormData() const blob = new Blob([readFileSync(bundleFile)], { type: 'application/octet-stream' }) - form.append('appId', appKey) + form.append('appKey', appKey) form.append('moduleId', moduleId) form.append('platform', platform.toUpperCase()) form.append('version', version) @@ -95,7 +95,7 @@ async function main() { // ── 2. Server latest ───────────────────────────────────────────────────── let serverVersion = 'none' try { - const resp = await apiFetch(`/api/v1/rn/list?appId=${appKey}`) + const resp = await apiFetch(`/api/v1/rn/list?appKey=${appKey}`) const published = (resp.data ?? []).filter(x => x.publishStatus === 'PUBLISHED') serverVersion = published[0]?.version ?? 'none' } catch { /* no bundles yet */ } diff --git a/packages/update/src/UpdateSDK.ts b/packages/update/src/UpdateSDK.ts index 3cd3892..18ef9df 100644 --- a/packages/update/src/UpdateSDK.ts +++ b/packages/update/src/UpdateSDK.ts @@ -89,7 +89,7 @@ export const UpdateSDK = { const result = await apiRequest('/api/v1/updates/app/check', { skipAuth: true, params: { - appId: config.appId, + appKey: config.appKey, platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS', currentVersionCode: String(currentVersionCode), }, @@ -118,7 +118,7 @@ export const UpdateSDK = { const result = await apiRequest('/api/v1/rn/update/check', { skipAuth: true, params: { - appId: config.appId, + appKey: config.appKey, moduleId, platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS', currentVersion: meta.version, diff --git a/packages/xwebview/package.json b/packages/xwebview/package.json new file mode 100644 index 0000000..8122a16 --- /dev/null +++ b/packages/xwebview/package.json @@ -0,0 +1,32 @@ +{ + "name": "@xuqm/rn-xwebview", + "version": "0.2.0", + "description": "XuqmGroup RN SDK — XWebView module", + "license": "UNLICENSED", + "main": "src/index.ts", + "react-native": "src/index.ts", + "types": "src/index.ts", + "private": false, + "publishConfig": { + "registry": "https://nexus.xuqinmin.com/repository/npm-hosted/" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@xuqm/rn-common": ">=0.2.2", + "react-native-blob-util": "^0.24.7", + "react-native-svg": "^15.15.4", + "react-native-webview": "^13.16.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-native": ">=0.76.0", + "@react-navigation/native": ">=7.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-native": "^0.73.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/xwebview/src/XWebViewDownload.ts b/packages/xwebview/src/XWebViewDownload.ts new file mode 100644 index 0000000..058b184 --- /dev/null +++ b/packages/xwebview/src/XWebViewDownload.ts @@ -0,0 +1,161 @@ +import { Platform } from 'react-native' +import RNFetchBlob from 'react-native-blob-util' + +import type { + XWebViewDownloadProgress, + XWebViewDownloadRequest, + XWebViewDownloadResult, +} from '@xuqm/rn-common' + +function parseContentDispositionFilename(header: string): string | null { + const rfc5987 = header.match(/filename\*=(?:[^']*'[^']*')?([^;\s]+)/i) + if (rfc5987?.[1]) { + try { + return decodeURIComponent(rfc5987[1].replace(/['"]/g, '')) + } catch {} + } + + const quoted = header.match(/filename="([^"]+)"/i) + if (quoted?.[1]) return quoted[1].trim() + + const plain = header.match(/filename=([^;\s"]+)/i) + if (plain?.[1]) return plain[1].trim() + + return null +} + +function filenameFromUrl(url: string): string { + try { + const { pathname } = new URL(url) + const parts = pathname.split('/').filter(Boolean) + return parts[parts.length - 1] ? decodeURIComponent(parts[parts.length - 1]) : 'download' + } catch { + const path = url.split('?')[0] + const parts = path.split('/').filter(Boolean) + return parts[parts.length - 1] || 'download' + } +} + +export async function fetchDownloadInfo( + url: string, + hintFilename?: string, +): Promise { + try { + const res = await fetch(url, { method: 'HEAD' }) + const disposition = res.headers.get('content-disposition') ?? '' + const mimeType = res.headers.get('content-type')?.split(';')[0].trim() || undefined + const length = res.headers.get('content-length') + const fileSize = length ? parseInt(length, 10) : undefined + + const suggestedFilename = + (hintFilename && hintFilename.trim()) || + parseContentDispositionFilename(disposition) || + filenameFromUrl(url) + + return { url, suggestedFilename, mimeType, fileSize } + } catch { + return { + url, + suggestedFilename: (hintFilename && hintFilename.trim()) || filenameFromUrl(url), + } + } +} + +async function resolveFilePath( + dir: string, + filename: string, + conflict: 'rename' | 'overwrite', +): Promise { + const full = `${dir}/${filename}` + if (conflict === 'overwrite') return full + + const exists = await RNFetchBlob.fs.exists(full) + if (!exists) return full + + const dot = filename.lastIndexOf('.') + const base = dot >= 0 ? filename.slice(0, dot) : filename + const ext = dot >= 0 ? filename.slice(dot) : '' + + let n = 1 + let candidate: string + do { + candidate = `${dir}/${base}(${n})${ext}` + n++ + } while (await RNFetchBlob.fs.exists(candidate)) + + return candidate +} + +export async function saveBase64File( + base64: string, + filename: string, + savePath: string | undefined, + conflict: 'rename' | 'overwrite', +): Promise { + const { dirs } = RNFetchBlob.fs + const dir = + savePath ?? + (Platform.OS === 'android' + ? (dirs.DownloadDir ?? dirs.DocumentDir) + : dirs.DocumentDir) + const filePath = await resolveFilePath(dir, filename, conflict) + await RNFetchBlob.fs.writeFile(filePath, base64, 'base64') + return filePath +} + +export type DownloadHandle = { cancel: () => void } + +export function startDownload( + url: string, + filename: string, + savePath: string | undefined, + conflict: 'rename' | 'overwrite', + onProgress: (p: XWebViewDownloadProgress) => void, + onComplete: (r: XWebViewDownloadResult) => void, + onError: (error: string) => void, +): DownloadHandle { + const { dirs } = RNFetchBlob.fs + const dir = + savePath ?? + (Platform.OS === 'android' + ? (dirs.DownloadDir ?? dirs.DocumentDir) + : dirs.DocumentDir) + + let cancelled = false + let cancelFn = () => { + cancelled = true + } + + resolveFilePath(dir, filename, conflict) + .then(filePath => { + if (cancelled) return + + const task = RNFetchBlob.config({ path: filePath }).fetch('GET', url) + cancelFn = () => task.cancel() + + task.progress({ interval: 300 }, (received: number, total: number) => { + const recv = Number(received) + const tot = Number(total) + onProgress({ + url, + filename, + received: recv, + total: tot, + percentage: tot > 0 ? Math.round((recv / tot) * 100) : -1, + }) + }) + + task + .then((res: { path: () => string }) => { + onComplete({ url, filename, filePath: res.path(), fileSize: 0 }) + }) + .catch((err: Error) => { + if (!String(err?.message ?? '').toLowerCase().includes('cancel')) { + onError(err?.message ?? String(err)) + } + }) + }) + .catch(err => onError(String(err))) + + return { cancel: () => cancelFn() } +} diff --git a/packages/xwebview/src/XWebViewProgress.tsx b/packages/xwebview/src/XWebViewProgress.tsx new file mode 100644 index 0000000..f89dbbc --- /dev/null +++ b/packages/xwebview/src/XWebViewProgress.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useRef } from 'react' +import { Animated, Dimensions, StyleSheet, View } from 'react-native' + +type Props = { + progress: number + visible: boolean +} + +const SCREEN_WIDTH = Dimensions.get('window').width + +const GRADIENT_COLORS = [ + '#3b82f6', + '#6366f1', + '#8b5cf6', + '#a855f7', + '#ec4899', + '#f43f5e', +] + +export default function XWebViewProgress({ progress, visible }: Props) { + const animWidth = useRef(new Animated.Value(0)).current + const animOpacity = useRef(new Animated.Value(0)).current + + useEffect(() => { + Animated.timing(animOpacity, { + toValue: visible ? 1 : 0, + duration: visible ? 150 : 300, + useNativeDriver: false, + }).start() + }, [visible, animOpacity]) + + useEffect(() => { + Animated.timing(animWidth, { + toValue: Math.min(progress, 1) * SCREEN_WIDTH, + duration: 200, + useNativeDriver: false, + }).start() + }, [progress, animWidth]) + + return ( + + + + {GRADIENT_COLORS.map(color => ( + + ))} + + + + ) +} + +const styles = StyleSheet.create({ + container: { + height: 3, + backgroundColor: '#e2e8f0', + overflow: 'hidden', + }, + track: { + height: 3, + overflow: 'hidden', + }, + gradient: { + flexDirection: 'row', + width: SCREEN_WIDTH, + height: 3, + }, + segment: { + flex: 1, + height: 3, + }, +}) diff --git a/packages/xwebview/src/XWebViewScreen.tsx b/packages/xwebview/src/XWebViewScreen.tsx new file mode 100644 index 0000000..9607d71 --- /dev/null +++ b/packages/xwebview/src/XWebViewScreen.tsx @@ -0,0 +1,642 @@ +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react' +import { + ActionSheetIOS, + Alert, + BackHandler, + Clipboard, + Linking, + Platform, + StatusBar, + StyleSheet, + Text, + ToastAndroid, + TouchableOpacity, + View, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import WebView from 'react-native-webview' +import type { + ShouldStartLoadRequest, + WebViewErrorEvent, + WebViewMessageEvent, + WebViewNavigation, + WebViewOpenWindowEvent, + WebViewProgressEvent, +} from 'react-native-webview/lib/WebViewTypes' +import { useNavigation } from '@react-navigation/native' + +import { + getXWebViewConfig, + setXWebViewController, + type XWebViewConfig, +} from '@xuqm/rn-common' + +import XWebViewProgress from './XWebViewProgress' +import { fetchDownloadInfo, saveBase64File, startDownload } from './XWebViewDownload' +import IconBack from './icons/IconBack' +import IconClose from './icons/IconClose' +import IconMenu from './icons/IconMenu' + +const DOWNLOAD_EXTENSIONS = [ + '.pdf', + '.zip', + '.rar', + '.tar', + '.gz', + '.apk', + '.ipa', + '.doc', + '.docx', + '.xls', + '.xlsx', + '.ppt', + '.pptx', + '.mp4', + '.mp3', + '.mov', + '.exe', + '.dmg', +] + +function isDownloadUrl(url: string): boolean { + const lower = url.toLowerCase().split('?')[0] + return DOWNLOAD_EXTENSIONS.some(ext => lower.endsWith(ext)) +} + +function HeaderBackClose({ + canGoBack, + onBack, + onClose, +}: { + canGoBack: boolean + onBack: () => void + onClose: () => void +}) { + return ( + + {canGoBack ? ( + + + + ) : null} + + + + + ) +} + +function MenuButton({ + config, + currentUrl, + onRefresh, +}: { + config: XWebViewConfig + currentUrl: string + onRefresh: () => void +}) { + const handleDefaultMenu = useCallback(() => { + const actions: { label: string; handler: () => void }[] = [ + { label: '刷新', handler: onRefresh }, + { + label: '复制链接', + handler: () => { + Clipboard.setString(currentUrl) + }, + }, + ] + + if (config.openForBrowser) { + actions.push({ + label: '浏览器打开', + handler: () => Linking.openURL(currentUrl), + }) + } + + if (Platform.OS === 'ios') { + ActionSheetIOS.showActionSheetWithOptions( + { + options: [...actions.map(a => a.label), '取消'], + cancelButtonIndex: actions.length, + }, + index => { + if (index < actions.length) actions[index].handler() + }, + ) + return + } + + Alert.alert( + '操作', + undefined, + [...actions.map(a => ({ text: a.label, onPress: a.handler })), { text: '取消', style: 'cancel' as const }], + { cancelable: true }, + ) + }, [config, currentUrl, onRefresh]) + + if (config.clickMenu) { + const { clickMenu } = config + return ( + + {clickMenu.view ?? } + + ) + } + + return ( + + + + ) +} + +const DOWNLOAD_EXT_RE = + /\.(exe|apk|ipa|zip|rar|tar|gz|dmg|pkg|deb|rpm|msi|pdf|doc|docx|xls|xlsx|ppt|pptx|mp4|mp3|mov|avi|mkv)(\?|#|$)/i + +const DIALOG_OVERRIDE_JS = ` +(function() { + function post(obj) { + window.ReactNativeWebView.postMessage(JSON.stringify(obj)); + } + + window.alert = function(msg) { post({ __xwv: 'alert', msg: String(msg) }); }; + window.confirm = function(msg) { post({ __xwv: 'confirm', msg: String(msg) }); return true; }; + window.prompt = function(msg, def) { post({ __xwv: 'prompt', msg: String(msg), def: def || '' }); return def || ''; }; + + window.open = function(url) { + post({ __xwv: 'log', msg: '[XWV] window.open: ' + url }); + if (url) { window.location.href = url; } + return null; + }; + + URL.revokeObjectURL = function() {}; + + function readBlobAndPost(blobUrl, filename) { + fetch(blobUrl) + .then(function(r) { return r.blob(); }) + .then(function(blob) { + var reader = new FileReader(); + reader.onloadend = function() { + var b64 = reader.result.split(',')[1]; + post({ __xwv: 'blobdownload', url: blobUrl, filename: filename, data: b64 }); + }; + reader.readAsDataURL(blob); + }) + .catch(function(err) { + post({ __xwv: 'bloberror', msg: String(err) }); + }); + } + + var DL_RE = ${JSON.stringify(DOWNLOAD_EXT_RE.source)}; + var dlRe = new RegExp(DL_RE, 'i'); + + function tryInterceptAnchor(el, e) { + if (!el || el.tagName !== 'A') return false; + var href = el.href || el.getAttribute('href') || ''; + if (!href || href.indexOf('javascript') === 0) return false; + var hasDownloadAttr = el.hasAttribute('download'); + var dlName = el.getAttribute('download') || ''; + var isDL = hasDownloadAttr || dlRe.test(href); + post({ __xwv: 'log', msg: '[XWV] anchor href=' + href + ' download=' + hasDownloadAttr + ' dlName=' + dlName + ' isDL=' + isDL }); + if (isDL) { + if (e) { e.preventDefault(); e.stopPropagation(); } + if (href.startsWith('blob:')) { + readBlobAndPost(href, dlName); + } else { + post({ __xwv: 'download', url: href, filename: dlName }); + } + return true; + } + return false; + } + + document.addEventListener('click', function(e) { + var el = e.target; + while (el && el.tagName !== 'A') { el = el.parentElement; } + tryInterceptAnchor(el, e); + }, true); + + var _origClick = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function() { + post({ __xwv: 'log', msg: '[XWV] HTMLAnchorElement.click() href=' + this.href }); + if (!tryInterceptAnchor(this, null)) { + _origClick.call(this); + } + }; + + post({ __xwv: 'log', msg: '[XWV] injected scripts ready' }); +})(); +true; +` + +export function XWebViewScreen() { + const navigation = useNavigation() + const webViewRef = useRef(null) + const lastBackPressRef = useRef(0) + + const config = getXWebViewConfig() + const { + showTopBar = true, + showStatusBar = true, + doubleBackExit = false, + showTitle = true, + autoTitle = false, + showMenu = true, + title: initialTitle = '', + url, + content, + injectedJavaScript, + onMessage: userOnMessage, + onPermissionRequest, + autoDownload = true, + downloadConflict = 'rename', + onDownloadStart, + onDownloadProgress, + onDownloadComplete, + onDownloadError, + onDownloadDecide, + onClose, + } = config + + const [progress, setProgress] = useState(0) + const [showProgress, setShowProgress] = useState(false) + const [canGoBack, setCanGoBack] = useState(false) + const [currentUrl, setCurrentUrl] = useState(url ?? '') + const [currentTitle, setCurrentTitle] = useState(initialTitle) + const [loadError, setLoadError] = useState(null) + + useEffect(() => { + setXWebViewController({ + refresh: () => webViewRef.current?.reload(), + close: () => { + onClose?.() + navigation.goBack() + }, + goBack: () => webViewRef.current?.goBack(), + goForward: () => webViewRef.current?.goForward(), + copyUrl: () => Clipboard.setString(currentUrl), + postMessageToWeb: (js: string) => webViewRef.current?.injectJavaScript(js), + getTitle: () => currentTitle, + }) + return () => setXWebViewController(null) + }, [currentUrl, currentTitle, navigation, onClose]) + + useLayoutEffect(() => { + navigation.setOptions({ headerShown: false }) + }, [navigation]) + + useEffect(() => { + if (Platform.OS !== 'android') return + + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + if (canGoBack) { + webViewRef.current?.goBack() + return true + } + + if (!doubleBackExit) { + onClose?.() + navigation.goBack() + return true + } + + const now = Date.now() + if (now - lastBackPressRef.current < 2000) { + BackHandler.exitApp() + return true + } + + lastBackPressRef.current = now + ToastAndroid.show('再按一次退出应用', ToastAndroid.SHORT) + return true + }) + + return () => subscription.remove() + }, [canGoBack, doubleBackExit, navigation, onClose]) + + const handleNavigationStateChange = useCallback( + (state: WebViewNavigation) => { + setCanGoBack(state.canGoBack) + if (state.url) setCurrentUrl(state.url) + if (autoTitle && state.title) setCurrentTitle(state.title) + }, + [autoTitle], + ) + + const handleLoadProgress = useCallback((event: WebViewProgressEvent) => { + const p = event.nativeEvent.progress + setProgress(p) + if (p >= 0.96) setShowProgress(false) + }, []) + + const processBlobDownload = useCallback( + async (blobUrl: string, hintFilename: string, base64: string) => { + const info = { url: blobUrl, suggestedFilename: hintFilename || 'download' } + + let finalFilename = info.suggestedFilename + let finalSavePath: string | undefined + + if (!autoDownload) { + if (!onDownloadDecide) return + const decision = await Promise.resolve(onDownloadDecide(info)) + if (!decision.allowed) return + if (decision.filename) finalFilename = decision.filename + if (decision.savePath) finalSavePath = decision.savePath + } + + onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) + + try { + const filePath = await saveBase64File(base64, finalFilename, finalSavePath, downloadConflict) + onDownloadComplete?.({ url: blobUrl, filename: finalFilename, filePath, fileSize: 0 }) + } catch (err) { + onDownloadError?.(blobUrl, String(err)) + } + }, + [autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError], + ) + + const processDownload = useCallback( + async (downloadUrl: string, hintFilename?: string) => { + const info = await fetchDownloadInfo(downloadUrl, hintFilename) + + let finalFilename = info.suggestedFilename + let finalSavePath: string | undefined + + if (!autoDownload) { + if (!onDownloadDecide) return + const decision = await Promise.resolve(onDownloadDecide(info)) + if (!decision.allowed) return + if (decision.filename) finalFilename = decision.filename + if (decision.savePath) finalSavePath = decision.savePath + } + + onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) + + startDownload( + downloadUrl, + finalFilename, + finalSavePath, + downloadConflict, + p => onDownloadProgress?.(p), + r => onDownloadComplete?.(r), + err => onDownloadError?.(downloadUrl, err), + ) + }, + [autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadProgress, onDownloadComplete, onDownloadError], + ) + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + const raw = event.nativeEvent.data + try { + const parsed = JSON.parse(raw) + + if (parsed.__xwv === 'download') { + processDownload(String(parsed.url), parsed.filename ? String(parsed.filename) : undefined) + return + } + if (parsed.__xwv === 'blobdownload') { + processBlobDownload( + String(parsed.url), + String(parsed.filename ?? 'download'), + String(parsed.data ?? ''), + ) + return + } + if (parsed.__xwv === 'bloberror') { + onDownloadError?.(String(parsed.url ?? ''), String(parsed.msg ?? 'blob read failed')) + return + } + if (parsed.__xwv === 'alert') { + Alert.alert('提示', String(parsed.msg ?? '')) + return + } + if (parsed.__xwv === 'confirm') { + Alert.alert('确认', String(parsed.msg ?? ''), [{ text: '知道了' }]) + return + } + if (parsed.__xwv === 'prompt') { + Alert.alert('输入', `${parsed.msg}${parsed.def ? `\n默认值: ${parsed.def}` : ''}`) + return + } + } catch { + // not an internal message + } + userOnMessage?.(event) + }, + [processDownload, processBlobDownload, onDownloadError, userOnMessage], + ) + + const handleShouldStartLoad = useCallback( + (request: ShouldStartLoadRequest): boolean => { + const isDL = isDownloadUrl(request.url) + if (isDL) { + processDownload(request.url) + return false + } + return true + }, + [processDownload], + ) + + const handleError = useCallback((event: WebViewErrorEvent) => { + setLoadError(event.nativeEvent.description || '页面加载失败') + setShowProgress(false) + }, []) + + const handlePermissionRequest = useCallback( + (request: { + nativeEvent: { + origin: string + resources: string[] + grant: (r: string[]) => void + deny: () => void + } + }) => { + if (!onPermissionRequest) return + onPermissionRequest({ + origin: request.nativeEvent.origin, + resources: request.nativeEvent.resources, + grant: (res?: string[]) => request.nativeEvent.grant(res ?? request.nativeEvent.resources), + deny: () => request.nativeEvent.deny(), + }) + }, + [onPermissionRequest], + ) + + const handleOpenWindow = useCallback((event: WebViewOpenWindowEvent) => { + const targetUrl = event.nativeEvent.targetUrl + if (targetUrl) { + webViewRef.current?.injectJavaScript( + `window.location.href = ${JSON.stringify(targetUrl)}; true;`, + ) + } + }, []) + + const source = url + ? ({ uri: url } as const) + : ({ html: content ?? '' } as const) + + const injected = DIALOG_OVERRIDE_JS + '\n' + (injectedJavaScript ?? '') + const shouldShowStatusBar = showTopBar || showStatusBar + const ContentContainer = shouldShowStatusBar ? SafeAreaView : View + + return ( + + + ) +} + +const HIT_SLOP = { top: 10, bottom: 10, left: 10, right: 10 } + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + }, + topBar: { + height: 48, + paddingHorizontal: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#e2e8f0', + backgroundColor: '#ffffff', + }, + title: { + flex: 1, + marginHorizontal: 10, + fontSize: 16, + fontWeight: '700', + color: '#111827', + }, + webview: { + flex: 1, + }, + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + }, + headerBtn: { + paddingHorizontal: 0, + paddingVertical: 0, + justifyContent: 'center', + alignItems: 'center', + minWidth: 22, + }, + headerBtnPlaceholder: { + minWidth: 22, + }, + errorContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 32, + gap: 12, + }, + errorTitle: { + fontSize: 18, + fontWeight: '800', + color: '#b91c1c', + }, + errorMessage: { + fontSize: 13, + color: '#7f1d1d', + textAlign: 'center', + lineHeight: 20, + }, + retryBtn: { + marginTop: 8, + backgroundColor: '#3b82f6', + borderRadius: 8, + paddingHorizontal: 24, + paddingVertical: 10, + }, + retryText: { + color: '#ffffff', + fontWeight: '700', + fontSize: 15, + }, +}) diff --git a/packages/xwebview/src/XWebViewView.tsx b/packages/xwebview/src/XWebViewView.tsx new file mode 100644 index 0000000..af07c7b --- /dev/null +++ b/packages/xwebview/src/XWebViewView.tsx @@ -0,0 +1,418 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { + Alert, + Clipboard, + Platform, + StatusBar, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import WebView from 'react-native-webview' +import type { + ShouldStartLoadRequest, + WebViewErrorEvent, + WebViewMessageEvent, + WebViewNavigation, + WebViewOpenWindowEvent, + WebViewProgressEvent, +} from 'react-native-webview/lib/WebViewTypes' + +import { + getXWebViewConfig, + setXWebViewController, + type XWebViewConfig, +} from '@xuqm/rn-common' + +import XWebViewProgress from './XWebViewProgress' +import { fetchDownloadInfo, saveBase64File, startDownload } from './XWebViewDownload' + +const DOWNLOAD_EXTENSIONS = [ + '.pdf', + '.zip', + '.rar', + '.tar', + '.gz', + '.apk', + '.ipa', + '.doc', + '.docx', + '.xls', + '.xlsx', + '.ppt', + '.pptx', + '.mp4', + '.mp3', + '.mov', + '.exe', + '.dmg', +] + +function isDownloadUrl(url: string): boolean { + const lower = url.toLowerCase().split('?')[0] + return DOWNLOAD_EXTENSIONS.some(ext => lower.endsWith(ext)) +} + +const DOWNLOAD_EXT_RE = + /\.(exe|apk|ipa|zip|rar|tar|gz|dmg|pkg|deb|rpm|msi|pdf|doc|docx|xls|xlsx|ppt|pptx|mp4|mp3|mov|avi|mkv)(\?|#|$)/i + +const DIALOG_OVERRIDE_JS = ` +(function() { + function post(obj) { + window.ReactNativeWebView.postMessage(JSON.stringify(obj)); + } + + window.alert = function(msg) { post({ __xwv: 'alert', msg: String(msg) }); }; + window.confirm = function(msg) { post({ __xwv: 'confirm', msg: String(msg) }); return true; }; + window.prompt = function(msg, def) { post({ __xwv: 'prompt', msg: String(msg), def: def || '' }); return def || ''; }; + + window.open = function(url) { + post({ __xwv: 'log', msg: '[XWV] window.open: ' + url }); + if (url) { window.location.href = url; } + return null; + }; + + URL.revokeObjectURL = function() {}; + + function readBlobAndPost(blobUrl, filename) { + fetch(blobUrl) + .then(function(r) { return r.blob(); }) + .then(function(blob) { + var reader = new FileReader(); + reader.onloadend = function() { + var b64 = reader.result.split(',')[1]; + post({ __xwv: 'blobdownload', url: blobUrl, filename: filename, data: b64 }); + }; + reader.readAsDataURL(blob); + }) + .catch(function(err) { + post({ __xwv: 'bloberror', msg: String(err) }); + }); + } + + var DL_RE = ${JSON.stringify(DOWNLOAD_EXT_RE.source)}; + var dlRe = new RegExp(DL_RE, 'i'); + + function tryInterceptAnchor(el, e) { + if (!el || el.tagName !== 'A') return false; + var href = el.href || el.getAttribute('href') || ''; + if (!href || href.indexOf('javascript') === 0) return false; + var hasDownloadAttr = el.hasAttribute('download'); + var dlName = el.getAttribute('download') || ''; + var isDL = hasDownloadAttr || dlRe.test(href); + post({ __xwv: 'log', msg: '[XWV] anchor href=' + href + ' download=' + hasDownloadAttr + ' dlName=' + dlName + ' isDL=' + isDL }); + if (isDL) { + if (e) { e.preventDefault(); e.stopPropagation(); } + if (href.startsWith('blob:')) { + readBlobAndPost(href, dlName); + } else { + post({ __xwv: 'download', url: href, filename: dlName }); + } + return true; + } + return false; + } + + document.addEventListener('click', function(e) { + var el = e.target; + while (el && el.tagName !== 'A') { el = el.parentElement; } + tryInterceptAnchor(el, e); + }, true); + + var _origClick = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function() { + post({ __xwv: 'log', msg: '[XWV] HTMLAnchorElement.click() href=' + this.href }); + if (!tryInterceptAnchor(this, null)) { + _origClick.call(this); + } + }; + + post({ __xwv: 'log', msg: '[XWV] injected scripts ready' }); +})(); +true; +` + +export function XWebViewView() { + const webViewRef = useRef(null) + + const config = getXWebViewConfig() + const { + showStatusBar = true, + title: initialTitle = '', + url, + content, + injectedJavaScript, + onMessage: userOnMessage, + onPermissionRequest, + autoDownload = true, + downloadConflict = 'rename', + onDownloadStart, + onDownloadProgress, + onDownloadComplete, + onDownloadError, + onDownloadDecide, + onClose, + } = config + + const [progress, setProgress] = useState(0) + const [showProgress, setShowProgress] = useState(false) + const [canGoBack, setCanGoBack] = useState(false) + const [currentUrl, setCurrentUrl] = useState(url ?? '') + const [currentTitle, setCurrentTitle] = useState(initialTitle) + const [loadError, setLoadError] = useState(null) + + useEffect(() => { + setXWebViewController({ + refresh: () => webViewRef.current?.reload(), + close: () => onClose?.(), + goBack: () => webViewRef.current?.goBack(), + goForward: () => webViewRef.current?.goForward(), + copyUrl: () => Clipboard.setString(currentUrl), + postMessageToWeb: (js: string) => webViewRef.current?.injectJavaScript(js), + getTitle: () => currentTitle, + }) + return () => setXWebViewController(null) + }, [currentUrl, currentTitle, onClose]) + + const handleNavigationStateChange = useCallback((state: WebViewNavigation) => { + setCanGoBack(state.canGoBack) + if (state.url) setCurrentUrl(state.url) + if (state.title) setCurrentTitle(state.title) + }, []) + + const handleLoadProgress = useCallback((event: WebViewProgressEvent) => { + const p = event.nativeEvent.progress + setProgress(p) + if (p >= 0.96) setShowProgress(false) + }, []) + + const processBlobDownload = useCallback( + async (blobUrl: string, hintFilename: string, base64: string) => { + const info = { url: blobUrl, suggestedFilename: hintFilename || 'download' } + let finalFilename = info.suggestedFilename + let finalSavePath: string | undefined + + if (!autoDownload) { + if (!onDownloadDecide) return + const decision = await Promise.resolve(onDownloadDecide(info)) + if (!decision.allowed) return + if (decision.filename) finalFilename = decision.filename + if (decision.savePath) finalSavePath = decision.savePath + } + + onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) + try { + const filePath = await saveBase64File(base64, finalFilename, finalSavePath, downloadConflict) + onDownloadComplete?.({ url: blobUrl, filename: finalFilename, filePath, fileSize: 0 }) + } catch (err) { + onDownloadError?.(blobUrl, String(err)) + } + }, + [autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError], + ) + + const processDownload = useCallback( + async (downloadUrl: string, hintFilename?: string) => { + const info = await fetchDownloadInfo(downloadUrl, hintFilename) + let finalFilename = info.suggestedFilename + let finalSavePath: string | undefined + + if (!autoDownload) { + if (!onDownloadDecide) return + const decision = await Promise.resolve(onDownloadDecide(info)) + if (!decision.allowed) return + if (decision.filename) finalFilename = decision.filename + if (decision.savePath) finalSavePath = decision.savePath + } + + onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) + startDownload( + downloadUrl, + finalFilename, + finalSavePath, + downloadConflict, + p => onDownloadProgress?.(p), + r => onDownloadComplete?.(r), + err => onDownloadError?.(downloadUrl, err), + ) + }, + [autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadProgress, onDownloadComplete, onDownloadError], + ) + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + const raw = event.nativeEvent.data + try { + const parsed = JSON.parse(raw) + + if (parsed.__xwv === 'download') { + processDownload(String(parsed.url), parsed.filename ? String(parsed.filename) : undefined) + return + } + if (parsed.__xwv === 'blobdownload') { + processBlobDownload( + String(parsed.url), + String(parsed.filename ?? 'download'), + String(parsed.data ?? ''), + ) + return + } + if (parsed.__xwv === 'bloberror') { + onDownloadError?.(String(parsed.url ?? ''), String(parsed.msg ?? 'blob read failed')) + return + } + if (parsed.__xwv === 'alert' || parsed.__xwv === 'confirm' || parsed.__xwv === 'prompt') { + Alert.alert('提示', String(parsed.msg ?? '')) + return + } + } catch { + // not internal + } + userOnMessage?.(event) + }, + [processDownload, processBlobDownload, onDownloadError, userOnMessage], + ) + + const handleShouldStartLoad = useCallback( + (request: ShouldStartLoadRequest): boolean => { + if (isDownloadUrl(request.url)) { + processDownload(request.url) + return false + } + return true + }, + [processDownload], + ) + + const handleError = useCallback((event: WebViewErrorEvent) => { + setLoadError(event.nativeEvent.description || '页面加载失败') + setShowProgress(false) + }, []) + + const handlePermissionRequest = useCallback( + (request: { + nativeEvent: { + origin: string + resources: string[] + grant: (r: string[]) => void + deny: () => void + } + }) => { + if (!onPermissionRequest) return + onPermissionRequest({ + origin: request.nativeEvent.origin, + resources: request.nativeEvent.resources, + grant: (res?: string[]) => request.nativeEvent.grant(res ?? request.nativeEvent.resources), + deny: () => request.nativeEvent.deny(), + }) + }, + [onPermissionRequest], + ) + + const handleOpenWindow = useCallback((event: WebViewOpenWindowEvent) => { + const targetUrl = event.nativeEvent.targetUrl + if (targetUrl) { + webViewRef.current?.injectJavaScript( + `window.location.href = ${JSON.stringify(targetUrl)}; true;`, + ) + } + }, []) + + const source = url + ? ({ uri: url } as const) + : ({ html: content ?? '' } as const) + + const injected = DIALOG_OVERRIDE_JS + '\n' + (injectedJavaScript ?? '') + const ContentContainer = showStatusBar ? SafeAreaView : View + + return ( + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + }, + webview: { + flex: 1, + }, + errorContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 32, + gap: 12, + }, + errorTitle: { + fontSize: 18, + fontWeight: '800', + color: '#b91c1c', + }, + errorMessage: { + fontSize: 13, + color: '#7f1d1d', + textAlign: 'center', + lineHeight: 20, + }, + retryBtn: { + marginTop: 8, + backgroundColor: '#3b82f6', + borderRadius: 8, + paddingHorizontal: 24, + paddingVertical: 10, + }, + retryText: { + color: '#ffffff', + fontWeight: '700', + fontSize: 15, + }, +}) diff --git a/packages/xwebview/src/icons/IconBack.tsx b/packages/xwebview/src/icons/IconBack.tsx new file mode 100644 index 0000000..0ddd10c --- /dev/null +++ b/packages/xwebview/src/icons/IconBack.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import Svg, { Path } from 'react-native-svg' + +type Props = { size?: number; color?: string } + +export default function IconBack({ size = 22, color = '#222222' }: Props) { + return ( + + + + ) +} diff --git a/packages/xwebview/src/icons/IconClose.tsx b/packages/xwebview/src/icons/IconClose.tsx new file mode 100644 index 0000000..446937f --- /dev/null +++ b/packages/xwebview/src/icons/IconClose.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import Svg, { Path } from 'react-native-svg' + +type Props = { size?: number; color?: string } + +export default function IconClose({ size = 22, color = '#222222' }: Props) { + return ( + + + + ) +} diff --git a/packages/xwebview/src/icons/IconMenu.tsx b/packages/xwebview/src/icons/IconMenu.tsx new file mode 100644 index 0000000..06380af --- /dev/null +++ b/packages/xwebview/src/icons/IconMenu.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import Svg, { Path } from 'react-native-svg' + +type Props = { size?: number; color?: string } + +export default function IconMenu({ size = 22, color = '#5A5A68' }: Props) { + return ( + + + + + + ) +} diff --git a/packages/xwebview/src/index.ts b/packages/xwebview/src/index.ts new file mode 100644 index 0000000..0ee7c24 --- /dev/null +++ b/packages/xwebview/src/index.ts @@ -0,0 +1,16 @@ +export { getXWebViewConfig, openXWebView, setXWebViewController, XWebViewControl } from '@xuqm/rn-common' +export type { + XWebViewClickMenu, + XWebViewConfig, + XWebViewControllerAPI, + XWebViewDownloadDecision, + XWebViewDownloadProgress, + XWebViewDownloadRequest, + XWebViewDownloadResult, + XWebViewMessageEvent, + XWebViewPermissionRequest, +} from '@xuqm/rn-common' + +export { default as XWebViewProgress } from './XWebViewProgress' +export { XWebViewView } from './XWebViewView' +export { XWebViewScreen } from './XWebViewScreen' diff --git a/packages/xwebview/tsconfig.json b/packages/xwebview/tsconfig.json new file mode 100644 index 0000000..722b07c --- /dev/null +++ b/packages/xwebview/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-native", + "baseUrl": "../../", + "paths": { + "@xuqm/rn-common": ["packages/common/src"] + }, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/index.ts b/src/index.ts index fca3e66..7e15a90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,3 +25,22 @@ export { PushSDK } from '@xuqm/rn-push' export type { PushVendor } from '@xuqm/rn-push' export { UpdateSDK } from '@xuqm/rn-update' export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from '@xuqm/rn-update' +export { + XWebViewControl, + XWebViewScreen, + XWebViewView, + getXWebViewConfig, + openXWebView, + setXWebViewController, +} from '@xuqm/rn-xwebview' +export type { + XWebViewClickMenu, + XWebViewConfig, + XWebViewControllerAPI, + XWebViewDownloadDecision, + XWebViewDownloadProgress, + XWebViewDownloadRequest, + XWebViewDownloadResult, + XWebViewMessageEvent, + XWebViewPermissionRequest, +} from '@xuqm/rn-xwebview' diff --git a/src/sdk.ts b/src/sdk.ts index b341d07..cdd611c 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -27,6 +27,14 @@ async function applyLoginSession(session: UnifiedLoginOptions): Promise { } async function login(options: UnifiedLoginOptions): Promise { + if ( + currentSession && + currentSession.userId === options.userId && + currentSession.userSig === options.userSig + ) { + setCommonUserId(options.userId) + return + } if (currentSession) { await logout() }