diff --git a/App.tsx b/App.tsx index b7c1050..ced334c 100644 --- a/App.tsx +++ b/App.tsx @@ -10,9 +10,11 @@ import { View, } from 'react-native' import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context' -import {ImSDK, type ImEventListener, type ImMessage, type MsgType, UpdateSDK, XuqmSDK} from '@xuqm/rn-sdk' +import {ImSDK, type ImEventListener, type ImMessage, type MsgType, XuqmSDK} from '@xuqm/rn-sdk' import {MessageBubble} from './src/components/MessageBubble' import {DEMO_CONTENT, MessageComposer} from './src/components/MessageComposer' +import {GroupChatPanel} from './src/components/GroupChatPanel' +import {UpdatePanel} from './src/components/UpdatePanel' const APP_ID = 'ak_demo_chat' const MODULE_ID = 'chat-home' @@ -48,46 +50,39 @@ function App() { function DemoConsole() { const [currentUser, setCurrentUser] = useState(DEMO_USERS[0]) const [connected, setConnected] = useState(false) - const [messages, setMessages] = useState([]) + const [singleMessages, setSingleMessages] = useState([]) + const [groupMessages, setGroupMessages] = useState([]) const [sending, setSending] = useState(false) const [activityLog, setActivityLog] = useState([]) - const [appUpdateResult, setAppUpdateResult] = useState('点击"检查 App 更新"查看线上版本结果') - const [rnUpdateResult, setRnUpdateResult] = useState('点击"检查插件更新"查看热更新结果') - const [cachedBundleSummary, setCachedBundleSummary] = useState('暂无本地缓存') const activeUserRef = useRef(currentUser) const peerUser = DEMO_USERS.find(u => u.id !== currentUser.id) ?? DEMO_USERS[1] const appendLog = (msg: string) => { startTransition(() => { - setActivityLog(prev => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, 20)) + setActivityLog(prev => + [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, 30), + ) }) } - const mergeMessage = (message: ImMessage) => { + const mergeSingle = (message: ImMessage) => { startTransition(() => { - setMessages(prev => { - const exists = prev.some(item => item.id === message.id) - if (exists) return prev.map(item => item.id === message.id ? message : item) + setSingleMessages(prev => { + const exists = prev.some(m => m.id === message.id) + if (exists) return prev.map(m => m.id === message.id ? message : m) return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt)) }) }) } - const connectUser = async (user: DemoUser) => { - try { - ImSDK.disconnect() - setConnected(false) - setMessages([]) - await ImSDK.login(user.id, user.nickname) - appendLog(`已登录 IM:${user.nickname}`) - const history = await ImSDK.fetchHistory(peerUser.id, 0, 50) - history.slice().reverse().forEach(item => mergeMessage(item)) - setConnected(ImSDK.isConnected()) - } catch (error) { - const msg = error instanceof Error ? error.message : 'IM 登录失败' - appendLog(`连接失败:${msg}`) - Alert.alert('IM 登录失败', msg) - } + const mergeGroup = (message: ImMessage) => { + startTransition(() => { + setGroupMessages(prev => { + const exists = prev.some(m => m.id === message.id) + if (exists) return prev.map(m => m.id === message.id ? message : m) + return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt)) + }) + }) } useEffect(() => { @@ -115,13 +110,14 @@ function DemoConsole() { setConnected(false) appendLog(`WebSocket 断开:${reason || 'unknown'}`) }, - onMessage(message) { - mergeMessage(message) - appendLog(`收到消息 [${message.msgType}] from ${message.fromUserId}`) + onMessage(msg) { + if (msg.chatType === 'SINGLE') mergeSingle(msg) + else mergeGroup(msg) + appendLog(`收到 [${msg.chatType}/${msg.msgType}] from ${msg.fromUserId}`) }, - onGroupMessage(message) { - mergeMessage(message) - appendLog(`收到群消息 [${message.msgType}]`) + onGroupMessage(msg) { + mergeGroup(msg) + appendLog(`收到群消息 [${msg.msgType}]`) }, onError(error) { appendLog(`IM 错误:${error}`) @@ -139,14 +135,14 @@ function DemoConsole() { try { ImSDK.disconnect() setConnected(false) - setMessages([]) + setSingleMessages([]) await ImSDK.login(currentUser.id, currentUser.nickname) appendLog(`已登录 IM:${currentUser.nickname}`) const history = await ImSDK.fetchHistory(peerUser.id, 0, 50) - history.slice().reverse().forEach(item => mergeMessage(item)) + history.slice().reverse().forEach(m => mergeSingle(m)) setConnected(ImSDK.isConnected()) - } catch (error) { - const msg = error instanceof Error ? error.message : 'IM 登录失败' + } catch (e) { + const msg = e instanceof Error ? e.message : 'IM 登录失败' appendLog(`连接失败:${msg}`) Alert.alert('IM 登录失败', msg) } @@ -154,21 +150,14 @@ function DemoConsole() { run() }, [currentUser, peerUser.id]) - useEffect(() => { - UpdateSDK.getCachedRnBundle(MODULE_ID).then(bundle => { - if (!bundle) return - setCachedBundleSummary(`已缓存 ${bundle.version},md5=${bundle.md5}`) - }) - }, []) - - const handleSend = async (msgType: MsgType, content: string) => { + const handleSendSingle = async (msgType: MsgType, content: string) => { setSending(true) try { const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', msgType, content) - mergeMessage(sent) + mergeSingle(sent) appendLog(`已发送 [${msgType}] 给 ${peerUser.nickname}`) - } catch (error) { - const msg = error instanceof Error ? error.message : '发送失败' + } catch (e) { + const msg = e instanceof Error ? e.message : '发送失败' appendLog(`发送失败 [${msgType}]:${msg}`) Alert.alert('发送失败', msg) } finally { @@ -176,96 +165,52 @@ function DemoConsole() { } } - const handleSendAllTypes = async () => { + const handleSendAllSingle = async () => { setSending(true) - appendLog(`开始发送全部 ${ALL_DEMO_TYPES.length} 种消息类型…`) + appendLog(`开始发送全部 ${ALL_DEMO_TYPES.length} 种消息类型(单聊)…`) for (const type of ALL_DEMO_TYPES) { + const content = DEMO_CONTENT[type] + if (!content) continue try { - const content = DEMO_CONTENT[type] - if (!content) continue const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', type, content) - mergeMessage(sent) + mergeSingle(sent) appendLog(`✓ [${type}]`) await new Promise(resolve => setTimeout(resolve, 300)) - } catch (error) { - const msg = error instanceof Error ? error.message : 'error' - appendLog(`✗ [${type}]:${msg}`) + } catch (e) { + appendLog(`✗ [${type}]:${e instanceof Error ? e.message : 'error'}`) } } - appendLog('全类型演示发送完成') + appendLog('单聊全类型演示完成') setSending(false) } - const handleRevoke = async (messageId: string) => { + const handleRevokeSingle = async (messageId: string) => { try { const revoked = await ImSDK.revokeMessage(messageId) - mergeMessage(revoked) + mergeSingle(revoked) appendLog(`消息已撤回:${messageId}`) - } catch (error) { - const msg = error instanceof Error ? error.message : '撤回失败' - appendLog(`撤回失败:${msg}`) + } catch (e) { + const msg = e instanceof Error ? e.message : '撤回失败' Alert.alert('撤回失败', msg) } } - const handleReloadHistory = async () => { + const handleReloadSingle = async () => { try { const history = await ImSDK.fetchHistory(peerUser.id, 0, 50) - setMessages(history.slice().reverse()) + setSingleMessages(history.slice().reverse()) appendLog(`历史消息已刷新,共 ${history.length} 条`) - } catch (error) { - const msg = error instanceof Error ? error.message : '拉取失败' - appendLog(`历史拉取失败:${msg}`) - } - } - - const handleCheckAppUpdate = async () => { - try { - const result = await UpdateSDK.checkAppUpdate(CURRENT_APP_VERSION_CODE) - if (!result.needsUpdate) { - setAppUpdateResult('当前已是最新 App 版本') - appendLog('App 更新检查:当前最新') - return - } - setAppUpdateResult( - `发现版本 ${result.versionName}(code=${result.versionCode})\nforce=${String(result.forceUpdate)}\n${result.changeLog ?? ''}\n下载:${result.downloadUrl ?? '未提供'}`, - ) - appendLog(`发现 App 新版本:${result.versionName}`) - } catch (error) { - const msg = error instanceof Error ? error.message : 'App 更新检查失败' - setAppUpdateResult(msg) - appendLog(`App 更新失败:${msg}`) - } - } - - const handleCheckRnUpdate = async () => { - try { - const result = await UpdateSDK.checkRnUpdate(MODULE_ID, CURRENT_RN_VERSION) - if (!result.needsUpdate) { - setRnUpdateResult('当前已是最新插件版本') - appendLog('插件更新检查:当前最新') - return - } - const source = await UpdateSDK.downloadRnBundle(result.downloadUrl) - const cached = await UpdateSDK.cacheRnBundle(MODULE_ID, result.latestVersion, result.md5, source) - setRnUpdateResult( - `发现插件版本 ${result.latestVersion}\nmd5=${result.md5}\n说明:${result.note || '无'}\n已下载源码长度:${source.length}`, - ) - setCachedBundleSummary(`已缓存 ${cached.version},下载于 ${cached.downloadedAt}`) - appendLog(`插件更新已缓存:${cached.version}`) - } catch (error) { - const msg = error instanceof Error ? error.message : '插件更新检查失败' - setRnUpdateResult(msg) - appendLog(`插件更新失败:${msg}`) + } catch (e) { + appendLog(`历史拉取失败:${e instanceof Error ? e.message : 'error'}`) } } return ( XuqmGroup RN SDK Demo - IM · 全消息类型 · App更新 · 热更新 + IM · 全消息类型 · 群聊 · App 更新 · 热更新 - 连接 {API_BASE_URL},使用两个演示用户演示全部 IM 消息类型、App 版本检查和插件热更新。 + 连接 {API_BASE_URL},演示单聊/群聊全消息类型、整包更新和 RN 插件热更新完整流程。 {/* Section 1: 当前会话 */} @@ -285,72 +230,84 @@ function DemoConsole() { ) })} - 当前用户:{currentUser.nickname}({currentUser.id}) - 聊天对象:{peerUser.nickname}({peerUser.id}) + 当前用户:{currentUser.nickname}({currentUser.id}) + 单聊对象:{peerUser.nickname}({peerUser.id}) - {connected ? '已连接' : '连接中 / 未连接'} + {connected ? 'IM 已连接' : '连接中 / 未连接'} - - connectUser(currentUser)} /> + + { + ImSDK.disconnect() + setConnected(false) + setSingleMessages([]) + ImSDK.login(currentUser.id, currentUser.nickname) + .then(() => ImSDK.fetchHistory(peerUser.id, 0, 50)) + .then(h => { h.slice().reverse().forEach(m => mergeSingle(m)); setConnected(ImSDK.isConnected()) }) + .catch(e => appendLog(`重登录失败:${e instanceof Error ? e.message : 'error'}`)) + }} /> - {/* Section 2: 聊天演示 */} - - - 长按自己的消息可撤回。点击消息类型选择器切换,"全部类型演示"按钮依次发送所有 12 种类型。 - + {/* Section 2: 单聊 */} + + 长按自己的消息可撤回。"全部类型演示"依次发送所有 12 种消息类型。 - {messages.length === 0 ? ( - 还没有消息,发一条试试看。 + {singleMessages.length === 0 ? ( + 还没有消息,发一条试试看。 ) : ( - messages.map(message => ( + singleMessages.map(m => ( )) )} - {/* Section 3: App 更新 */} - - - 本地版本号:{CURRENT_APP_VERSION_CODE}。在服务端发布更高版本后点击检查即可看到结果。 + {/* Section 3: 群聊 */} + + + 点击"创建演示群"创建包含 Alice+Bob 的群组,"加载群列表"列出已有群,然后即可发送群消息。 - - {appUpdateResult} + - {/* Section 4: 插件热更新 */} - - - 模块:{MODULE_ID},本地版本:{CURRENT_RN_VERSION}。 - 热更新流程:检查 → 下载 → 缓存至 AsyncStorage。 - - - {rnUpdateResult} - 本地缓存:{cachedBundleSummary} + {/* Section 4: 更新演示 */} + + {/* Section 5: 活动日志 */} {activityLog.length === 0 ? ( - 暂无日志 + 暂无日志 ) : ( activityLog.map((item, i) => ( {item} @@ -370,7 +327,7 @@ function SectionCard({title, children}: {title: string; children: React.ReactNod ) } -function PrimaryButton({title, onPress, disabled}: {title: string; onPress: () => void; disabled?: boolean}) { +function PrimaryBtn({title, onPress, disabled}: {title: string; onPress: () => void; disabled?: boolean}) { return ( {title} @@ -378,7 +335,7 @@ function PrimaryButton({title, onPress, disabled}: {title: string; onPress: () = ) } -function GhostButton({title, onPress}: {title: string; onPress: () => void}) { +function GhostBtn({title, onPress}: {title: string; onPress: () => void}) { return ( {title} @@ -389,12 +346,12 @@ function GhostButton({title, onPress}: {title: string; onPress: () => void}) { const styles = StyleSheet.create({ safeArea: {flex: 1, backgroundColor: '#f5efe3'}, scroll: {flex: 1, backgroundColor: '#f5efe3'}, - content: {padding: 18, gap: 14, paddingBottom: 32}, + content: {padding: 18, gap: 14, paddingBottom: 40}, eyebrow: {fontSize: 13, fontWeight: '700', color: '#915f34', textTransform: 'uppercase', letterSpacing: 1.1}, - title: {fontSize: 28, lineHeight: 34, fontWeight: '800', color: '#1f2933'}, - subtitle: {fontSize: 14, lineHeight: 21, color: '#4b5563'}, + title: {fontSize: 26, lineHeight: 32, fontWeight: '800', color: '#1f2933'}, + subtitle: {fontSize: 13, lineHeight: 20, color: '#4b5563'}, card: {borderRadius: 24, padding: 16, backgroundColor: '#fffaf2', borderWidth: 1, borderColor: '#ead7b7', gap: 12}, - cardTitle: {fontSize: 18, fontWeight: '800', color: '#1f2933'}, + cardTitle: {fontSize: 17, fontWeight: '800', color: '#1f2933'}, hint: {fontSize: 13, lineHeight: 19, color: '#6b7280'}, userRow: {flexDirection: 'row', gap: 10}, userChip: {paddingHorizontal: 16, paddingVertical: 10, borderRadius: 999, backgroundColor: '#efe2c5'}, @@ -405,20 +362,17 @@ const styles = StyleSheet.create({ dot: {width: 8, height: 8, borderRadius: 4}, dotGreen: {backgroundColor: '#22c55e'}, dotGray: {backgroundColor: '#9ca3af'}, - metaText: {fontSize: 14, color: '#4b5563'}, + meta: {fontSize: 13, color: '#4b5563'}, row: {flexDirection: 'row', gap: 10}, - chatPanel: {maxHeight: 360, borderRadius: 18, backgroundColor: '#f4ecdf'}, + chatPanel: {maxHeight: 340, borderRadius: 18, backgroundColor: '#f4ecdf'}, chatContent: {padding: 12, gap: 6}, - emptyText: {fontSize: 14, color: '#7c7f85', textAlign: 'center', paddingVertical: 20}, - paragraph: {fontSize: 14, lineHeight: 21, color: '#4b5563'}, - primaryBtn: {minHeight: 46, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#1f2933', paddingHorizontal: 16}, - primaryBtnText: {color: '#fffdf8', fontSize: 15, fontWeight: '800'}, - ghostBtn: {minHeight: 46, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#efe2c5', paddingHorizontal: 16}, - ghostBtnText: {color: '#4b5563', fontSize: 15, fontWeight: '700'}, + empty: {fontSize: 14, color: '#7c7f85', textAlign: 'center', paddingVertical: 20}, + primaryBtn: {minHeight: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: '#1f2933', paddingHorizontal: 16}, + primaryBtnText: {color: '#fffdf8', fontSize: 14, fontWeight: '800'}, + ghostBtn: {minHeight: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: '#efe2c5', paddingHorizontal: 16}, + ghostBtnText: {color: '#4b5563', fontSize: 14, fontWeight: '700'}, btnDisabled: {opacity: 0.45}, - resultText: {fontSize: 14, lineHeight: 21, color: '#1f2933'}, - cacheText: {fontSize: 13, color: '#6b7280'}, - logLine: {fontSize: 12, lineHeight: 18, color: '#374151', fontFamily: 'monospace'}, + logLine: {fontSize: 11, lineHeight: 17, color: '#374151', fontFamily: 'monospace'}, }) export default App diff --git a/src/components/GroupChatPanel.tsx b/src/components/GroupChatPanel.tsx new file mode 100644 index 0000000..07a35c1 --- /dev/null +++ b/src/components/GroupChatPanel.tsx @@ -0,0 +1,269 @@ +import React, {useState} from 'react' +import {Alert, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native' +import {ImSDK, type ImGroup, type ImMessage, type MsgType} from '@xuqm/rn-sdk' +import {MessageBubble} from './MessageBubble' +import {DEMO_CONTENT, MessageComposer} from './MessageComposer' + +const ALL_DEMO_TYPES: MsgType[] = [ + 'TEXT', 'IMAGE', 'VIDEO', 'AUDIO', 'FILE', 'CUSTOM', + 'LOCATION', 'NOTIFY', 'RICH_TEXT', 'CALL_AUDIO', 'CALL_VIDEO', 'FORWARD', +] + +interface Props { + currentUserId: string + peerUserId: string + peerNickname: string + appId: string + onLog: (msg: string) => void + groupMessages: ImMessage[] + onMergeMessage: (msg: ImMessage) => void +} + +export function GroupChatPanel({ + currentUserId, + peerUserId, + peerNickname, + onLog, + groupMessages, + onMergeMessage, +}: Props) { + const [activeGroup, setActiveGroup] = useState(null) + const [groups, setGroups] = useState([]) + const [sending, setSending] = useState(false) + const [loadingGroups, setLoadingGroups] = useState(false) + + const handleCreateGroup = async () => { + try { + const group = await ImSDK.createGroup('RN Demo 演示群', [currentUserId, peerUserId]) + onLog(`创建群组:${group.name}(id=${group.id})`) + setActiveGroup(group) + setGroups(prev => [group, ...prev.filter(g => g.id !== group.id)]) + ImSDK.subscribeGroup(group.id) + const history = await ImSDK.fetchGroupHistory(group.id, 0, 50) + history.slice().reverse().forEach(m => onMergeMessage(m)) + } catch (e) { + const msg = e instanceof Error ? e.message : '创建群组失败' + onLog(`群组创建失败:${msg}`) + Alert.alert('创建群组失败', msg) + } + } + + const handleLoadGroups = async () => { + setLoadingGroups(true) + try { + const list = await ImSDK.listGroups() + setGroups(list) + onLog(`已加载 ${list.length} 个群组`) + if (list.length > 0 && !activeGroup) { + const first = list[0] + setActiveGroup(first) + ImSDK.subscribeGroup(first.id) + const history = await ImSDK.fetchGroupHistory(first.id, 0, 50) + history.slice().reverse().forEach(m => onMergeMessage(m)) + onLog(`加入群组:${first.name}`) + } + } catch (e) { + const msg = e instanceof Error ? e.message : '加载群组失败' + onLog(`加载群组失败:${msg}`) + } finally { + setLoadingGroups(false) + } + } + + const handleSwitchGroup = async (group: ImGroup) => { + setActiveGroup(group) + ImSDK.subscribeGroup(group.id) + try { + const history = await ImSDK.fetchGroupHistory(group.id, 0, 50) + history.slice().reverse().forEach(m => onMergeMessage(m)) + onLog(`切换群组:${group.name}`) + } catch (e) { + onLog(`历史拉取失败:${e instanceof Error ? e.message : 'error'}`) + } + } + + const handleSend = async (msgType: MsgType, content: string) => { + if (!activeGroup) { + Alert.alert('提示', '请先创建或选择一个群组') + return + } + setSending(true) + try { + const sent = await ImSDK.sendMessage(activeGroup.id, 'GROUP', msgType, content) + onMergeMessage(sent) + onLog(`群发 [${msgType}] → ${activeGroup.name}`) + } catch (e) { + const msg = e instanceof Error ? e.message : '发送失败' + onLog(`群发失败 [${msgType}]:${msg}`) + Alert.alert('发送失败', msg) + } finally { + setSending(false) + } + } + + const handleSendAllTypes = async () => { + if (!activeGroup) { + Alert.alert('提示', '请先创建或选择一个群组') + return + } + setSending(true) + onLog(`开始群发全部 ${ALL_DEMO_TYPES.length} 种消息类型…`) + for (const type of ALL_DEMO_TYPES) { + const content = DEMO_CONTENT[type] + if (!content) continue + try { + const sent = await ImSDK.sendMessage(activeGroup.id, 'GROUP', type, content) + onMergeMessage(sent) + onLog(`✓ 群发 [${type}]`) + await new Promise(resolve => setTimeout(resolve, 300)) + } catch (e) { + onLog(`✗ 群发 [${type}]:${e instanceof Error ? e.message : 'error'}`) + } + } + onLog('群发全类型演示完成') + setSending(false) + } + + const handleRevoke = async (messageId: string) => { + try { + const revoked = await ImSDK.revokeMessage(messageId) + onMergeMessage(revoked) + onLog(`群消息已撤回:${messageId}`) + } catch (e) { + const msg = e instanceof Error ? e.message : '撤回失败' + Alert.alert('撤回失败', msg) + } + } + + const groupMessages_ = activeGroup + ? groupMessages.filter(m => m.toId === activeGroup.id) + : [] + + const parsedMembers = activeGroup ? (() => { + try { return JSON.parse(activeGroup.memberIds) as string[] } catch { return [] } + })() : [] + + return ( + + {/* 群组管理 */} + + + + 创建演示群 + + + {loadingGroups ? '加载中…' : '加载群列表'} + + + + {/* 群列表 */} + {groups.length > 0 && ( + + {groups.map(g => ( + handleSwitchGroup(g)} + style={[styles.groupChip, activeGroup?.id === g.id && styles.groupChipActive]}> + + {g.name} + + + ))} + + )} + + {/* 当前群信息 */} + {activeGroup ? ( + + 📋 {activeGroup.name} + + 群 ID:{activeGroup.id.slice(0, 8)}… + {' · '}成员 {parsedMembers.length} 人 + + + ) : ( + 创建或加载群组后,即可在此发送群消息。 + )} + + {/* 群消息列表 */} + + {groupMessages_.length === 0 ? ( + + {activeGroup ? '还没有群消息,发一条试试看。' : '暂无群组'} + + ) : ( + groupMessages_.map(m => ( + + )) + )} + + + {/* 发送区 */} + + + ) +} + +const styles = StyleSheet.create({ + container: {gap: 10}, + groupBar: {flexDirection: 'row', gap: 10}, + btnCreate: { + flex: 1, + minHeight: 42, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#1f2933', + }, + btnCreateText: {color: '#fffdf8', fontSize: 14, fontWeight: '700'}, + btnLoad: { + flex: 1, + minHeight: 42, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#efe2c5', + }, + btnLoadText: {color: '#4b5563', fontSize: 14, fontWeight: '700'}, + btnDisabled: {opacity: 0.5}, + groupList: {gap: 8, paddingVertical: 2}, + groupChip: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 999, + backgroundColor: '#efe2c5', + }, + groupChipActive: {backgroundColor: '#915f34'}, + groupChipText: {fontSize: 13, fontWeight: '700', color: '#4b5563'}, + groupChipTextActive: {color: '#fff'}, + groupInfo: { + backgroundColor: '#f0e8d8', + borderRadius: 14, + padding: 10, + gap: 3, + }, + groupInfoName: {fontSize: 14, fontWeight: '700', color: '#1f2933'}, + groupInfoMeta: {fontSize: 12, color: '#6b7280'}, + noGroupHint: {fontSize: 13, color: '#9ca3af', textAlign: 'center', paddingVertical: 8}, + chatPanel: {maxHeight: 320, borderRadius: 18, backgroundColor: '#f4ecdf'}, + chatContent: {padding: 12, gap: 6}, + emptyText: {fontSize: 14, color: '#7c7f85', textAlign: 'center', paddingVertical: 20}, +}) diff --git a/src/components/UpdatePanel.tsx b/src/components/UpdatePanel.tsx new file mode 100644 index 0000000..0b13df6 --- /dev/null +++ b/src/components/UpdatePanel.tsx @@ -0,0 +1,320 @@ +import React, {useState} from 'react' +import {Linking, Platform, Pressable, StyleSheet, Text, View} from 'react-native' +import {UpdateSDK} from '@xuqm/rn-sdk' + +interface Props { + appVersionCode: number + rnModuleId: string + rnCurrentVersion: string + onLog: (msg: string) => void +} + +type StepStatus = 'idle' | 'loading' | 'ok' | 'error' + +interface CheckResult { + needsUpdate: boolean + label: string + detail: string +} + +export function UpdatePanel({appVersionCode, rnModuleId, rnCurrentVersion, onLog}: Props) { + const [appStep, setAppStep] = useState('idle') + const [rnCheckStep, setRnCheckStep] = useState('idle') + const [rnDownloadStep, setRnDownloadStep] = useState('idle') + const [rnCacheStep, setRnCacheStep] = useState('idle') + + const [appResult, setAppResult] = useState(null) + const [rnResult, setRnResult] = useState(null) + const [rnDownloadInfo, setRnDownloadInfo] = useState('') + const [rnCacheInfo, setRnCacheInfo] = useState('') + + const reset = () => { + setAppStep('idle') + setRnCheckStep('idle') + setRnDownloadStep('idle') + setRnCacheStep('idle') + setAppResult(null) + setRnResult(null) + setRnDownloadInfo('') + setRnCacheInfo('') + } + + const runAppUpdate = async () => { + reset() + setAppStep('loading') + try { + const res = await UpdateSDK.checkAppUpdate(appVersionCode) + if (!res.needsUpdate) { + setAppResult({ + needsUpdate: false, + label: '✓ App 已是最新版本', + detail: `当前版本码:${appVersionCode},服务端无更高版本`, + }) + setAppStep('ok') + onLog('App 更新检查:当前最新') + return + } + setAppResult({ + needsUpdate: true, + label: `↑ 发现新版本 ${res.versionName ?? ''}(code=${res.versionCode ?? ''})`, + detail: [ + res.changeLog ? `更新说明:${res.changeLog}` : '', + `强制更新:${res.forceUpdate ? '是' : '否'}`, + res.downloadUrl ? `下载:${res.downloadUrl}` : '', + res.appStoreUrl ? `App Store:${res.appStoreUrl}` : '', + ].filter(Boolean).join('\n'), + }) + setAppStep('ok') + onLog(`发现 App 新版本:${res.versionName}`) + } catch (e) { + const msg = e instanceof Error ? e.message : 'App 更新检查失败' + setAppResult({needsUpdate: false, label: '✗ 检查失败', detail: msg}) + setAppStep('error') + onLog(`App 更新失败:${msg}`) + } + } + + const runRnUpdate = async () => { + setRnCheckStep('loading') + setRnDownloadStep('idle') + setRnCacheStep('idle') + setRnResult(null) + setRnDownloadInfo('') + setRnCacheInfo('') + + let checkInfo: Awaited> + try { + checkInfo = await UpdateSDK.checkRnUpdate(rnModuleId, rnCurrentVersion) + if (!checkInfo.needsUpdate) { + setRnResult({ + needsUpdate: false, + label: '✓ 插件已是最新版本', + detail: `当前版本:${rnCurrentVersion},服务端无更高版本`, + }) + setRnCheckStep('ok') + onLog('插件更新检查:当前最新') + return + } + setRnResult({ + needsUpdate: true, + label: `↑ 发现插件版本 ${checkInfo.latestVersion}(当前 ${rnCurrentVersion})`, + detail: [ + `模块:${rnModuleId}`, + `平台:${Platform.OS}`, + `md5:${checkInfo.md5}`, + checkInfo.note ? `说明:${checkInfo.note}` : '', + `minCommonVersion:${checkInfo.minCommonVersion}`, + ].filter(Boolean).join('\n'), + }) + setRnCheckStep('ok') + onLog(`发现插件新版本:${checkInfo.latestVersion}`) + } catch (e) { + const msg = e instanceof Error ? e.message : '检查失败' + setRnResult({needsUpdate: false, label: '✗ 检查失败', detail: msg}) + setRnCheckStep('error') + onLog(`插件更新检查失败:${msg}`) + return + } + + // Step 2: Download + setRnDownloadStep('loading') + let source: string + try { + source = await UpdateSDK.downloadRnBundle(checkInfo.downloadUrl) + setRnDownloadInfo(`下载完成,源码长度:${source.length} 字符`) + setRnDownloadStep('ok') + onLog(`插件 bundle 下载完成:${source.length} chars`) + } catch (e) { + const msg = e instanceof Error ? e.message : '下载失败' + setRnDownloadInfo(`✗ 下载失败:${msg}`) + setRnDownloadStep('error') + onLog(`插件下载失败:${msg}`) + return + } + + // Step 3: Cache + setRnCacheStep('loading') + try { + const cached = await UpdateSDK.cacheRnBundle( + rnModuleId, + checkInfo.latestVersion, + checkInfo.md5, + source, + ) + setRnCacheInfo( + `已写入 AsyncStorage\n版本:${cached.version}\nmd5:${cached.md5}\n时间:${cached.downloadedAt}`, + ) + setRnCacheStep('ok') + onLog(`插件 bundle 已缓存:${cached.version}`) + } catch (e) { + const msg = e instanceof Error ? e.message : '缓存失败' + setRnCacheInfo(`✗ 缓存失败:${msg}`) + setRnCacheStep('error') + onLog(`插件缓存失败:${msg}`) + } + } + + const handleOpenDownload = () => { + if (appResult?.detail) { + const match = appResult.detail.match(/下载:(.+)/) + const url = match?.[1]?.trim() + if (url) Linking.openURL(url) + } + } + + return ( + + {/* App 整包更新 */} + + App 整包更新(APK / IPA) + + Android 返回 APK 下载链接,iOS 返回 App Store 跳转。 + 本地版本码写死为 {appVersionCode}。 + + + {appResult && ( + + )} + {appResult?.needsUpdate && appStep === 'ok' && ( + + 打开下载链接 ↗ + + )} + + + {/* RN 插件热更新 */} + + RN 插件热更新(JS Bundle) + + 模块 {rnModuleId},本地版本 {rnCurrentVersion}。 + 热更新全流程:检查 → 下载 → 缓存至 AsyncStorage。 + + + + {rnResult && ( + + )} + + {rnCheckStep === 'ok' && rnResult?.needsUpdate && ( + <> + + {rnDownloadInfo ? ( + + ) : null} + + + {rnCacheInfo ? ( + + ) : null} + + )} + + + ) +} + +function StepRow({ + step, + label, + status, + onPress, + buttonText, +}: { + step: number + label: string + status: StepStatus + onPress?: () => void + buttonText?: string +}) { + const icon = status === 'idle' ? '○' : status === 'loading' ? '⟳' : status === 'ok' ? '✓' : '✗' + const iconColor = status === 'ok' ? '#22c55e' : status === 'error' ? '#ef4444' : status === 'loading' ? '#f59e0b' : '#9ca3af' + + return ( + + {icon} + + + 步骤 {step}: + {label} + + + {onPress && buttonText && ( + + + {status === 'loading' ? '检查中…' : buttonText} + + + )} + + ) +} + +function ResultBox({label, detail, ok}: {label: string; detail: string; ok: boolean}) { + return ( + + {label} + {detail ? {detail} : null} + + ) +} + +const styles = StyleSheet.create({ + container: {gap: 16}, + block: {gap: 10}, + blockTitle: {fontSize: 15, fontWeight: '800', color: '#1f2933'}, + blockHint: {fontSize: 13, lineHeight: 19, color: '#6b7280'}, + mono: {fontFamily: 'monospace', fontSize: 12}, + stepRow: {flexDirection: 'row', alignItems: 'center', gap: 8}, + stepIcon: {fontSize: 18, width: 24, textAlign: 'center'}, + stepBody: {flex: 1}, + stepLabel: {fontSize: 13, color: '#374151'}, + stepNum: {fontWeight: '700', color: '#1f2933'}, + stepBtn: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 12, + backgroundColor: '#1f2933', + }, + stepBtnText: {color: '#fff', fontSize: 12, fontWeight: '700'}, + btnDisabled: {opacity: 0.5}, + resultBox: { + borderRadius: 12, + padding: 10, + gap: 4, + }, + resultBoxOk: {backgroundColor: '#f0fdf4', borderWidth: 1, borderColor: '#86efac'}, + resultBoxInfo: {backgroundColor: '#fff7ed', borderWidth: 1, borderColor: '#fdba74'}, + resultLabel: {fontSize: 13, fontWeight: '600', color: '#1f2933'}, + resultDetail: {fontSize: 12, color: '#4b5563', lineHeight: 18, fontFamily: 'monospace'}, + openBtn: { + minHeight: 40, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#2563eb', + }, + openBtnText: {color: '#fff', fontSize: 13, fontWeight: '700'}, +})