feat(chat-demo): 扩展为全消息类型演示,支持 12 种类型发送与渲染
- 新增 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>
这个提交包含在:
父节点
bc512a17cb
当前提交
85c23b34a4
456
App.tsx
456
App.tsx
@ -1,4 +1,4 @@
|
|||||||
import React, {startTransition, useEffect, useRef, useState} from 'react';
|
import React, {startTransition, useEffect, useRef, useState} from 'react'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Pressable,
|
Pressable,
|
||||||
@ -6,12 +6,13 @@ import {
|
|||||||
StatusBar,
|
StatusBar,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
useColorScheme,
|
useColorScheme,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native'
|
||||||
import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context';
|
import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'
|
||||||
import {ImSDK, type ImEventListener, type ImMessage, UpdateSDK, XuqmSDK} from '@xuqm/rn-sdk';
|
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 APP_ID = 'ak_demo_chat'
|
||||||
const MODULE_ID = 'chat-home'
|
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 API_BASE_URL = 'https://sentry.xuqinmin.com'
|
||||||
const IM_WS_URL = 'wss://sentry.xuqinmin.com/ws/im'
|
const IM_WS_URL = 'wss://sentry.xuqinmin.com/ws/im'
|
||||||
|
|
||||||
type DemoUser = {
|
type DemoUser = {id: string; nickname: string}
|
||||||
id: string
|
|
||||||
nickname: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEMO_USERS: DemoUser[] = [
|
const DEMO_USERS: DemoUser[] = [
|
||||||
{id: 'demo_alice', nickname: 'Alice'},
|
{id: 'demo_alice', nickname: 'Alice'},
|
||||||
{id: 'demo_bob', nickname: 'Bob'},
|
{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() {
|
function App() {
|
||||||
const isDarkMode = useColorScheme() === 'dark'
|
const isDarkMode = useColorScheme() === 'dark'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
||||||
@ -47,17 +49,17 @@ function DemoConsole() {
|
|||||||
const [currentUser, setCurrentUser] = useState<DemoUser>(DEMO_USERS[0])
|
const [currentUser, setCurrentUser] = useState<DemoUser>(DEMO_USERS[0])
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
const [messages, setMessages] = useState<ImMessage[]>([])
|
const [messages, setMessages] = useState<ImMessage[]>([])
|
||||||
const [draft, setDraft] = useState('你好,我是 RN Demo。')
|
const [sending, setSending] = useState(false)
|
||||||
const [activityLog, setActivityLog] = useState<string[]>([])
|
const [activityLog, setActivityLog] = useState<string[]>([])
|
||||||
const [appUpdateResult, setAppUpdateResult] = useState('点击“检查 App 更新”查看线上版本结果')
|
const [appUpdateResult, setAppUpdateResult] = useState('点击"检查 App 更新"查看线上版本结果')
|
||||||
const [rnUpdateResult, setRnUpdateResult] = useState('点击“检查插件更新”查看热更新结果')
|
const [rnUpdateResult, setRnUpdateResult] = useState('点击"检查插件更新"查看热更新结果')
|
||||||
const [cachedBundleSummary, setCachedBundleSummary] = useState('暂无本地缓存')
|
const [cachedBundleSummary, setCachedBundleSummary] = useState('暂无本地缓存')
|
||||||
const activeUserRef = useRef(currentUser)
|
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(() => {
|
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(() => {
|
startTransition(() => {
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const exists = prev.some(item => item.id === message.id)
|
const exists = prev.some(item => item.id === message.id)
|
||||||
if (exists) {
|
if (exists) return prev.map(item => item.id === message.id ? message : item)
|
||||||
return prev.map(item => (item.id === message.id ? message : item))
|
|
||||||
}
|
|
||||||
return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -81,15 +81,12 @@ function DemoConsole() {
|
|||||||
await ImSDK.login(user.id, user.nickname)
|
await ImSDK.login(user.id, user.nickname)
|
||||||
appendLog(`已登录 IM:${user.nickname}`)
|
appendLog(`已登录 IM:${user.nickname}`)
|
||||||
const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
|
const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
|
||||||
history
|
history.slice().reverse().forEach(item => mergeMessage(item))
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.forEach(item => mergeMessage(item))
|
|
||||||
setConnected(ImSDK.isConnected())
|
setConnected(ImSDK.isConnected())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'IM 登录失败'
|
const msg = error instanceof Error ? error.message : 'IM 登录失败'
|
||||||
appendLog(`连接失败:${message}`)
|
appendLog(`连接失败:${msg}`)
|
||||||
Alert.alert('IM 登录失败', message)
|
Alert.alert('IM 登录失败', msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +103,7 @@ function DemoConsole() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeUserRef.current = currentUser
|
activeUserRef.current = currentUser
|
||||||
}, [currentUser, peerUser.id])
|
}, [currentUser])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener: ImEventListener = {
|
const listener: ImEventListener = {
|
||||||
@ -120,15 +117,16 @@ function DemoConsole() {
|
|||||||
},
|
},
|
||||||
onMessage(message) {
|
onMessage(message) {
|
||||||
mergeMessage(message)
|
mergeMessage(message)
|
||||||
|
appendLog(`收到消息 [${message.msgType}] from ${message.fromUserId}`)
|
||||||
},
|
},
|
||||||
onGroupMessage(message) {
|
onGroupMessage(message) {
|
||||||
mergeMessage(message)
|
mergeMessage(message)
|
||||||
|
appendLog(`收到群消息 [${message.msgType}]`)
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
appendLog(`IM 错误:${error}`)
|
appendLog(`IM 错误:${error}`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ImSDK.addListener(listener)
|
ImSDK.addListener(listener)
|
||||||
return () => {
|
return () => {
|
||||||
ImSDK.removeListener(listener)
|
ImSDK.removeListener(listener)
|
||||||
@ -137,7 +135,7 @@ function DemoConsole() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function run() {
|
const run = async () => {
|
||||||
try {
|
try {
|
||||||
ImSDK.disconnect()
|
ImSDK.disconnect()
|
||||||
setConnected(false)
|
setConnected(false)
|
||||||
@ -145,18 +143,14 @@ function DemoConsole() {
|
|||||||
await ImSDK.login(currentUser.id, currentUser.nickname)
|
await ImSDK.login(currentUser.id, currentUser.nickname)
|
||||||
appendLog(`已登录 IM:${currentUser.nickname}`)
|
appendLog(`已登录 IM:${currentUser.nickname}`)
|
||||||
const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
|
const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
|
||||||
history
|
history.slice().reverse().forEach(item => mergeMessage(item))
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.forEach(item => mergeMessage(item))
|
|
||||||
setConnected(ImSDK.isConnected())
|
setConnected(ImSDK.isConnected())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'IM 登录失败'
|
const msg = error instanceof Error ? error.message : 'IM 登录失败'
|
||||||
appendLog(`连接失败:${message}`)
|
appendLog(`连接失败:${msg}`)
|
||||||
Alert.alert('IM 登录失败', message)
|
Alert.alert('IM 登录失败', msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run()
|
run()
|
||||||
}, [currentUser, peerUser.id])
|
}, [currentUser, peerUser.id])
|
||||||
|
|
||||||
@ -167,25 +161,50 @@ function DemoConsole() {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSwitchUser = (user: DemoUser) => {
|
const handleSend = async (msgType: MsgType, content: string) => {
|
||||||
setCurrentUser(user)
|
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 handleSendAllTypes = async () => {
|
||||||
const content = draft.trim()
|
setSending(true)
|
||||||
if (!content) {
|
appendLog(`开始发送全部 ${ALL_DEMO_TYPES.length} 种消息类型…`)
|
||||||
return
|
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 {
|
try {
|
||||||
const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', 'TEXT', content)
|
const revoked = await ImSDK.revokeMessage(messageId)
|
||||||
mergeMessage(sent)
|
mergeMessage(revoked)
|
||||||
setDraft('')
|
appendLog(`消息已撤回:${messageId}`)
|
||||||
appendLog(`已发送给 ${peerUser.nickname}`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : '消息发送失败'
|
const msg = error instanceof Error ? error.message : '撤回失败'
|
||||||
appendLog(`发送失败:${message}`)
|
appendLog(`撤回失败:${msg}`)
|
||||||
Alert.alert('发送失败', message)
|
Alert.alert('撤回失败', msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,8 +214,8 @@ function DemoConsole() {
|
|||||||
setMessages(history.slice().reverse())
|
setMessages(history.slice().reverse())
|
||||||
appendLog(`历史消息已刷新,共 ${history.length} 条`)
|
appendLog(`历史消息已刷新,共 ${history.length} 条`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : '历史消息加载失败'
|
const msg = error instanceof Error ? error.message : '拉取失败'
|
||||||
appendLog(`历史拉取失败:${message}`)
|
appendLog(`历史拉取失败:${msg}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,13 +228,13 @@ function DemoConsole() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setAppUpdateResult(
|
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}`)
|
appendLog(`发现 App 新版本:${result.versionName}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'App 更新检查失败'
|
const msg = error instanceof Error ? error.message : 'App 更新检查失败'
|
||||||
setAppUpdateResult(message)
|
setAppUpdateResult(msg)
|
||||||
appendLog(`App 更新失败:${message}`)
|
appendLog(`App 更新失败:${msg}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,7 +246,6 @@ function DemoConsole() {
|
|||||||
appendLog('插件更新检查:当前最新')
|
appendLog('插件更新检查:当前最新')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = await UpdateSDK.downloadRnBundle(result.downloadUrl)
|
const source = await UpdateSDK.downloadRnBundle(result.downloadUrl)
|
||||||
const cached = await UpdateSDK.cacheRnBundle(MODULE_ID, result.latestVersion, result.md5, source)
|
const cached = await UpdateSDK.cacheRnBundle(MODULE_ID, result.latestVersion, result.md5, source)
|
||||||
setRnUpdateResult(
|
setRnUpdateResult(
|
||||||
@ -236,30 +254,29 @@ function DemoConsole() {
|
|||||||
setCachedBundleSummary(`已缓存 ${cached.version},下载于 ${cached.downloadedAt}`)
|
setCachedBundleSummary(`已缓存 ${cached.version},下载于 ${cached.downloadedAt}`)
|
||||||
appendLog(`插件更新已缓存:${cached.version}`)
|
appendLog(`插件更新已缓存:${cached.version}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : '插件更新检查失败'
|
const msg = error instanceof Error ? error.message : '插件更新检查失败'
|
||||||
setRnUpdateResult(message)
|
setRnUpdateResult(msg)
|
||||||
appendLog(`插件更新失败:${message}`)
|
appendLog(`插件更新失败:${msg}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.heroEyebrow}>XuqmGroup RN SDK Demo</Text>
|
<Text style={styles.eyebrow}>XuqmGroup RN SDK Demo</Text>
|
||||||
<Text style={styles.heroTitle}>聊天、App 更新、插件热更新一屏演示</Text>
|
<Text style={styles.title}>IM · 全消息类型 · App更新 · 热更新</Text>
|
||||||
<Text style={styles.heroSubtitle}>
|
<Text style={styles.subtitle}>
|
||||||
这个项目直接连接 {API_BASE_URL},默认使用两个演示用户互聊。
|
连接 {API_BASE_URL},使用两个演示用户演示全部 IM 消息类型、App 版本检查和插件热更新。
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{/* Section 1: 当前会话 */}
|
||||||
<SectionCard title="1. 当前会话">
|
<SectionCard title="1. 当前会话">
|
||||||
<View style={styles.userSwitcher}>
|
<View style={styles.userRow}>
|
||||||
{DEMO_USERS.map(user => {
|
{DEMO_USERS.map(user => {
|
||||||
const active = user.id === currentUser.id
|
const active = user.id === currentUser.id
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={user.id}
|
key={user.id}
|
||||||
onPress={() => {
|
onPress={() => setCurrentUser(user)}
|
||||||
handleSwitchUser(user)
|
|
||||||
}}
|
|
||||||
style={[styles.userChip, active && styles.userChipActive]}>
|
style={[styles.userChip, active && styles.userChipActive]}>
|
||||||
<Text style={[styles.userChipText, active && styles.userChipTextActive]}>
|
<Text style={[styles.userChipText, active && styles.userChipTextActive]}>
|
||||||
{user.nickname}
|
{user.nickname}
|
||||||
@ -270,80 +287,73 @@ function DemoConsole() {
|
|||||||
</View>
|
</View>
|
||||||
<Text style={styles.metaText}>当前用户:{currentUser.nickname}({currentUser.id})</Text>
|
<Text style={styles.metaText}>当前用户:{currentUser.nickname}({currentUser.id})</Text>
|
||||||
<Text style={styles.metaText}>聊天对象:{peerUser.nickname}({peerUser.id})</Text>
|
<Text style={styles.metaText}>聊天对象:{peerUser.nickname}({peerUser.id})</Text>
|
||||||
<Text style={styles.metaText}>连接状态:{connected ? '已连接' : '连接中 / 未连接'}</Text>
|
<View style={styles.statusRow}>
|
||||||
<View style={styles.inlineActions}>
|
<View style={[styles.dot, connected ? styles.dotGreen : styles.dotGray]} />
|
||||||
<PrimaryButton title="重新拉历史" onPress={() => {
|
<Text style={styles.metaText}>{connected ? '已连接' : '连接中 / 未连接'}</Text>
|
||||||
handleReloadHistory()
|
</View>
|
||||||
}} />
|
<View style={styles.row}>
|
||||||
<GhostButton title="重新登录" onPress={() => {
|
<PrimaryButton title="刷新历史" onPress={handleReloadHistory} />
|
||||||
connectUser(currentUser)
|
<GhostButton title="重新登录" onPress={() => connectUser(currentUser)} />
|
||||||
}} />
|
|
||||||
</View>
|
</View>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="2. 聊天演示">
|
{/* Section 2: 聊天演示 */}
|
||||||
<View style={styles.chatPanel}>
|
<SectionCard title="2. 聊天演示(全消息类型)">
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
长按自己的消息可撤回。点击消息类型选择器切换,"全部类型演示"按钮依次发送所有 12 种类型。
|
||||||
|
</Text>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.chatPanel}
|
||||||
|
contentContainerStyle={styles.chatContent}
|
||||||
|
showsVerticalScrollIndicator={false}>
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<Text style={styles.emptyText}>还没有消息,发一条试试看。</Text>
|
<Text style={styles.emptyText}>还没有消息,发一条试试看。</Text>
|
||||||
) : (
|
) : (
|
||||||
messages.map(message => {
|
messages.map(message => (
|
||||||
const mine = message.fromUserId === currentUser.id
|
<MessageBubble
|
||||||
return (
|
key={message.id}
|
||||||
<View
|
message={message}
|
||||||
key={message.id}
|
isOwn={message.fromUserId === currentUser.id}
|
||||||
style={[styles.bubbleRow, mine ? styles.bubbleRowMine : styles.bubbleRowPeer]}>
|
peerNickname={peerUser.nickname}
|
||||||
<View style={[styles.bubble, mine ? styles.bubbleMine : styles.bubblePeer]}>
|
onRevoke={handleRevoke}
|
||||||
<Text style={styles.bubbleMeta}>
|
/>
|
||||||
{mine ? '我' : peerUser.nickname} · {message.msgType}
|
))
|
||||||
</Text>
|
|
||||||
<Text style={styles.bubbleText}>{message.content}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</ScrollView>
|
||||||
<TextInput
|
<MessageComposer
|
||||||
value={draft}
|
onSend={handleSend}
|
||||||
onChangeText={setDraft}
|
onSendAll={handleSendAllTypes}
|
||||||
placeholder="输入一条消息"
|
disabled={sending || !connected}
|
||||||
placeholderTextColor="#7c7f85"
|
|
||||||
style={styles.input}
|
|
||||||
/>
|
/>
|
||||||
<PrimaryButton title="发送消息" onPress={() => {
|
|
||||||
handleSend()
|
|
||||||
}} />
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Section 3: App 更新 */}
|
||||||
<SectionCard title="3. App 更新演示">
|
<SectionCard title="3. App 更新演示">
|
||||||
<Text style={styles.paragraph}>
|
<Text style={styles.paragraph}>
|
||||||
当前本地版本号写死为 {CURRENT_APP_VERSION_CODE},方便你在服务端发布更高版本后立即观察效果。
|
本地版本号:{CURRENT_APP_VERSION_CODE}。在服务端发布更高版本后点击检查即可看到结果。
|
||||||
</Text>
|
</Text>
|
||||||
<PrimaryButton title="检查 App 更新" onPress={() => {
|
<PrimaryButton title="检查 App 更新" onPress={handleCheckAppUpdate} />
|
||||||
handleCheckAppUpdate()
|
|
||||||
}} />
|
|
||||||
<Text style={styles.resultText}>{appUpdateResult}</Text>
|
<Text style={styles.resultText}>{appUpdateResult}</Text>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Section 4: 插件热更新 */}
|
||||||
<SectionCard title="4. 插件热更新演示">
|
<SectionCard title="4. 插件热更新演示">
|
||||||
<Text style={styles.paragraph}>
|
<Text style={styles.paragraph}>
|
||||||
当前模块:{MODULE_ID},本地插件版本:{CURRENT_RN_VERSION}。
|
模块:{MODULE_ID},本地版本:{CURRENT_RN_VERSION}。
|
||||||
|
热更新流程:检查 → 下载 → 缓存至 AsyncStorage。
|
||||||
</Text>
|
</Text>
|
||||||
<PrimaryButton title="检查插件更新并缓存" onPress={() => {
|
<PrimaryButton title="检查插件更新并缓存" onPress={handleCheckRnUpdate} />
|
||||||
handleCheckRnUpdate()
|
|
||||||
}} />
|
|
||||||
<Text style={styles.resultText}>{rnUpdateResult}</Text>
|
<Text style={styles.resultText}>{rnUpdateResult}</Text>
|
||||||
<Text style={styles.cacheText}>{cachedBundleSummary}</Text>
|
<Text style={styles.cacheText}>本地缓存:{cachedBundleSummary}</Text>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Section 5: 活动日志 */}
|
||||||
<SectionCard title="5. 活动日志">
|
<SectionCard title="5. 活动日志">
|
||||||
{activityLog.length === 0 ? (
|
{activityLog.length === 0 ? (
|
||||||
<Text style={styles.emptyText}>暂无日志</Text>
|
<Text style={styles.emptyText}>暂无日志</Text>
|
||||||
) : (
|
) : (
|
||||||
activityLog.map(item => (
|
activityLog.map((item, i) => (
|
||||||
<Text key={item} style={styles.logLine}>
|
<Text key={i} style={styles.logLine}>{item}</Text>
|
||||||
{item}
|
|
||||||
</Text>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@ -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 (
|
return (
|
||||||
<Pressable onPress={onPress} style={styles.primaryButton}>
|
<Pressable onPress={onPress} disabled={disabled} style={[styles.primaryBtn, disabled && styles.btnDisabled]}>
|
||||||
<Text style={styles.primaryButtonText}>{title}</Text>
|
<Text style={styles.primaryBtnText}>{title}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GhostButton({title, onPress}: {title: string; onPress: () => void}) {
|
function GhostButton({title, onPress}: {title: string; onPress: () => void}) {
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress} style={styles.ghostButton}>
|
<Pressable onPress={onPress} style={styles.ghostBtn}>
|
||||||
<Text style={styles.ghostButtonText}>{title}</Text>
|
<Text style={styles.ghostBtnText}>{title}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
safeArea: {
|
safeArea: {flex: 1, backgroundColor: '#f5efe3'},
|
||||||
flex: 1,
|
scroll: {flex: 1, backgroundColor: '#f5efe3'},
|
||||||
backgroundColor: '#f5efe3',
|
content: {padding: 18, gap: 14, paddingBottom: 32},
|
||||||
},
|
eyebrow: {fontSize: 13, fontWeight: '700', color: '#915f34', textTransform: 'uppercase', letterSpacing: 1.1},
|
||||||
container: {
|
title: {fontSize: 28, lineHeight: 34, fontWeight: '800', color: '#1f2933'},
|
||||||
flex: 1,
|
subtitle: {fontSize: 14, lineHeight: 21, color: '#4b5563'},
|
||||||
backgroundColor: '#f5efe3',
|
card: {borderRadius: 24, padding: 16, backgroundColor: '#fffaf2', borderWidth: 1, borderColor: '#ead7b7', gap: 12},
|
||||||
},
|
cardTitle: {fontSize: 18, fontWeight: '800', color: '#1f2933'},
|
||||||
content: {
|
hint: {fontSize: 13, lineHeight: 19, color: '#6b7280'},
|
||||||
padding: 18,
|
userRow: {flexDirection: 'row', gap: 10},
|
||||||
gap: 14,
|
userChip: {paddingHorizontal: 16, paddingVertical: 10, borderRadius: 999, backgroundColor: '#efe2c5'},
|
||||||
},
|
userChipActive: {backgroundColor: '#1f2933'},
|
||||||
heroEyebrow: {
|
userChipText: {color: '#4b5563', fontWeight: '700'},
|
||||||
fontSize: 13,
|
userChipTextActive: {color: '#fffdf8'},
|
||||||
fontWeight: '700',
|
statusRow: {flexDirection: 'row', alignItems: 'center', gap: 6},
|
||||||
color: '#915f34',
|
dot: {width: 8, height: 8, borderRadius: 4},
|
||||||
textTransform: 'uppercase',
|
dotGreen: {backgroundColor: '#22c55e'},
|
||||||
letterSpacing: 1.1,
|
dotGray: {backgroundColor: '#9ca3af'},
|
||||||
},
|
metaText: {fontSize: 14, color: '#4b5563'},
|
||||||
heroTitle: {
|
row: {flexDirection: 'row', gap: 10},
|
||||||
fontSize: 30,
|
chatPanel: {maxHeight: 360, borderRadius: 18, backgroundColor: '#f4ecdf'},
|
||||||
lineHeight: 36,
|
chatContent: {padding: 12, gap: 6},
|
||||||
fontWeight: '800',
|
emptyText: {fontSize: 14, color: '#7c7f85', textAlign: 'center', paddingVertical: 20},
|
||||||
color: '#1f2933',
|
paragraph: {fontSize: 14, lineHeight: 21, color: '#4b5563'},
|
||||||
},
|
primaryBtn: {minHeight: 46, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#1f2933', paddingHorizontal: 16},
|
||||||
heroSubtitle: {
|
primaryBtnText: {color: '#fffdf8', fontSize: 15, fontWeight: '800'},
|
||||||
fontSize: 15,
|
ghostBtn: {minHeight: 46, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#efe2c5', paddingHorizontal: 16},
|
||||||
lineHeight: 22,
|
ghostBtnText: {color: '#4b5563', fontSize: 15, fontWeight: '700'},
|
||||||
color: '#4b5563',
|
btnDisabled: {opacity: 0.45},
|
||||||
},
|
resultText: {fontSize: 14, lineHeight: 21, color: '#1f2933'},
|
||||||
card: {
|
cacheText: {fontSize: 13, color: '#6b7280'},
|
||||||
borderRadius: 24,
|
logLine: {fontSize: 12, lineHeight: 18, color: '#374151', fontFamily: 'monospace'},
|
||||||
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',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
132
docs/README.md
132
docs/README.md
@ -1,49 +1,133 @@
|
|||||||
# XuqmGroup RN Chat Demo
|
# XuqmGroup RN Chat Demo
|
||||||
|
|
||||||
这个演示项目用于验证三条能力:
|
> 文档版本:V2.0 · 更新日期:2026-04-24
|
||||||
|
|
||||||
1. `@xuqm/rn-sdk` 的单聊消息发送、历史拉取、实时接收
|
这个演示项目用于验证 `@xuqm/rn-sdk` 的三条核心能力:
|
||||||
2. `update-service` 的 App 版本检查
|
|
||||||
3. `update-service` 的 RN 插件热更新检查、下载与本地缓存
|
|
||||||
|
|
||||||
## 当前默认配置
|
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
|
```bash
|
||||||
cd XuqmGroup-RNChatDemo
|
cd XuqmGroup-RNChatDemo
|
||||||
npm install
|
npm install
|
||||||
npm run start
|
npm run start # Metro bundler
|
||||||
npm run android
|
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
|
```bash
|
||||||
cd XuqmGroup-RNChatDemo
|
cd XuqmGroup-RNChatDemo
|
||||||
./scripts/publish-demo-assets.sh
|
./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`
|
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`。
|
- **文本类**(TEXT、RICH_TEXT):直接显示文本内容
|
||||||
如果后续要做真正的运行时热替换,可以在此基础上再接原生 bundle loader。
|
- **媒体类**(IMAGE、VIDEO、AUDIO、FILE):显示图标 + 关键信息(尺寸/时长/大小)
|
||||||
|
- **结构类**(LOCATION、NOTIFY、CUSTOM):展示解析后的关键字段
|
||||||
|
- **通话类**(CALL_AUDIO、CALL_VIDEO):显示通话类型 + 动作状态
|
||||||
|
- **转发消息**(FORWARD):引用样式展示原始内容
|
||||||
|
- **已撤回**(REVOKED / status=REVOKED):灰色气泡 + "此消息已被撤回"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 消息撤回后,服务端返回 `status=REVOKED` 的消息,Demo 会就地更新气泡
|
||||||
|
- 热更新演示的是"检查 + 下载 + 缓存"链路,缓存内容保存在 AsyncStorage
|
||||||
|
- 生产场景如需真正运行时热替换,需额外对接原生 bundle loader
|
||||||
|
|||||||
356
src/components/MessageBubble.tsx
普通文件
356
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<string, unknown> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(content) as Record<string, unknown>
|
||||||
|
} 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 <Text style={styles.revokedText}>此消息已被撤回</Text>
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (msgType) {
|
||||||
|
case 'TEXT':
|
||||||
|
return <Text style={styles.textContent}>{content}</Text>
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.mediaRow}>
|
||||||
|
<Text style={styles.mediaIcon}>📄</Text>
|
||||||
|
<View style={styles.mediaInfo}>
|
||||||
|
<Text style={styles.mediaLabel}>富文本消息</Text>
|
||||||
|
<Text style={styles.mediaDesc} numberOfLines={2}>{preview}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'IMAGE': {
|
||||||
|
const data = parseContent(content)
|
||||||
|
const w = typeof data.width === 'number' ? data.width : '?'
|
||||||
|
const h = typeof data.height === 'number' ? data.height : '?'
|
||||||
|
return (
|
||||||
|
<View style={styles.mediaRow}>
|
||||||
|
<Text style={styles.mediaIcon}>🖼️</Text>
|
||||||
|
<View style={styles.mediaInfo}>
|
||||||
|
<Text style={styles.mediaLabel}>图片 {w}×{h}</Text>
|
||||||
|
{typeof data.url === 'string' && (
|
||||||
|
<Text style={styles.mediaUrl} numberOfLines={1}>{data.url as string}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.mediaRow}>
|
||||||
|
<Text style={styles.mediaIcon}>🎬</Text>
|
||||||
|
<View style={styles.mediaInfo}>
|
||||||
|
<Text style={styles.mediaLabel}>视频 {duration}{size ? ` · ${size}` : ''}</Text>
|
||||||
|
{typeof data.url === 'string' && (
|
||||||
|
<Text style={styles.mediaUrl} numberOfLines={1}>{data.url as string}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.mediaRow}>
|
||||||
|
<Text style={styles.mediaIcon}>🎵</Text>
|
||||||
|
<View style={styles.mediaInfo}>
|
||||||
|
<Text style={styles.mediaLabel}>语音消息 {duration}{size ? ` · ${size}` : ''}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.mediaRow}>
|
||||||
|
<Text style={styles.mediaIcon}>📎</Text>
|
||||||
|
<View style={styles.mediaInfo}>
|
||||||
|
<Text style={styles.mediaLabel}>{name}</Text>
|
||||||
|
{size ? <Text style={styles.mediaDesc}>{size}</Text> : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.mediaRow}>
|
||||||
|
<Text style={styles.mediaIcon}>📍</Text>
|
||||||
|
<View style={styles.mediaInfo}>
|
||||||
|
<Text style={styles.mediaLabel}>{title}</Text>
|
||||||
|
{address ? <Text style={styles.mediaDesc}>{address}</Text> : null}
|
||||||
|
{lat && lng ? <Text style={styles.mediaDesc}>{lat}°N, {lng}°E</Text> : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'NOTIFY': {
|
||||||
|
const data = parseContent(content)
|
||||||
|
const title = typeof data.title === 'string' ? data.title : '系统通知'
|
||||||
|
const body = typeof data.content === 'string' ? data.content : ''
|
||||||
|
return (
|
||||||
|
<View style={styles.notifyBox}>
|
||||||
|
<Text style={styles.notifyTitle}>🔔 {title}</Text>
|
||||||
|
{body ? <Text style={styles.notifyBody}>{body}</Text> : null}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CUSTOM': {
|
||||||
|
const data = parseContent(content)
|
||||||
|
const title = typeof data.title === 'string' ? data.title : '自定义消息'
|
||||||
|
const desc = typeof data.desc === 'string' ? data.desc : ''
|
||||||
|
return (
|
||||||
|
<View style={styles.customBox}>
|
||||||
|
<Text style={styles.customTitle}>⚙️ {title}</Text>
|
||||||
|
{desc ? <Text style={styles.customDesc}>{desc}</Text> : null}
|
||||||
|
<Text style={styles.customRaw} numberOfLines={2}>{content}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CALL_AUDIO':
|
||||||
|
case 'CALL_VIDEO': {
|
||||||
|
const data = parseContent(content)
|
||||||
|
const action = typeof data.action === 'string' ? data.action : 'invite'
|
||||||
|
const actionLabel: Record<string, string> = {
|
||||||
|
invite: '邀请通话',
|
||||||
|
accept: '已接听',
|
||||||
|
reject: '已拒绝',
|
||||||
|
end: '通话结束',
|
||||||
|
missed: '未接听',
|
||||||
|
}
|
||||||
|
const icon = msgType === 'CALL_VIDEO' ? '📹' : '📞'
|
||||||
|
const label = msgType === 'CALL_VIDEO' ? '视频通话' : '语音通话'
|
||||||
|
return (
|
||||||
|
<View style={styles.mediaRow}>
|
||||||
|
<Text style={styles.mediaIcon}>{icon}</Text>
|
||||||
|
<View style={styles.mediaInfo}>
|
||||||
|
<Text style={styles.mediaLabel}>{label}</Text>
|
||||||
|
<Text style={styles.mediaDesc}>{actionLabel[action] ?? action}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'FORWARD': {
|
||||||
|
const data = parseContent(content)
|
||||||
|
const originalContent = typeof data.originalContent === 'string' ? data.originalContent : content
|
||||||
|
const originalSender = typeof data.originalSender === 'string' ? data.originalSender : ''
|
||||||
|
return (
|
||||||
|
<View style={styles.forwardBox}>
|
||||||
|
<Text style={styles.forwardLabel}>↪ 转发消息{originalSender ? ` · ${originalSender}` : ''}</Text>
|
||||||
|
<Text style={styles.forwardContent} numberOfLines={3}>{originalContent}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <Text style={styles.textContent}>{content}</Text>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={[styles.row, isOwn ? styles.rowOwn : styles.rowPeer]}>
|
||||||
|
<Pressable
|
||||||
|
onLongPress={handleLongPress}
|
||||||
|
style={[styles.bubble, isOwn ? styles.bubbleOwn : styles.bubblePeer, isRevoked && styles.bubbleRevoked]}>
|
||||||
|
<Text style={styles.metaLine}>
|
||||||
|
{senderLabel} · {message.msgType}
|
||||||
|
{message.status === 'READ' ? ' · 已读' : message.status === 'DELIVERED' ? ' · 已送达' : ''}
|
||||||
|
</Text>
|
||||||
|
<MessageContent message={message} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
})
|
||||||
274
src/components/MessageComposer.tsx
普通文件
274
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<MsgType, string> = {
|
||||||
|
TEXT: '文本',
|
||||||
|
IMAGE: '图片',
|
||||||
|
VIDEO: '视频',
|
||||||
|
AUDIO: '语音',
|
||||||
|
FILE: '文件',
|
||||||
|
CUSTOM: '自定义',
|
||||||
|
LOCATION: '位置',
|
||||||
|
NOTIFY: '通知',
|
||||||
|
RICH_TEXT: '富文本',
|
||||||
|
CALL_AUDIO: '语音通话',
|
||||||
|
CALL_VIDEO: '视频通话',
|
||||||
|
FORWARD: '转发',
|
||||||
|
REVOKED: '已撤回',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<MsgType, string> = {
|
||||||
|
TEXT: '💬',
|
||||||
|
IMAGE: '🖼️',
|
||||||
|
VIDEO: '🎬',
|
||||||
|
AUDIO: '🎵',
|
||||||
|
FILE: '📎',
|
||||||
|
CUSTOM: '⚙️',
|
||||||
|
LOCATION: '📍',
|
||||||
|
NOTIFY: '🔔',
|
||||||
|
RICH_TEXT: '📄',
|
||||||
|
CALL_AUDIO: '📞',
|
||||||
|
CALL_VIDEO: '📹',
|
||||||
|
FORWARD: '↪️',
|
||||||
|
REVOKED: '🚫',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEMO_CONTENT: Record<MsgType, string> = {
|
||||||
|
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: '<h3>富文本消息</h3><p>这是一段 <strong>加粗</strong> 内容,支持 <em>斜体</em> 和 <a href="#">链接</a>。</p>',
|
||||||
|
}),
|
||||||
|
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<MsgType>('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 (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.label}>消息类型</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.typeList}>
|
||||||
|
{ALL_MSG_TYPES.map(type => (
|
||||||
|
<Pressable
|
||||||
|
key={type}
|
||||||
|
onPress={() => handleSelectType(type)}
|
||||||
|
style={[styles.typeChip, selectedType === type && styles.typeChipActive]}>
|
||||||
|
<Text style={styles.typeChipIcon}>{TYPE_ICONS[type]}</Text>
|
||||||
|
<Text style={[styles.typeChipText, selectedType === type && styles.typeChipTextActive]}>
|
||||||
|
{TYPE_LABELS[type]}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={content}
|
||||||
|
onChangeText={setContent}
|
||||||
|
placeholder={`输入 ${TYPE_LABELS[selectedType]} 内容`}
|
||||||
|
placeholderTextColor="#7c7f85"
|
||||||
|
style={[styles.input, isMultiline && styles.inputMultiline]}
|
||||||
|
multiline={isMultiline}
|
||||||
|
numberOfLines={isMultiline ? 4 : 1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleSend}
|
||||||
|
disabled={disabled}
|
||||||
|
style={[styles.sendBtn, disabled && styles.btnDisabled]}>
|
||||||
|
<Text style={styles.sendBtnText}>发送 {TYPE_ICONS[selectedType]}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={onSendAll}
|
||||||
|
disabled={disabled}
|
||||||
|
style={[styles.allBtn, disabled && styles.btnDisabled]}>
|
||||||
|
<Text style={styles.allBtnText}>全部类型演示</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户