- 新增 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 <noreply@anthropic.com>
425 行
16 KiB
TypeScript
425 行
16 KiB
TypeScript
import React, {startTransition, useEffect, useRef, useState} from 'react'
|
||
import {
|
||
Alert,
|
||
Pressable,
|
||
ScrollView,
|
||
StatusBar,
|
||
StyleSheet,
|
||
Text,
|
||
useColorScheme,
|
||
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 {MessageBubble} from './src/components/MessageBubble'
|
||
import {DEMO_CONTENT, MessageComposer} from './src/components/MessageComposer'
|
||
|
||
const APP_ID = 'ak_demo_chat'
|
||
const MODULE_ID = 'chat-home'
|
||
const CURRENT_APP_VERSION_CODE = 1
|
||
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}
|
||
|
||
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 (
|
||
<SafeAreaProvider>
|
||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
||
<SafeAreaView style={styles.safeArea}>
|
||
<DemoConsole />
|
||
</SafeAreaView>
|
||
</SafeAreaProvider>
|
||
)
|
||
}
|
||
|
||
function DemoConsole() {
|
||
const [currentUser, setCurrentUser] = useState<DemoUser>(DEMO_USERS[0])
|
||
const [connected, setConnected] = useState(false)
|
||
const [messages, setMessages] = useState<ImMessage[]>([])
|
||
const [sending, setSending] = useState(false)
|
||
const [activityLog, setActivityLog] = useState<string[]>([])
|
||
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))
|
||
})
|
||
}
|
||
|
||
const mergeMessage = (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)
|
||
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)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
XuqmSDK.init({
|
||
appId: APP_ID,
|
||
appKey: APP_ID,
|
||
appSecret: 'demo-secret-not-used-by-current-services',
|
||
apiBaseUrl: API_BASE_URL,
|
||
imWsUrl: IM_WS_URL,
|
||
debug: true,
|
||
})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
activeUserRef.current = currentUser
|
||
}, [currentUser])
|
||
|
||
useEffect(() => {
|
||
const listener: ImEventListener = {
|
||
onConnected() {
|
||
setConnected(true)
|
||
appendLog(`WebSocket 已连接:${activeUserRef.current.nickname}`)
|
||
},
|
||
onDisconnected(reason) {
|
||
setConnected(false)
|
||
appendLog(`WebSocket 断开:${reason || 'unknown'}`)
|
||
},
|
||
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)
|
||
ImSDK.disconnect()
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const run = async () => {
|
||
try {
|
||
ImSDK.disconnect()
|
||
setConnected(false)
|
||
setMessages([])
|
||
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))
|
||
setConnected(ImSDK.isConnected())
|
||
} catch (error) {
|
||
const msg = error instanceof Error ? error.message : 'IM 登录失败'
|
||
appendLog(`连接失败:${msg}`)
|
||
Alert.alert('IM 登录失败', msg)
|
||
}
|
||
}
|
||
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) => {
|
||
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 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<void>(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 revoked = await ImSDK.revokeMessage(messageId)
|
||
mergeMessage(revoked)
|
||
appendLog(`消息已撤回:${messageId}`)
|
||
} catch (error) {
|
||
const msg = error instanceof Error ? error.message : '撤回失败'
|
||
appendLog(`撤回失败:${msg}`)
|
||
Alert.alert('撤回失败', msg)
|
||
}
|
||
}
|
||
|
||
const handleReloadHistory = async () => {
|
||
try {
|
||
const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
|
||
setMessages(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}`)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||
<Text style={styles.eyebrow}>XuqmGroup RN SDK Demo</Text>
|
||
<Text style={styles.title}>IM · 全消息类型 · App更新 · 热更新</Text>
|
||
<Text style={styles.subtitle}>
|
||
连接 {API_BASE_URL},使用两个演示用户演示全部 IM 消息类型、App 版本检查和插件热更新。
|
||
</Text>
|
||
|
||
{/* Section 1: 当前会话 */}
|
||
<SectionCard title="1. 当前会话">
|
||
<View style={styles.userRow}>
|
||
{DEMO_USERS.map(user => {
|
||
const active = user.id === currentUser.id
|
||
return (
|
||
<Pressable
|
||
key={user.id}
|
||
onPress={() => setCurrentUser(user)}
|
||
style={[styles.userChip, active && styles.userChipActive]}>
|
||
<Text style={[styles.userChipText, active && styles.userChipTextActive]}>
|
||
{user.nickname}
|
||
</Text>
|
||
</Pressable>
|
||
)
|
||
})}
|
||
</View>
|
||
<Text style={styles.metaText}>当前用户:{currentUser.nickname}({currentUser.id})</Text>
|
||
<Text style={styles.metaText}>聊天对象:{peerUser.nickname}({peerUser.id})</Text>
|
||
<View style={styles.statusRow}>
|
||
<View style={[styles.dot, connected ? styles.dotGreen : styles.dotGray]} />
|
||
<Text style={styles.metaText}>{connected ? '已连接' : '连接中 / 未连接'}</Text>
|
||
</View>
|
||
<View style={styles.row}>
|
||
<PrimaryButton title="刷新历史" onPress={handleReloadHistory} />
|
||
<GhostButton title="重新登录" onPress={() => connectUser(currentUser)} />
|
||
</View>
|
||
</SectionCard>
|
||
|
||
{/* Section 2: 聊天演示 */}
|
||
<SectionCard title="2. 聊天演示(全消息类型)">
|
||
<Text style={styles.hint}>
|
||
长按自己的消息可撤回。点击消息类型选择器切换,"全部类型演示"按钮依次发送所有 12 种类型。
|
||
</Text>
|
||
<ScrollView
|
||
style={styles.chatPanel}
|
||
contentContainerStyle={styles.chatContent}
|
||
showsVerticalScrollIndicator={false}>
|
||
{messages.length === 0 ? (
|
||
<Text style={styles.emptyText}>还没有消息,发一条试试看。</Text>
|
||
) : (
|
||
messages.map(message => (
|
||
<MessageBubble
|
||
key={message.id}
|
||
message={message}
|
||
isOwn={message.fromUserId === currentUser.id}
|
||
peerNickname={peerUser.nickname}
|
||
onRevoke={handleRevoke}
|
||
/>
|
||
))
|
||
)}
|
||
</ScrollView>
|
||
<MessageComposer
|
||
onSend={handleSend}
|
||
onSendAll={handleSendAllTypes}
|
||
disabled={sending || !connected}
|
||
/>
|
||
</SectionCard>
|
||
|
||
{/* Section 3: App 更新 */}
|
||
<SectionCard title="3. App 更新演示">
|
||
<Text style={styles.paragraph}>
|
||
本地版本号:{CURRENT_APP_VERSION_CODE}。在服务端发布更高版本后点击检查即可看到结果。
|
||
</Text>
|
||
<PrimaryButton title="检查 App 更新" onPress={handleCheckAppUpdate} />
|
||
<Text style={styles.resultText}>{appUpdateResult}</Text>
|
||
</SectionCard>
|
||
|
||
{/* Section 4: 插件热更新 */}
|
||
<SectionCard title="4. 插件热更新演示">
|
||
<Text style={styles.paragraph}>
|
||
模块:{MODULE_ID},本地版本:{CURRENT_RN_VERSION}。
|
||
热更新流程:检查 → 下载 → 缓存至 AsyncStorage。
|
||
</Text>
|
||
<PrimaryButton title="检查插件更新并缓存" onPress={handleCheckRnUpdate} />
|
||
<Text style={styles.resultText}>{rnUpdateResult}</Text>
|
||
<Text style={styles.cacheText}>本地缓存:{cachedBundleSummary}</Text>
|
||
</SectionCard>
|
||
|
||
{/* Section 5: 活动日志 */}
|
||
<SectionCard title="5. 活动日志">
|
||
{activityLog.length === 0 ? (
|
||
<Text style={styles.emptyText}>暂无日志</Text>
|
||
) : (
|
||
activityLog.map((item, i) => (
|
||
<Text key={i} style={styles.logLine}>{item}</Text>
|
||
))
|
||
)}
|
||
</SectionCard>
|
||
</ScrollView>
|
||
)
|
||
}
|
||
|
||
function SectionCard({title, children}: {title: string; children: React.ReactNode}) {
|
||
return (
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>{title}</Text>
|
||
{children}
|
||
</View>
|
||
)
|
||
}
|
||
|
||
function PrimaryButton({title, onPress, disabled}: {title: string; onPress: () => void; disabled?: boolean}) {
|
||
return (
|
||
<Pressable onPress={onPress} disabled={disabled} style={[styles.primaryBtn, disabled && styles.btnDisabled]}>
|
||
<Text style={styles.primaryBtnText}>{title}</Text>
|
||
</Pressable>
|
||
)
|
||
}
|
||
|
||
function GhostButton({title, onPress}: {title: string; onPress: () => void}) {
|
||
return (
|
||
<Pressable onPress={onPress} style={styles.ghostBtn}>
|
||
<Text style={styles.ghostBtnText}>{title}</Text>
|
||
</Pressable>
|
||
)
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
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
|