chore: sync local changes
这个提交包含在:
父节点
59114c9574
当前提交
5bddc9b167
@ -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)。
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -8,7 +8,7 @@ export const XuqmSDK = {
|
||||
*/
|
||||
async initialize(options: XuqmInitOptions): Promise<void> {
|
||||
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()
|
||||
|
||||
@ -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() ?? '',
|
||||
}
|
||||
@ -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<XWebViewDownloadDecision>
|
||||
downloadConflict?: 'rename' | 'overwrite'
|
||||
onClose?: () => void
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -17,19 +17,19 @@ export class ImClient {
|
||||
private groupSubscriptions = new Set<string>()
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, string>()
|
||||
const listenerMap = new WeakMap<ImEventListener, ImEventListener>()
|
||||
let conversationMemory: ConversationData[] = []
|
||||
@ -40,7 +41,7 @@ function normalizeMessage(msg: ImMessage, fallback?: Partial<ImMessage>): 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<void> {
|
||||
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<ImMessage[]> {
|
||||
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<ImMessage[]> {
|
||||
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<ImMessage>('/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<ImMessage>(`/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<ImMessage>(`/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<ImMessage[] | { data?: ImMessage[] }>('/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<number> {
|
||||
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<ImGroup>('/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<ImGroup[]> {
|
||||
const config = getConfig()
|
||||
const res = await apiRequest<ImGroup[] | { content?: ImGroup[] }>('/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<ImGroup[] | { content?: ImGroup[] }>('/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<UserProfile[]> {
|
||||
const config = getConfig()
|
||||
const res = await apiRequest<UserProfile[] | { content?: UserProfile[] }>(`/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<UserProfile[] | { content?: UserProfile[] }>(`/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<GroupJoinRequest>(`/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<GroupJoinRequest[] | { content?: GroupJoinRequest[] }>(
|
||||
`/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<string[]> {
|
||||
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<string[]> {
|
||||
const config = getConfig()
|
||||
const res = await apiRequest<string[] | { content?: string[] }>('/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<string[] | { content?: string[] }>(
|
||||
`/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<FriendRequest[] | { content?: FriendRequest[] }>('/api/im/friend-requests', {
|
||||
params: {
|
||||
appId: config.appId,
|
||||
appKey: config.appKey,
|
||||
direction,
|
||||
},
|
||||
})
|
||||
@ -972,7 +977,7 @@ export const ImSDK = {
|
||||
return apiRequest<FriendRequest>('/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<FriendRequest>(`/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<FriendRequest>(`/api/im/friend-requests/${encodeURIComponent(requestId)}/reject`, {
|
||||
method: 'POST',
|
||||
params: { appId: config.appId },
|
||||
params: { appKey: config.appKey },
|
||||
})
|
||||
},
|
||||
|
||||
async listBlacklist(): Promise<BlacklistEntry[]> {
|
||||
const config = getConfig()
|
||||
const res = await apiRequest<BlacklistEntry[] | { content?: BlacklistEntry[] }>('/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<BlacklistEntry>('/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<BlacklistCheckResult> {
|
||||
const config = getConfig()
|
||||
return apiRequest<BlacklistCheckResult>('/api/im/blacklist/check', {
|
||||
params: { appId: config.appId, targetUserId },
|
||||
params: { appKey: config.appKey, targetUserId },
|
||||
})
|
||||
},
|
||||
|
||||
async getProfile(userId: string): Promise<UserProfile> {
|
||||
const config = getConfig()
|
||||
return apiRequest<UserProfile>(`/api/im/accounts/${encodeURIComponent(userId)}`, {
|
||||
params: { appId: config.appId },
|
||||
params: { appKey: config.appKey },
|
||||
})
|
||||
},
|
||||
|
||||
@ -1043,7 +1048,7 @@ export const ImSDK = {
|
||||
return apiRequest<UserProfile>(`/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<UserProfile[] | { content?: UserProfile[] }>('/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<ImGroup[] | { content?: ImGroup[] }>('/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<ConversationData[]> {
|
||||
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<ConversationData[] | { content?: ConversationData[] }>('/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<void> {
|
||||
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<string[]> {
|
||||
const config = getConfig()
|
||||
const res = await apiRequest<string[] | { content?: string[] }>('/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<ConversationGroupItem[] | { content?: ConversationGroupItem[] }>(
|
||||
`/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<void>('/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<void>('/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<void>('/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<void>('/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<void>(`/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<void>(`/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<void>(`/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<void>(`/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<void>(`/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<string> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<void> {
|
||||
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<MessageModel>('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<ConversationModel>('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<ConversationModel>('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<void> {
|
||||
async revokeMessage(appKey: string, messageId: string): Promise<void> {
|
||||
const db = getDb()
|
||||
const messages = await db
|
||||
.get<MessageModel>('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<ConversationModel>('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<MessageModel[]> {
|
||||
async getMessages(appKey: string, targetId: string, chatType: string, currentUserId: string, limit = 50): Promise<MessageModel[]> {
|
||||
const db = getDb()
|
||||
const convId = conversationId(appId, currentUserId, targetId, chatType)
|
||||
const convId = conversationId(appKey, currentUserId, targetId, chatType)
|
||||
return db
|
||||
.get<MessageModel>('im_messages')
|
||||
.query(
|
||||
@ -177,22 +177,22 @@ export const ImDatabase = {
|
||||
.fetch()
|
||||
},
|
||||
|
||||
async getConversations(appId: string): Promise<ConversationModel[]> {
|
||||
async getConversations(appKey: string): Promise<ConversationModel[]> {
|
||||
const db = getDb()
|
||||
return db
|
||||
.get<ConversationModel>('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<void> {
|
||||
async markRead(appKey: string, targetId: string): Promise<void> {
|
||||
const db = getDb()
|
||||
const convs = await db
|
||||
.get<ConversationModel>('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<void> {
|
||||
draftStore.set(draftKey(appId, targetId, chatType), draft)
|
||||
async setDraft(appKey: string, targetId: string, chatType: string, draft: string): Promise<void> {
|
||||
draftStore.set(draftKey(appKey, targetId, chatType), draft)
|
||||
const db = getDb()
|
||||
const convs = await db
|
||||
.get<ConversationModel>('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<MessageModel[]> {
|
||||
async searchMessages(appKey: string, params: MessageSearchParams): Promise<MessageModel[]> {
|
||||
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<ConversationModel>('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<void> {
|
||||
async setConversationMuted(appKey: string, targetId: string, muted: boolean): Promise<void> {
|
||||
const db = getDb()
|
||||
const convs = await db
|
||||
.get<ConversationModel>('im_conversations')
|
||||
.query(Q.where('app_id', appId), Q.where('target_id', targetId))
|
||||
.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<void> {
|
||||
async setConversationPinned(appKey: string, targetId: string, pinned: boolean): Promise<void> {
|
||||
const db = getDb()
|
||||
const convs = await db
|
||||
.get<ConversationModel>('im_conversations')
|
||||
.query(Q.where('app_id', appId), Q.where('target_id', targetId))
|
||||
.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<string | null> {
|
||||
const memoryDraft = draftStore.get(draftKey(appId, targetId, chatType))
|
||||
async getConversationDraft(appKey: string, targetId: string, chatType: string): Promise<string | null> {
|
||||
const memoryDraft = draftStore.get(draftKey(appKey, targetId, chatType))
|
||||
if (memoryDraft !== undefined) {
|
||||
return memoryDraft
|
||||
}
|
||||
const db = getDb()
|
||||
const convs = await db
|
||||
.get<ConversationModel>('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<void> {
|
||||
async deleteConversation(appKey: string, targetId: string, chatType: string, currentUserId: string): Promise<void> {
|
||||
const db = getDb()
|
||||
const convId = conversationId(appId, currentUserId, targetId, chatType)
|
||||
const convId = conversationId(appKey, currentUserId, targetId, chatType)
|
||||
const convs = await db
|
||||
.get<ConversationModel>('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<MessageModel>('im_messages')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<T> {
|
||||
|
||||
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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
export const PushSDK = {
|
||||
async initialize(userId?: string): Promise<void> {
|
||||
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
|
||||
},
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
@ -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<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
|
||||
return Arrays.asList(new XuqmVersionModule(reactContext));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
dependency: {
|
||||
platforms: {
|
||||
android: {
|
||||
sourceDir: './android',
|
||||
packageImportPath: 'import com.xuqm.update.XuqmUpdatePackage;',
|
||||
packageInstance: 'new XuqmUpdatePackage()',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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 */ }
|
||||
|
||||
@ -89,7 +89,7 @@ export const UpdateSDK = {
|
||||
const result = await apiRequest<AppUpdateInfo>('/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<RnUpdateInfo>('/api/v1/rn/update/check', {
|
||||
skipAuth: true,
|
||||
params: {
|
||||
appId: config.appId,
|
||||
appKey: config.appKey,
|
||||
moduleId,
|
||||
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
||||
currentVersion: meta.version,
|
||||
|
||||
32
packages/xwebview/package.json
普通文件
32
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"
|
||||
}
|
||||
}
|
||||
@ -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<XWebViewDownloadRequest> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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() }
|
||||
}
|
||||
@ -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 (
|
||||
<Animated.View style={[styles.container, { opacity: animOpacity }]}>
|
||||
<Animated.View style={[styles.track, { width: animWidth }]}>
|
||||
<View style={styles.gradient}>
|
||||
{GRADIENT_COLORS.map(color => (
|
||||
<View key={color} style={[styles.segment, { backgroundColor: color }]} />
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
@ -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 (
|
||||
<View style={styles.headerLeft}>
|
||||
{canGoBack ? (
|
||||
<TouchableOpacity style={styles.headerBtn} onPress={onBack} hitSlop={HIT_SLOP}>
|
||||
<IconBack size={22} color="#222222" />
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<TouchableOpacity style={styles.headerBtn} onPress={onClose} hitSlop={HIT_SLOP}>
|
||||
<IconClose size={24} color="#222222" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
style={styles.headerBtn}
|
||||
onPress={clickMenu.onClick}
|
||||
hitSlop={HIT_SLOP}
|
||||
>
|
||||
{clickMenu.view ?? <IconMenu size={22} />}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.headerBtn} onPress={handleDefaultMenu} hitSlop={HIT_SLOP}>
|
||||
<IconMenu size={22} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
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<WebView>(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<string | null>(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 ?? '<html><body></body></html>' } as const)
|
||||
|
||||
const injected = DIALOG_OVERRIDE_JS + '\n' + (injectedJavaScript ?? '')
|
||||
const shouldShowStatusBar = showTopBar || showStatusBar
|
||||
const ContentContainer = shouldShowStatusBar ? SafeAreaView : View
|
||||
|
||||
return (
|
||||
<ContentContainer style={styles.container}>
|
||||
<StatusBar hidden={!shouldShowStatusBar} />
|
||||
<XWebViewProgress progress={progress} visible={showProgress} />
|
||||
{showTopBar ? (
|
||||
<View style={styles.topBar}>
|
||||
<HeaderBackClose
|
||||
canGoBack={canGoBack}
|
||||
onBack={() => webViewRef.current?.goBack()}
|
||||
onClose={() => {
|
||||
onClose?.()
|
||||
navigation.goBack()
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{showTitle ? (autoTitle ? currentTitle : initialTitle) : ''}
|
||||
</Text>
|
||||
{showMenu ? (
|
||||
<MenuButton
|
||||
config={config}
|
||||
currentUrl={currentUrl}
|
||||
onRefresh={() => webViewRef.current?.reload()}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.headerBtnPlaceholder} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{loadError ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorTitle}>页面加载失败</Text>
|
||||
<Text style={styles.errorMessage}>{loadError}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryBtn}
|
||||
onPress={() => {
|
||||
setLoadError(null)
|
||||
webViewRef.current?.reload()
|
||||
}}
|
||||
>
|
||||
<Text style={styles.retryText}>重试</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={source}
|
||||
style={styles.webview}
|
||||
onLoadStart={() => {
|
||||
setShowProgress(true)
|
||||
setLoadError(null)
|
||||
}}
|
||||
onLoadProgress={handleLoadProgress}
|
||||
onLoadEnd={() => setShowProgress(false)}
|
||||
onError={handleError}
|
||||
onNavigationStateChange={handleNavigationStateChange}
|
||||
onMessage={handleMessage}
|
||||
onShouldStartLoadWithRequest={handleShouldStartLoad}
|
||||
onPermissionRequest={onPermissionRequest ? handlePermissionRequest : undefined}
|
||||
injectedJavaScript={injected}
|
||||
onOpenWindow={handleOpenWindow}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
allowFileAccess
|
||||
allowsInlineMediaPlayback
|
||||
sharedCookiesEnabled
|
||||
mediaPlaybackRequiresUserAction={false}
|
||||
setSupportMultipleWindows={false}
|
||||
webviewDebuggingEnabled={__DEV__}
|
||||
/>
|
||||
)}
|
||||
</ContentContainer>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
@ -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<WebView>(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<string | null>(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 ?? '<html><body></body></html>' } as const)
|
||||
|
||||
const injected = DIALOG_OVERRIDE_JS + '\n' + (injectedJavaScript ?? '')
|
||||
const ContentContainer = showStatusBar ? SafeAreaView : View
|
||||
|
||||
return (
|
||||
<ContentContainer style={styles.container}>
|
||||
<StatusBar hidden={!showStatusBar} />
|
||||
<XWebViewProgress progress={progress} visible={showProgress} />
|
||||
{loadError ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorTitle}>页面加载失败</Text>
|
||||
<Text style={styles.errorMessage}>{loadError}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryBtn}
|
||||
onPress={() => {
|
||||
setLoadError(null)
|
||||
webViewRef.current?.reload()
|
||||
}}
|
||||
>
|
||||
<Text style={styles.retryText}>重试</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={source}
|
||||
style={styles.webview}
|
||||
onLoadStart={() => {
|
||||
setShowProgress(true)
|
||||
setLoadError(null)
|
||||
}}
|
||||
onLoadProgress={handleLoadProgress}
|
||||
onLoadEnd={() => setShowProgress(false)}
|
||||
onError={handleError}
|
||||
onNavigationStateChange={handleNavigationStateChange}
|
||||
onMessage={handleMessage}
|
||||
onShouldStartLoadWithRequest={handleShouldStartLoad}
|
||||
onPermissionRequest={onPermissionRequest ? handlePermissionRequest : undefined}
|
||||
injectedJavaScript={injected}
|
||||
onOpenWindow={handleOpenWindow}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
allowFileAccess
|
||||
allowsInlineMediaPlayback
|
||||
sharedCookiesEnabled
|
||||
mediaPlaybackRequiresUserAction={false}
|
||||
setSupportMultipleWindows={false}
|
||||
webviewDebuggingEnabled={__DEV__}
|
||||
/>
|
||||
)}
|
||||
</ContentContainer>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
@ -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 (
|
||||
<Svg viewBox="0 0 1024 1024" width={size} height={size}>
|
||||
<Path
|
||||
d="M778.624 49.056L743.104 13.456 280.944 476.4v-0.016L245.376 512v0.016l35.568 35.632 462.16 462.896 35.52-35.616-462.128-462.912z"
|
||||
fill={color}
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<Svg viewBox="0 0 1024 1024" width={size} height={size}>
|
||||
<Path
|
||||
d="M896.7 172.6l-45.3-45.3a4 4 0 0 0-5.6 0L512 461.1 178.2 127.3a4 4 0 0 0-5.6 0l-45.3 45.3a4 4 0 0 0 0 5.6L461.1 512 127.3 845.8a4 4 0 0 0 0 5.6l45.3 45.3a4 4 0 0 0 5.6 0L512 562.9l333.8 333.8a4 4 0 0 0 5.6 0l45.3-45.3a4 4 0 0 0 0-5.6L562.9 512l333.8-333.8a4 4 0 0 0 0-5.6z"
|
||||
fill={color}
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<Svg viewBox="0 0 1024 1024" width={size} height={size}>
|
||||
<Path
|
||||
d="M512 298.6496a85.3504 85.3504 0 1 0 0-170.6496 85.3504 85.3504 0 0 0 0 170.6496z"
|
||||
fill={color}
|
||||
/>
|
||||
<Path
|
||||
d="M512 512m-85.3504 0a85.3504 85.3504 0 1 0 170.7008 0 85.3504 85.3504 0 1 0-170.7008 0Z"
|
||||
fill={color}
|
||||
/>
|
||||
<Path
|
||||
d="M512 896a85.3504 85.3504 0 1 0 0-170.7008 85.3504 85.3504 0 0 0 0 170.7008z"
|
||||
fill={color}
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
16
packages/xwebview/src/index.ts
普通文件
16
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'
|
||||
13
packages/xwebview/tsconfig.json
普通文件
13
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"]
|
||||
}
|
||||
19
src/index.ts
19
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'
|
||||
|
||||
@ -27,6 +27,14 @@ async function applyLoginSession(session: UnifiedLoginOptions): Promise<void> {
|
||||
}
|
||||
|
||||
async function login(options: UnifiedLoginOptions): Promise<void> {
|
||||
if (
|
||||
currentSession &&
|
||||
currentSession.userId === options.userId &&
|
||||
currentSession.userSig === options.userSig
|
||||
) {
|
||||
setCommonUserId(options.userId)
|
||||
return
|
||||
}
|
||||
if (currentSession) {
|
||||
await logout()
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户