From 85c23b34a4c08aad529364621b6548bc2b83f261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8B=A4=E6=B0=91?= Date: Fri, 24 Apr 2026 11:03:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat-demo):=20=E6=89=A9=E5=B1=95=E4=B8=BA?= =?UTF-8?q?=E5=85=A8=E6=B6=88=E6=81=AF=E7=B1=BB=E5=9E=8B=E6=BC=94=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=2012=20=E7=A7=8D=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=8F=91=E9=80=81=E4=B8=8E=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MessageBubble 组件,按 msgType 智能渲染:TEXT/IMAGE/VIDEO/AUDIO/ FILE/CUSTOM/LOCATION/NOTIFY/RICH_TEXT/CALL_AUDIO/CALL_VIDEO/FORWARD/REVOKED - 新增 MessageComposer 组件:消息类型选择器 + 类型专属演示内容 + 一键发全类型 - App.tsx 集成撤回功能(长按弹框 → revokeMessage API → 气泡就地更新) - 活动日志增加时间戳和收发方向标注 - 更新 docs/README.md,补充消息类型表格和演示步骤 Co-Authored-By: Claude Sonnet 4.6 --- App.tsx | 456 +++++++++++------------------ docs/README.md | 132 +++++++-- src/components/MessageBubble.tsx | 356 ++++++++++++++++++++++ src/components/MessageComposer.tsx | 274 +++++++++++++++++ 4 files changed, 902 insertions(+), 316 deletions(-) create mode 100644 src/components/MessageBubble.tsx create mode 100644 src/components/MessageComposer.tsx diff --git a/App.tsx b/App.tsx index 6571ff2..b7c1050 100644 --- a/App.tsx +++ b/App.tsx @@ -1,4 +1,4 @@ -import React, {startTransition, useEffect, useRef, useState} from 'react'; +import React, {startTransition, useEffect, useRef, useState} from 'react' import { Alert, Pressable, @@ -6,12 +6,13 @@ import { StatusBar, StyleSheet, Text, - TextInput, useColorScheme, View, -} from 'react-native'; -import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'; -import {ImSDK, type ImEventListener, type ImMessage, UpdateSDK, XuqmSDK} from '@xuqm/rn-sdk'; +} 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 {MessageBubble} from './src/components/MessageBubble' +import {DEMO_CONTENT, MessageComposer} from './src/components/MessageComposer' const APP_ID = 'ak_demo_chat' const MODULE_ID = 'chat-home' @@ -20,19 +21,20 @@ const CURRENT_RN_VERSION = '1.0.0' const API_BASE_URL = 'https://sentry.xuqinmin.com' const IM_WS_URL = 'wss://sentry.xuqinmin.com/ws/im' -type DemoUser = { - id: string - nickname: string -} +type DemoUser = {id: string; nickname: string} const DEMO_USERS: DemoUser[] = [ {id: 'demo_alice', nickname: 'Alice'}, {id: 'demo_bob', nickname: 'Bob'}, ] +const ALL_DEMO_TYPES: MsgType[] = [ + 'TEXT', 'IMAGE', 'VIDEO', 'AUDIO', 'FILE', 'CUSTOM', + 'LOCATION', 'NOTIFY', 'RICH_TEXT', 'CALL_AUDIO', 'CALL_VIDEO', 'FORWARD', +] + function App() { const isDarkMode = useColorScheme() === 'dark' - return ( @@ -47,17 +49,17 @@ function DemoConsole() { const [currentUser, setCurrentUser] = useState(DEMO_USERS[0]) const [connected, setConnected] = useState(false) const [messages, setMessages] = useState([]) - const [draft, setDraft] = useState('你好,我是 RN Demo。') + const [sending, setSending] = useState(false) const [activityLog, setActivityLog] = useState([]) - const [appUpdateResult, setAppUpdateResult] = useState('点击“检查 App 更新”查看线上版本结果') - const [rnUpdateResult, setRnUpdateResult] = useState('点击“检查插件更新”查看热更新结果') + const [appUpdateResult, setAppUpdateResult] = useState('点击"检查 App 更新"查看线上版本结果') + const [rnUpdateResult, setRnUpdateResult] = useState('点击"检查插件更新"查看热更新结果') const [cachedBundleSummary, setCachedBundleSummary] = useState('暂无本地缓存') const activeUserRef = useRef(currentUser) - const peerUser = DEMO_USERS.find(user => user.id !== currentUser.id) ?? DEMO_USERS[1] + const peerUser = DEMO_USERS.find(u => u.id !== currentUser.id) ?? DEMO_USERS[1] - const appendLog = (message: string) => { + const appendLog = (msg: string) => { startTransition(() => { - setActivityLog(prev => [message, ...prev].slice(0, 12)) + setActivityLog(prev => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, 20)) }) } @@ -65,9 +67,7 @@ function DemoConsole() { startTransition(() => { setMessages(prev => { const exists = prev.some(item => item.id === message.id) - if (exists) { - return prev.map(item => (item.id === message.id ? message : item)) - } + if (exists) return prev.map(item => item.id === message.id ? message : item) return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt)) }) }) @@ -81,15 +81,12 @@ function DemoConsole() { 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)) + history.slice().reverse().forEach(item => mergeMessage(item)) setConnected(ImSDK.isConnected()) } catch (error) { - const message = error instanceof Error ? error.message : 'IM 登录失败' - appendLog(`连接失败:${message}`) - Alert.alert('IM 登录失败', message) + const msg = error instanceof Error ? error.message : 'IM 登录失败' + appendLog(`连接失败:${msg}`) + Alert.alert('IM 登录失败', msg) } } @@ -106,7 +103,7 @@ function DemoConsole() { useEffect(() => { activeUserRef.current = currentUser - }, [currentUser, peerUser.id]) + }, [currentUser]) useEffect(() => { const listener: ImEventListener = { @@ -120,15 +117,16 @@ function DemoConsole() { }, onMessage(message) { mergeMessage(message) + appendLog(`收到消息 [${message.msgType}] from ${message.fromUserId}`) }, onGroupMessage(message) { mergeMessage(message) + appendLog(`收到群消息 [${message.msgType}]`) }, onError(error) { appendLog(`IM 错误:${error}`) }, } - ImSDK.addListener(listener) return () => { ImSDK.removeListener(listener) @@ -137,7 +135,7 @@ function DemoConsole() { }, []) useEffect(() => { - async function run() { + const run = async () => { try { ImSDK.disconnect() setConnected(false) @@ -145,18 +143,14 @@ function DemoConsole() { 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(item => mergeMessage(item)) setConnected(ImSDK.isConnected()) } catch (error) { - const message = error instanceof Error ? error.message : 'IM 登录失败' - appendLog(`连接失败:${message}`) - Alert.alert('IM 登录失败', message) + const msg = error instanceof Error ? error.message : 'IM 登录失败' + appendLog(`连接失败:${msg}`) + Alert.alert('IM 登录失败', msg) } } - run() }, [currentUser, peerUser.id]) @@ -167,25 +161,50 @@ function DemoConsole() { }) }, []) - const handleSwitchUser = (user: DemoUser) => { - setCurrentUser(user) + const handleSend = async (msgType: MsgType, content: string) => { + setSending(true) + try { + const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', msgType, content) + mergeMessage(sent) + appendLog(`已发送 [${msgType}] 给 ${peerUser.nickname}`) + } catch (error) { + const msg = error instanceof Error ? error.message : '发送失败' + appendLog(`发送失败 [${msgType}]:${msg}`) + Alert.alert('发送失败', msg) + } finally { + setSending(false) + } } - const handleSend = async () => { - const content = draft.trim() - if (!content) { - return + const handleSendAllTypes = async () => { + setSending(true) + appendLog(`开始发送全部 ${ALL_DEMO_TYPES.length} 种消息类型…`) + for (const type of ALL_DEMO_TYPES) { + try { + const content = DEMO_CONTENT[type] + if (!content) continue + const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', type, content) + mergeMessage(sent) + appendLog(`✓ [${type}]`) + await new Promise(resolve => setTimeout(resolve, 300)) + } catch (error) { + const msg = error instanceof Error ? error.message : 'error' + appendLog(`✗ [${type}]:${msg}`) + } } + appendLog('全类型演示发送完成') + setSending(false) + } + const handleRevoke = async (messageId: string) => { try { - const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', 'TEXT', content) - mergeMessage(sent) - setDraft('') - appendLog(`已发送给 ${peerUser.nickname}`) + const revoked = await ImSDK.revokeMessage(messageId) + mergeMessage(revoked) + appendLog(`消息已撤回:${messageId}`) } catch (error) { - const message = error instanceof Error ? error.message : '消息发送失败' - appendLog(`发送失败:${message}`) - Alert.alert('发送失败', message) + const msg = error instanceof Error ? error.message : '撤回失败' + appendLog(`撤回失败:${msg}`) + Alert.alert('撤回失败', msg) } } @@ -195,8 +214,8 @@ function DemoConsole() { setMessages(history.slice().reverse()) appendLog(`历史消息已刷新,共 ${history.length} 条`) } catch (error) { - const message = error instanceof Error ? error.message : '历史消息加载失败' - appendLog(`历史拉取失败:${message}`) + const msg = error instanceof Error ? error.message : '拉取失败' + appendLog(`历史拉取失败:${msg}`) } } @@ -209,13 +228,13 @@ function DemoConsole() { return } setAppUpdateResult( - `发现版本 ${result.versionName}(code=${result.versionCode})\nforce=${String(result.forceUpdate)}\n下载地址:${result.downloadUrl || '未提供'}`, + `发现版本 ${result.versionName}(code=${result.versionCode})\nforce=${String(result.forceUpdate)}\n${result.changeLog ?? ''}\n下载:${result.downloadUrl ?? '未提供'}`, ) appendLog(`发现 App 新版本:${result.versionName}`) } catch (error) { - const message = error instanceof Error ? error.message : 'App 更新检查失败' - setAppUpdateResult(message) - appendLog(`App 更新失败:${message}`) + const msg = error instanceof Error ? error.message : 'App 更新检查失败' + setAppUpdateResult(msg) + appendLog(`App 更新失败:${msg}`) } } @@ -227,7 +246,6 @@ function DemoConsole() { appendLog('插件更新检查:当前最新') return } - const source = await UpdateSDK.downloadRnBundle(result.downloadUrl) const cached = await UpdateSDK.cacheRnBundle(MODULE_ID, result.latestVersion, result.md5, source) setRnUpdateResult( @@ -236,30 +254,29 @@ function DemoConsole() { setCachedBundleSummary(`已缓存 ${cached.version},下载于 ${cached.downloadedAt}`) appendLog(`插件更新已缓存:${cached.version}`) } catch (error) { - const message = error instanceof Error ? error.message : '插件更新检查失败' - setRnUpdateResult(message) - appendLog(`插件更新失败:${message}`) + const msg = error instanceof Error ? error.message : '插件更新检查失败' + setRnUpdateResult(msg) + appendLog(`插件更新失败:${msg}`) } } return ( - - XuqmGroup RN SDK Demo - 聊天、App 更新、插件热更新一屏演示 - - 这个项目直接连接 {API_BASE_URL},默认使用两个演示用户互聊。 + + XuqmGroup RN SDK Demo + IM · 全消息类型 · App更新 · 热更新 + + 连接 {API_BASE_URL},使用两个演示用户演示全部 IM 消息类型、App 版本检查和插件热更新。 + {/* Section 1: 当前会话 */} - + {DEMO_USERS.map(user => { const active = user.id === currentUser.id return ( { - handleSwitchUser(user) - }} + onPress={() => setCurrentUser(user)} style={[styles.userChip, active && styles.userChipActive]}> {user.nickname} @@ -270,80 +287,73 @@ function DemoConsole() { 当前用户:{currentUser.nickname}({currentUser.id}) 聊天对象:{peerUser.nickname}({peerUser.id}) - 连接状态:{connected ? '已连接' : '连接中 / 未连接'} - - { - handleReloadHistory() - }} /> - { - connectUser(currentUser) - }} /> + + + {connected ? '已连接' : '连接中 / 未连接'} + + + + connectUser(currentUser)} /> - - + {/* Section 2: 聊天演示 */} + + + 长按自己的消息可撤回。点击消息类型选择器切换,"全部类型演示"按钮依次发送所有 12 种类型。 + + {messages.length === 0 ? ( 还没有消息,发一条试试看。 ) : ( - messages.map(message => { - const mine = message.fromUserId === currentUser.id - return ( - - - - {mine ? '我' : peerUser.nickname} · {message.msgType} - - {message.content} - - - ) - }) + messages.map(message => ( + + )) )} - - + - { - handleSend() - }} /> + {/* Section 3: App 更新 */} - 当前本地版本号写死为 {CURRENT_APP_VERSION_CODE},方便你在服务端发布更高版本后立即观察效果。 + 本地版本号:{CURRENT_APP_VERSION_CODE}。在服务端发布更高版本后点击检查即可看到结果。 - { - handleCheckAppUpdate() - }} /> + {appUpdateResult} + {/* Section 4: 插件热更新 */} - 当前模块:{MODULE_ID},本地插件版本:{CURRENT_RN_VERSION}。 + 模块:{MODULE_ID},本地版本:{CURRENT_RN_VERSION}。 + 热更新流程:检查 → 下载 → 缓存至 AsyncStorage。 - { - handleCheckRnUpdate() - }} /> + {rnUpdateResult} - {cachedBundleSummary} + 本地缓存:{cachedBundleSummary} + {/* Section 5: 活动日志 */} {activityLog.length === 0 ? ( 暂无日志 ) : ( - activityLog.map(item => ( - - {item} - + activityLog.map((item, i) => ( + {item} )) )} @@ -360,193 +370,55 @@ function SectionCard({title, children}: {title: string; children: React.ReactNod ) } -function PrimaryButton({title, onPress}: {title: string; onPress: () => void}) { +function PrimaryButton({title, onPress, disabled}: {title: string; onPress: () => void; disabled?: boolean}) { return ( - - {title} + + {title} ) } function GhostButton({title, onPress}: {title: string; onPress: () => void}) { return ( - - {title} + + {title} ) } const styles = StyleSheet.create({ - safeArea: { - flex: 1, - backgroundColor: '#f5efe3', - }, - container: { - flex: 1, - backgroundColor: '#f5efe3', - }, - content: { - padding: 18, - gap: 14, - }, - heroEyebrow: { - fontSize: 13, - fontWeight: '700', - color: '#915f34', - textTransform: 'uppercase', - letterSpacing: 1.1, - }, - heroTitle: { - fontSize: 30, - lineHeight: 36, - fontWeight: '800', - color: '#1f2933', - }, - heroSubtitle: { - fontSize: 15, - lineHeight: 22, - color: '#4b5563', - }, - card: { - borderRadius: 24, - padding: 16, - backgroundColor: '#fffaf2', - borderWidth: 1, - borderColor: '#ead7b7', - gap: 12, - }, - cardTitle: { - fontSize: 19, - fontWeight: '800', - color: '#1f2933', - }, - userSwitcher: { - flexDirection: 'row', - gap: 10, - }, - userChip: { - paddingHorizontal: 14, - paddingVertical: 10, - borderRadius: 999, - backgroundColor: '#efe2c5', - }, - userChipActive: { - backgroundColor: '#1f2933', - }, - userChipText: { - color: '#4b5563', - fontWeight: '700', - }, - userChipTextActive: { - color: '#fffdf8', - }, - metaText: { - fontSize: 14, - color: '#4b5563', - }, - inlineActions: { - flexDirection: 'row', - gap: 10, - }, - chatPanel: { - maxHeight: 340, - borderRadius: 18, - backgroundColor: '#f4ecdf', - padding: 12, - gap: 8, - }, - bubbleRow: { - flexDirection: 'row', - }, - bubbleRowMine: { - justifyContent: 'flex-end', - }, - bubbleRowPeer: { - justifyContent: 'flex-start', - }, - bubble: { - maxWidth: '85%', - borderRadius: 18, - paddingHorizontal: 12, - paddingVertical: 10, - gap: 4, - }, - bubbleMine: { - backgroundColor: '#cae7d8', - }, - bubblePeer: { - backgroundColor: '#ffffff', - borderWidth: 1, - borderColor: '#ddd3c2', - }, - bubbleMeta: { - fontSize: 11, - color: '#6b7280', - fontWeight: '700', - }, - bubbleText: { - fontSize: 15, - lineHeight: 21, - color: '#111827', - }, - input: { - minHeight: 48, - borderRadius: 16, - borderWidth: 1, - borderColor: '#dac8a8', - backgroundColor: '#fff', - paddingHorizontal: 14, - color: '#111827', - }, - paragraph: { - fontSize: 14, - lineHeight: 21, - color: '#4b5563', - }, - primaryButton: { - minHeight: 46, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#1f2933', - paddingHorizontal: 16, - }, - primaryButtonText: { - color: '#fffdf8', - fontSize: 15, - fontWeight: '800', - }, - ghostButton: { - minHeight: 46, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#efe2c5', - paddingHorizontal: 16, - }, - ghostButtonText: { - color: '#4b5563', - fontSize: 15, - fontWeight: '800', - }, - resultText: { - fontSize: 14, - lineHeight: 21, - color: '#1f2933', - }, - cacheText: { - fontSize: 13, - color: '#6b7280', - }, - emptyText: { - fontSize: 14, - color: '#7c7f85', - }, - logLine: { - fontSize: 13, - lineHeight: 20, - color: '#374151', - }, + safeArea: {flex: 1, backgroundColor: '#f5efe3'}, + scroll: {flex: 1, backgroundColor: '#f5efe3'}, + content: {padding: 18, gap: 14, paddingBottom: 32}, + 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'}, + card: {borderRadius: 24, padding: 16, backgroundColor: '#fffaf2', borderWidth: 1, borderColor: '#ead7b7', gap: 12}, + cardTitle: {fontSize: 18, 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'}, + userChipActive: {backgroundColor: '#1f2933'}, + userChipText: {color: '#4b5563', fontWeight: '700'}, + userChipTextActive: {color: '#fffdf8'}, + statusRow: {flexDirection: 'row', alignItems: 'center', gap: 6}, + dot: {width: 8, height: 8, borderRadius: 4}, + dotGreen: {backgroundColor: '#22c55e'}, + dotGray: {backgroundColor: '#9ca3af'}, + metaText: {fontSize: 14, color: '#4b5563'}, + row: {flexDirection: 'row', gap: 10}, + chatPanel: {maxHeight: 360, 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'}, + 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'}, }) export default App diff --git a/docs/README.md b/docs/README.md index cb20421..455fbb2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,49 +1,133 @@ # XuqmGroup RN Chat Demo -这个演示项目用于验证三条能力: +> 文档版本:V2.0 · 更新日期:2026-04-24 -1. `@xuqm/rn-sdk` 的单聊消息发送、历史拉取、实时接收 -2. `update-service` 的 App 版本检查 -3. `update-service` 的 RN 插件热更新检查、下载与本地缓存 +这个演示项目用于验证 `@xuqm/rn-sdk` 的三条核心能力: -## 当前默认配置 +1. **IM 聊天**:单聊消息发送与接收,覆盖全部 12 种消息类型,支持消息撤回 +2. **App 整包更新**:`update-service` 版本检查 +3. **RN 插件热更新**:热更新包检查、下载与本地缓存 -- 域名:`https://sentry.xuqinmin.com` -- IM WebSocket:`wss://sentry.xuqinmin.com/ws/im` -- 演示 AppId:`ak_demo_chat` -- 演示用户:`demo_alice`、`demo_bob` -- 演示模块:`chat-home` +--- + +## 当前配置 + +| 配置项 | 值 | +|--------|-----| +| API 域名 | `https://sentry.xuqinmin.com` | +| IM WebSocket | `wss://sentry.xuqinmin.com/ws/im` | +| 演示 AppId | `ak_demo_chat` | +| 演示用户 | `demo_alice`、`demo_bob` | +| 演示模块 | `chat-home` | +| 本地 App 版本号 | `1`(写死,便于触发更新) | +| 本地插件版本 | `1.0.0`(写死,便于触发热更新) | + +--- + +## 项目结构 + +``` +XuqmGroup-RNChatDemo/ +├── App.tsx # 主入口,包含全部 Demo 逻辑 +├── src/ +│ └── components/ +│ ├── MessageBubble.tsx # 消息气泡,支持所有消息类型渲染 +│ └── MessageComposer.tsx # 消息类型选择器 + 内容编辑区 +├── demo-assets/ # 演示用 APK 和 RN Bundle +└── scripts/ + └── publish-demo-assets.sh # 一键发布演示版本数据到服务端 +``` + +--- + +## 支持的消息类型 + +| 消息类型 | 说明 | 演示内容 | +|----------|------|----------| +| `TEXT` | 纯文本 | 普通文字 | +| `IMAGE` | 图片 | URL + 尺寸信息,picsum 示例图 | +| `VIDEO` | 视频 | URL + 时长 + 文件大小 | +| `AUDIO` | 语音 | URL + 时长 | +| `FILE` | 文件 | URL + 文件名 + 大小 | +| `CUSTOM` | 自定义 | 自定义卡片(任意 JSON) | +| `LOCATION` | 位置 | 经纬度 + 地址(天安门广场) | +| `NOTIFY` | 系统通知 | 标题 + 内容 | +| `RICH_TEXT` | 富文本 | HTML 内容 | +| `CALL_AUDIO` | 语音通话信令 | callId + 通话动作 | +| `CALL_VIDEO` | 视频通话信令 | callId + 通话动作 | +| `FORWARD` | 转发消息 | 原消息 ID + 原始内容 | +| `REVOKED` | 已撤回 | 服务端下发,气泡显示"已撤回" | + +非文本消息的 `content` 字段均为 JSON 字符串。 + +--- ## 运行 ```bash cd XuqmGroup-RNChatDemo npm install -npm run start -npm run android +npm run start # Metro bundler +npm run android # Android 真机/模拟器 +npm run ios # iOS 模拟器 ``` -## 发布演示更新数据 +--- + +## 演示步骤 + +### IM 聊天演示 + +1. App 启动后自动以 `Alice` 登录并拉取与 `Bob` 的历史消息 +2. 在消息类型选择器中点击不同类型(TEXT / IMAGE / VIDEO 等) +3. 内容区会自动填入该类型的演示 JSON,可自行修改后发送 +4. 点击 **"全部类型演示"** 按钮,将依次自动发送所有 12 种消息类型 +5. 长按自己发送的消息可撤回(调用 `revokeMessage` API) +6. 切换到 `Bob` 用户,可看到来自 Alice 的历史消息(对端视角) + +### App 更新演示 + +1. 点击 **"检查 App 更新"** +2. 服务端若有 versionCode > 1 的版本即触发更新提示 +3. 运行脚本 `scripts/publish-demo-assets.sh` 可发布 v1.0.1 演示版本 + +### 插件热更新演示 + +1. 点击 **"检查插件更新并缓存"** +2. 若服务端 `chat-home` 模块存在 > `1.0.0` 的 bundle,将自动下载并缓存至 `AsyncStorage` +3. 运行脚本 `scripts/publish-demo-assets.sh` 可发布演示 bundle + +--- + +## 发布演示数据 ```bash cd XuqmGroup-RNChatDemo ./scripts/publish-demo-assets.sh ``` -脚本会发布两类演示数据: +脚本发布内容: -1. `appId=ak_demo_chat` 的 Android App 版本 `1.0.1` +1. `appId=ak_demo_chat` 的 Android App 版本 `1.0.1`(versionCode=2) 2. `moduleId=chat-home` 的 Android RN bundle 版本 `1.0.1` -## 演示步骤 +--- -1. 打开 App,默认会以 `Alice` 登录 IM 并拉取与 `Bob` 的历史消息 -2. 输入内容并发送,观察消息列表和日志区 -3. 点击顶部切换到 `Bob`,可以看到同一条会话历史 -4. 点击“检查 App 更新”,验证版本管理接口 -5. 点击“检查插件更新并缓存”,验证热更新查询、下载和本地缓存 +## 消息渲染说明 -## 说明 +`MessageBubble.tsx` 根据 `msgType` 字段自动选择渲染方式: -当前插件更新演示的是“检查 + 下载 + 缓存”链路,缓存内容保存在 `AsyncStorage`。 -如果后续要做真正的运行时热替换,可以在此基础上再接原生 bundle loader。 +- **文本类**(TEXT、RICH_TEXT):直接显示文本内容 +- **媒体类**(IMAGE、VIDEO、AUDIO、FILE):显示图标 + 关键信息(尺寸/时长/大小) +- **结构类**(LOCATION、NOTIFY、CUSTOM):展示解析后的关键字段 +- **通话类**(CALL_AUDIO、CALL_VIDEO):显示通话类型 + 动作状态 +- **转发消息**(FORWARD):引用样式展示原始内容 +- **已撤回**(REVOKED / status=REVOKED):灰色气泡 + "此消息已被撤回" + +--- + +## 注意事项 + +- 消息撤回后,服务端返回 `status=REVOKED` 的消息,Demo 会就地更新气泡 +- 热更新演示的是"检查 + 下载 + 缓存"链路,缓存内容保存在 AsyncStorage +- 生产场景如需真正运行时热替换,需额外对接原生 bundle loader diff --git a/src/components/MessageBubble.tsx b/src/components/MessageBubble.tsx new file mode 100644 index 0000000..14e78ad --- /dev/null +++ b/src/components/MessageBubble.tsx @@ -0,0 +1,356 @@ +import React from 'react' +import {Alert, Pressable, StyleSheet, Text, View} from 'react-native' +import type {ImMessage} from '@xuqm/rn-sdk' + +interface Props { + message: ImMessage + isOwn: boolean + peerNickname: string + onRevoke?: (messageId: string) => void +} + +function parseContent(content: string): Record { + try { + return JSON.parse(content) as Record + } catch { + return {} + } +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / 1024 / 1024).toFixed(1)}MB` +} + +function formatSeconds(s: number): string { + const m = Math.floor(s / 60) + const sec = s % 60 + return `${m}:${String(sec).padStart(2, '0')}` +} + +function MessageContent({message}: {message: ImMessage}) { + const {msgType, content, status} = message + + if (status === 'REVOKED' || msgType === 'REVOKED') { + return 此消息已被撤回 + } + + switch (msgType) { + case 'TEXT': + return {content} + + case 'RICH_TEXT': { + const data = parseContent(content) + const html = typeof data.html === 'string' ? data.html : content + const preview = html.replace(/<[^>]+>/g, '').slice(0, 80) + return ( + + 📄 + + 富文本消息 + {preview} + + + ) + } + + case 'IMAGE': { + const data = parseContent(content) + const w = typeof data.width === 'number' ? data.width : '?' + const h = typeof data.height === 'number' ? data.height : '?' + return ( + + 🖼️ + + 图片 {w}×{h} + {typeof data.url === 'string' && ( + {data.url as string} + )} + + + ) + } + + case 'VIDEO': { + const data = parseContent(content) + const duration = typeof data.duration === 'number' ? formatSeconds(data.duration as number) : '?' + const size = typeof data.size === 'number' ? formatBytes(data.size as number) : '' + return ( + + 🎬 + + 视频 {duration}{size ? ` · ${size}` : ''} + {typeof data.url === 'string' && ( + {data.url as string} + )} + + + ) + } + + case 'AUDIO': { + const data = parseContent(content) + const duration = typeof data.duration === 'number' ? formatSeconds(data.duration as number) : '?' + const size = typeof data.size === 'number' ? formatBytes(data.size as number) : '' + return ( + + 🎵 + + 语音消息 {duration}{size ? ` · ${size}` : ''} + + + ) + } + + case 'FILE': { + const data = parseContent(content) + const name = typeof data.name === 'string' ? data.name : '文件' + const size = typeof data.size === 'number' ? formatBytes(data.size as number) : '' + return ( + + 📎 + + {name} + {size ? {size} : null} + + + ) + } + + case 'LOCATION': { + const data = parseContent(content) + const title = typeof data.title === 'string' ? data.title : '位置' + const address = typeof data.address === 'string' ? data.address : '' + const lat = typeof data.lat === 'number' ? (data.lat as number).toFixed(4) : '' + const lng = typeof data.lng === 'number' ? (data.lng as number).toFixed(4) : '' + return ( + + 📍 + + {title} + {address ? {address} : null} + {lat && lng ? {lat}°N, {lng}°E : null} + + + ) + } + + case 'NOTIFY': { + const data = parseContent(content) + const title = typeof data.title === 'string' ? data.title : '系统通知' + const body = typeof data.content === 'string' ? data.content : '' + return ( + + 🔔 {title} + {body ? {body} : null} + + ) + } + + case 'CUSTOM': { + const data = parseContent(content) + const title = typeof data.title === 'string' ? data.title : '自定义消息' + const desc = typeof data.desc === 'string' ? data.desc : '' + return ( + + ⚙️ {title} + {desc ? {desc} : null} + {content} + + ) + } + + case 'CALL_AUDIO': + case 'CALL_VIDEO': { + const data = parseContent(content) + const action = typeof data.action === 'string' ? data.action : 'invite' + const actionLabel: Record = { + invite: '邀请通话', + accept: '已接听', + reject: '已拒绝', + end: '通话结束', + missed: '未接听', + } + const icon = msgType === 'CALL_VIDEO' ? '📹' : '📞' + const label = msgType === 'CALL_VIDEO' ? '视频通话' : '语音通话' + return ( + + {icon} + + {label} + {actionLabel[action] ?? action} + + + ) + } + + case 'FORWARD': { + const data = parseContent(content) + const originalContent = typeof data.originalContent === 'string' ? data.originalContent : content + const originalSender = typeof data.originalSender === 'string' ? data.originalSender : '' + return ( + + ↪ 转发消息{originalSender ? ` · ${originalSender}` : ''} + {originalContent} + + ) + } + + default: + return {content} + } +} + +export function MessageBubble({message, isOwn, peerNickname, onRevoke}: Props) { + const canRevoke = isOwn && message.status !== 'REVOKED' && message.msgType !== 'REVOKED' + + const handleLongPress = () => { + if (!canRevoke) return + Alert.alert('操作', '撤回这条消息?', [ + {text: '取消', style: 'cancel'}, + { + text: '撤回', + style: 'destructive', + onPress: () => onRevoke?.(message.id), + }, + ]) + } + + const senderLabel = isOwn ? '我' : peerNickname + const isRevoked = message.status === 'REVOKED' || message.msgType === 'REVOKED' + + return ( + + + + {senderLabel} · {message.msgType} + {message.status === 'READ' ? ' · 已读' : message.status === 'DELIVERED' ? ' · 已送达' : ''} + + + + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + marginBottom: 4, + }, + rowOwn: { + justifyContent: 'flex-end', + }, + rowPeer: { + justifyContent: 'flex-start', + }, + bubble: { + maxWidth: '85%', + borderRadius: 18, + paddingHorizontal: 12, + paddingVertical: 10, + gap: 4, + }, + bubbleOwn: { + backgroundColor: '#cae7d8', + }, + bubblePeer: { + backgroundColor: '#ffffff', + borderWidth: 1, + borderColor: '#ddd3c2', + }, + bubbleRevoked: { + backgroundColor: '#f0f0f0', + borderWidth: 1, + borderColor: '#e0e0e0', + }, + metaLine: { + fontSize: 11, + color: '#6b7280', + fontWeight: '700', + }, + textContent: { + fontSize: 15, + lineHeight: 21, + color: '#111827', + }, + revokedText: { + fontSize: 14, + color: '#9ca3af', + fontStyle: 'italic', + }, + mediaRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 8, + }, + mediaIcon: { + fontSize: 22, + lineHeight: 28, + }, + mediaInfo: { + flex: 1, + gap: 2, + }, + mediaLabel: { + fontSize: 14, + fontWeight: '600', + color: '#111827', + }, + mediaDesc: { + fontSize: 12, + color: '#6b7280', + }, + mediaUrl: { + fontSize: 11, + color: '#9ca3af', + }, + notifyBox: { + gap: 4, + paddingVertical: 2, + }, + notifyTitle: { + fontSize: 14, + fontWeight: '700', + color: '#1d4ed8', + }, + notifyBody: { + fontSize: 13, + color: '#374151', + }, + customBox: { + gap: 4, + paddingVertical: 2, + }, + customTitle: { + fontSize: 14, + fontWeight: '700', + color: '#7c3aed', + }, + customDesc: { + fontSize: 13, + color: '#374151', + }, + customRaw: { + fontSize: 10, + color: '#9ca3af', + fontFamily: 'monospace', + }, + forwardBox: { + borderLeftWidth: 3, + borderLeftColor: '#d1d5db', + paddingLeft: 8, + gap: 4, + }, + forwardLabel: { + fontSize: 12, + fontWeight: '700', + color: '#6b7280', + }, + forwardContent: { + fontSize: 13, + color: '#374151', + }, +}) diff --git a/src/components/MessageComposer.tsx b/src/components/MessageComposer.tsx new file mode 100644 index 0000000..7623e9a --- /dev/null +++ b/src/components/MessageComposer.tsx @@ -0,0 +1,274 @@ +import React, {useState} from 'react' +import {Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native' +import type {MsgType} from '@xuqm/rn-sdk' + +const ALL_MSG_TYPES: MsgType[] = [ + 'TEXT', + 'IMAGE', + 'VIDEO', + 'AUDIO', + 'FILE', + 'CUSTOM', + 'LOCATION', + 'NOTIFY', + 'RICH_TEXT', + 'CALL_AUDIO', + 'CALL_VIDEO', + 'FORWARD', +] + +const TYPE_LABELS: Record = { + TEXT: '文本', + IMAGE: '图片', + VIDEO: '视频', + AUDIO: '语音', + FILE: '文件', + CUSTOM: '自定义', + LOCATION: '位置', + NOTIFY: '通知', + RICH_TEXT: '富文本', + CALL_AUDIO: '语音通话', + CALL_VIDEO: '视频通话', + FORWARD: '转发', + REVOKED: '已撤回', +} + +const TYPE_ICONS: Record = { + TEXT: '💬', + IMAGE: '🖼️', + VIDEO: '🎬', + AUDIO: '🎵', + FILE: '📎', + CUSTOM: '⚙️', + LOCATION: '📍', + NOTIFY: '🔔', + RICH_TEXT: '📄', + CALL_AUDIO: '📞', + CALL_VIDEO: '📹', + FORWARD: '↪️', + REVOKED: '🚫', +} + +export const DEMO_CONTENT: Record = { + TEXT: '你好,这是一条文本消息!', + IMAGE: JSON.stringify({ + url: 'https://picsum.photos/400/300', + width: 400, + height: 300, + thumbnailUrl: 'https://picsum.photos/200/150', + }), + VIDEO: JSON.stringify({ + url: 'https://example.com/demo.mp4', + duration: 15, + thumbnailUrl: 'https://picsum.photos/320/240', + size: 2048000, + }), + AUDIO: JSON.stringify({ + url: 'https://example.com/demo.mp3', + duration: 8, + size: 128000, + }), + FILE: JSON.stringify({ + url: 'https://example.com/doc.pdf', + name: '项目文档.pdf', + size: 204800, + mimeType: 'application/pdf', + }), + CUSTOM: JSON.stringify({ + type: 'card', + title: '自定义卡片消息', + desc: '这是自定义消息内容,支持任意 JSON 结构。', + actionUrl: 'https://xuqinmin.com', + }), + LOCATION: JSON.stringify({ + lat: 39.9042, + lng: 116.4074, + address: '北京市东城区天安门广场', + title: '天安门广场', + }), + NOTIFY: JSON.stringify({ + title: '系统通知', + content: '您有一条新的系统消息,请及时查阅。', + level: 'info', + }), + RICH_TEXT: JSON.stringify({ + html: '

富文本消息

这是一段 加粗 内容,支持 斜体链接

', + }), + CALL_AUDIO: JSON.stringify({ + callId: 'ca_demo_001', + action: 'invite', + callerName: 'Alice', + }), + CALL_VIDEO: JSON.stringify({ + callId: 'cv_demo_001', + action: 'invite', + callerName: 'Alice', + }), + FORWARD: JSON.stringify({ + originalMsgId: 'msg_original_001', + originalContent: '这是被转发的原始消息内容', + originalSender: 'demo_alice', + originalTime: new Date().toISOString(), + }), + REVOKED: '', +} + +interface Props { + onSend: (type: MsgType, content: string) => void + onSendAll: () => void + disabled?: boolean +} + +export function MessageComposer({onSend, onSendAll, disabled}: Props) { + const [selectedType, setSelectedType] = useState('TEXT') + const [content, setContent] = useState(DEMO_CONTENT['TEXT']) + + const handleSelectType = (type: MsgType) => { + setSelectedType(type) + setContent(DEMO_CONTENT[type]) + } + + const handleSend = () => { + if (!content.trim()) return + onSend(selectedType, content.trim()) + } + + const isMultiline = selectedType !== 'TEXT' + + return ( + + 消息类型 + + {ALL_MSG_TYPES.map(type => ( + handleSelectType(type)} + style={[styles.typeChip, selectedType === type && styles.typeChipActive]}> + {TYPE_ICONS[type]} + + {TYPE_LABELS[type]} + + + ))} + + + + + + + 发送 {TYPE_ICONS[selectedType]} + + + 全部类型演示 + + + + ) +} + +const styles = StyleSheet.create({ + container: { + gap: 10, + }, + label: { + fontSize: 13, + fontWeight: '700', + color: '#6b7280', + textTransform: 'uppercase', + letterSpacing: 0.8, + }, + typeList: { + gap: 8, + paddingVertical: 2, + }, + typeChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 10, + paddingVertical: 7, + borderRadius: 999, + backgroundColor: '#efe2c5', + }, + typeChipActive: { + backgroundColor: '#1f2933', + }, + typeChipIcon: { + fontSize: 14, + }, + typeChipText: { + fontSize: 12, + fontWeight: '700', + color: '#4b5563', + }, + typeChipTextActive: { + color: '#fffdf8', + }, + input: { + minHeight: 48, + borderRadius: 16, + borderWidth: 1, + borderColor: '#dac8a8', + backgroundColor: '#fff', + paddingHorizontal: 14, + paddingVertical: 12, + color: '#111827', + fontSize: 13, + fontFamily: 'monospace', + }, + inputMultiline: { + minHeight: 96, + textAlignVertical: 'top', + }, + actions: { + flexDirection: 'row', + gap: 10, + }, + sendBtn: { + flex: 1, + minHeight: 46, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#1f2933', + paddingHorizontal: 16, + }, + sendBtnText: { + color: '#fffdf8', + fontSize: 15, + fontWeight: '800', + }, + allBtn: { + minHeight: 46, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#915f34', + paddingHorizontal: 16, + }, + allBtnText: { + color: '#fffdf8', + fontSize: 14, + fontWeight: '700', + }, + btnDisabled: { + opacity: 0.5, + }, +})