diff --git a/App.tsx b/App.tsx
index d2cb639..1b82f3a 100644
--- a/App.tsx
+++ b/App.tsx
@@ -1,375 +1,22 @@
-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'
-import {GroupChatPanel} from './src/components/GroupChatPanel'
-import {UpdatePanel} from './src/components/UpdatePanel'
+import React, { useEffect } from 'react'
+import { StatusBar, useColorScheme } from 'react-native'
+import { SafeAreaProvider } from 'react-native-safe-area-context'
+import { UpdateSDK } from '@xuqm/rn-sdk'
+import { AuthProvider } from './src/context/AuthContext'
+import AppNavigator from './src/navigation/AppNavigator'
import pluginMeta from './plugin.json'
-// Register this bundle as a plugin so UpdateSDK.checkRnUpdate() knows the current version.
UpdateSDK.registerPlugin(pluginMeta)
-
-// Dev fallback: set app versionCode for simulators where native module is not linked.
UpdateSDK._devSetAppVersion(1, '1.0.0')
-const APP_ID = 'ak_demo_chat'
-const MODULE_ID = pluginMeta.moduleId
-
-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() {
+export default function App() {
const isDarkMode = useColorScheme() === 'dark'
return (
-
-
-
+
+
+
)
}
-
-function DemoConsole() {
- const [currentUser, setCurrentUser] = useState(DEMO_USERS[0])
- const [connected, setConnected] = useState(false)
- const [singleMessages, setSingleMessages] = useState([])
- const [groupMessages, setGroupMessages] = useState([])
- const [sending, setSending] = useState(false)
- const [activityLog, setActivityLog] = 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, 30),
- )
- })
- }
-
- const mergeSingle = (message: ImMessage) => {
- startTransition(() => {
- setSingleMessages(prev => {
- const exists = prev.some(m => m.id === message.id)
- if (exists) return prev.map(m => m.id === message.id ? message : m)
- return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
- })
- })
- }
-
- const mergeGroup = (message: ImMessage) => {
- startTransition(() => {
- setGroupMessages(prev => {
- const exists = prev.some(m => m.id === message.id)
- if (exists) return prev.map(m => m.id === message.id ? message : m)
- return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
- })
- })
- }
-
- useEffect(() => {
- XuqmSDK.init({
- appId: APP_ID,
- debug: __DEV__,
- })
- }, [])
-
- 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(msg) {
- if (msg.chatType === 'SINGLE') mergeSingle(msg)
- else mergeGroup(msg)
- appendLog(`收到 [${msg.chatType}/${msg.msgType}] from ${msg.fromUserId}`)
- },
- onGroupMessage(msg) {
- mergeGroup(msg)
- appendLog(`收到群消息 [${msg.msgType}]`)
- },
- onError(error) {
- appendLog(`IM 错误:${error}`)
- },
- }
- ImSDK.addListener(listener)
- return () => {
- ImSDK.removeListener(listener)
- ImSDK.disconnect()
- }
- }, [])
-
- useEffect(() => {
- const run = async () => {
- try {
- ImSDK.disconnect()
- setConnected(false)
- setSingleMessages([])
- await ImSDK.login(currentUser.id, currentUser.nickname)
- appendLog(`已登录 IM:${currentUser.nickname}`)
- const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
- history.slice().reverse().forEach(m => mergeSingle(m))
- setConnected(ImSDK.isConnected())
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'IM 登录失败'
- appendLog(`连接失败:${msg}`)
- Alert.alert('IM 登录失败', msg)
- }
- }
- run()
- }, [currentUser, peerUser.id])
-
- const handleSendSingle = async (msgType: MsgType, content: string) => {
- setSending(true)
- try {
- const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', msgType, content)
- mergeSingle(sent)
- appendLog(`已发送 [${msgType}] 给 ${peerUser.nickname}`)
- } catch (e) {
- const msg = e instanceof Error ? e.message : '发送失败'
- appendLog(`发送失败 [${msgType}]:${msg}`)
- Alert.alert('发送失败', msg)
- } finally {
- setSending(false)
- }
- }
-
- const handleSendAllSingle = async () => {
- setSending(true)
- appendLog(`开始发送全部 ${ALL_DEMO_TYPES.length} 种消息类型(单聊)…`)
- for (const type of ALL_DEMO_TYPES) {
- const content = DEMO_CONTENT[type]
- if (!content) continue
- try {
- const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', type, content)
- mergeSingle(sent)
- appendLog(`✓ [${type}]`)
- await new Promise(resolve => setTimeout(resolve, 300))
- } catch (e) {
- appendLog(`✗ [${type}]:${e instanceof Error ? e.message : 'error'}`)
- }
- }
- appendLog('单聊全类型演示完成')
- setSending(false)
- }
-
- const handleRevokeSingle = async (messageId: string) => {
- try {
- const revoked = await ImSDK.revokeMessage(messageId)
- mergeSingle(revoked)
- appendLog(`消息已撤回:${messageId}`)
- } catch (e) {
- const msg = e instanceof Error ? e.message : '撤回失败'
- Alert.alert('撤回失败', msg)
- }
- }
-
- const handleReloadSingle = async () => {
- try {
- const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
- setSingleMessages(history.slice().reverse())
- appendLog(`历史消息已刷新,共 ${history.length} 条`)
- } catch (e) {
- appendLog(`历史拉取失败:${e instanceof Error ? e.message : 'error'}`)
- }
- }
-
- return (
-
- XuqmGroup RN SDK Demo
- IM · 全消息类型 · 群聊 · App 更新 · 热更新
-
- 演示单聊/群聊全消息类型、整包更新和 RN 插件热更新完整流程。
-
-
- {/* Section 1: 当前会话 */}
-
-
- {DEMO_USERS.map(user => {
- const active = user.id === currentUser.id
- return (
- setCurrentUser(user)}
- style={[styles.userChip, active && styles.userChipActive]}>
-
- {user.nickname}
-
-
- )
- })}
-
- 当前用户:{currentUser.nickname}({currentUser.id})
- 单聊对象:{peerUser.nickname}({peerUser.id})
-
-
- {connected ? 'IM 已连接' : '连接中 / 未连接'}
-
-
-
- {
- ImSDK.disconnect()
- setConnected(false)
- setSingleMessages([])
- ImSDK.login(currentUser.id, currentUser.nickname)
- .then(() => ImSDK.fetchHistory(peerUser.id, 0, 50))
- .then(h => { h.slice().reverse().forEach(m => mergeSingle(m)); setConnected(ImSDK.isConnected()) })
- .catch(e => appendLog(`重登录失败:${e instanceof Error ? e.message : 'error'}`))
- }} />
-
-
-
- {/* Section 2: 单聊 */}
-
- 长按自己的消息可撤回。"全部类型演示"依次发送所有 12 种消息类型。
-
- {singleMessages.length === 0 ? (
- 还没有消息,发一条试试看。
- ) : (
- singleMessages.map(m => (
-
- ))
- )}
-
-
-
-
- {/* Section 3: 群聊 */}
-
-
- 点击"创建演示群"创建包含 Alice+Bob 的群组,"加载群列表"列出已有群,然后即可发送群消息。
-
-
-
-
- {/* Section 4: 更新演示 */}
-
-
-
-
- {/* Section 5: 活动日志 */}
-
- {activityLog.length === 0 ? (
- 暂无日志
- ) : (
- activityLog.map((item, i) => (
- {item}
- ))
- )}
-
-
- )
-}
-
-function SectionCard({title, children}: {title: string; children: React.ReactNode}) {
- return (
-
- {title}
- {children}
-
- )
-}
-
-function PrimaryBtn({title, onPress, disabled}: {title: string; onPress: () => void; disabled?: boolean}) {
- return (
-
- {title}
-
- )
-}
-
-function GhostBtn({title, onPress}: {title: string; onPress: () => void}) {
- return (
-
- {title}
-
- )
-}
-
-const styles = StyleSheet.create({
- safeArea: {flex: 1, backgroundColor: '#f5efe3'},
- scroll: {flex: 1, backgroundColor: '#f5efe3'},
- content: {padding: 18, gap: 14, paddingBottom: 40},
- eyebrow: {fontSize: 13, fontWeight: '700', color: '#915f34', textTransform: 'uppercase', letterSpacing: 1.1},
- title: {fontSize: 26, lineHeight: 32, fontWeight: '800', color: '#1f2933'},
- subtitle: {fontSize: 13, lineHeight: 20, color: '#4b5563'},
- card: {borderRadius: 24, padding: 16, backgroundColor: '#fffaf2', borderWidth: 1, borderColor: '#ead7b7', gap: 12},
- cardTitle: {fontSize: 17, fontWeight: '800', color: '#1f2933'},
- hint: {fontSize: 13, lineHeight: 19, color: '#6b7280'},
- userRow: {flexDirection: 'row', gap: 10},
- userChip: {paddingHorizontal: 16, paddingVertical: 10, borderRadius: 999, backgroundColor: '#efe2c5'},
- 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'},
- meta: {fontSize: 13, color: '#4b5563'},
- row: {flexDirection: 'row', gap: 10},
- chatPanel: {maxHeight: 340, borderRadius: 18, backgroundColor: '#f4ecdf'},
- chatContent: {padding: 12, gap: 6},
- empty: {fontSize: 14, color: '#7c7f85', textAlign: 'center', paddingVertical: 20},
- primaryBtn: {minHeight: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: '#1f2933', paddingHorizontal: 16},
- primaryBtnText: {color: '#fffdf8', fontSize: 14, fontWeight: '800'},
- ghostBtn: {minHeight: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: '#efe2c5', paddingHorizontal: 16},
- ghostBtnText: {color: '#4b5563', fontSize: 14, fontWeight: '700'},
- btnDisabled: {opacity: 0.45},
- logLine: {fontSize: 11, lineHeight: 17, color: '#374151', fontFamily: 'monospace'},
-})
-
-export default App
diff --git a/babel.config.js b/babel.config.js
index f7b3da3..f2a2188 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -1,3 +1,4 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
+ plugins: [['@babel/plugin-proposal-decorators', { legacy: true }]],
};
diff --git a/metro.config.js b/metro.config.js
index 2a0a21c..94e4faa 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -1,11 +1,19 @@
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
+const path = require('path');
-/**
- * Metro configuration
- * https://reactnative.dev/docs/metro
- *
- * @type {import('@react-native/metro-config').MetroConfig}
- */
-const config = {};
+const sdkRoot = path.resolve(__dirname, 'node_modules/@xuqm/rn-sdk');
+
+const config = {
+ resolver: {
+ extraNodeModules: {
+ '@xuqm/rn-common': path.join(sdkRoot, 'packages/common'),
+ '@xuqm/rn-im': path.join(sdkRoot, 'packages/im'),
+ '@xuqm/rn-push': path.join(sdkRoot, 'packages/push'),
+ '@xuqm/rn-update': path.join(sdkRoot, 'packages/update'),
+ // WatermelonDB is a peerDep of rn-im; resolve from demo's node_modules
+ '@nozbe/watermelondb': path.resolve(__dirname, 'node_modules/@nozbe/watermelondb'),
+ },
+ },
+};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
diff --git a/package.json b/package.json
index d6de393..b576ee5 100644
--- a/package.json
+++ b/package.json
@@ -10,15 +10,24 @@
"test": "jest"
},
"dependencies": {
+ "@nozbe/watermelondb": "^0.28.0",
"@react-native-async-storage/async-storage": "^2.2.0",
+ "@react-native/new-app-screen": "0.85.2",
+ "@react-navigation/bottom-tabs": "^7.15.10",
+ "@react-navigation/native": "^7.2.2",
+ "@react-navigation/native-stack": "^7.14.12",
"@xuqm/rn-sdk": "file:../XuqmGroup-RNSDK",
"react": "19.2.3",
"react-native": "0.85.2",
- "@react-native/new-app-screen": "0.85.2",
- "react-native-safe-area-context": "^5.5.2"
+ "react-native-audio-recorder-player": "^4.5.0",
+ "react-native-document-picker": "^9.3.1",
+ "react-native-image-picker": "^8.2.1",
+ "react-native-safe-area-context": "^5.5.2",
+ "react-native-screens": "^4.24.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",
+ "@babel/plugin-proposal-decorators": "^7.29.0",
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.1.0",
diff --git a/src/api/demo.ts b/src/api/demo.ts
new file mode 100644
index 0000000..ad77937
--- /dev/null
+++ b/src/api/demo.ts
@@ -0,0 +1,85 @@
+import { load, K } from '../utils/storage'
+
+const BASE = 'https://sentry.xuqinmin.com/api/demo'
+const APP_ID = 'ak_demo_chat'
+
+async function getToken(): Promise {
+ return load(K.DEMO_TOKEN)
+}
+
+async function request(
+ path: string,
+ options: { method?: string; body?: unknown; params?: Record; skipAuth?: boolean } = {},
+): Promise {
+ let url = BASE + path
+ const params = { appId: APP_ID, ...(options.params ?? {}) }
+ url += '?' + new URLSearchParams(params).toString()
+
+ const headers: Record = { 'Content-Type': 'application/json' }
+ if (!options.skipAuth) {
+ const token = await getToken()
+ if (token) headers['Authorization'] = `Bearer ${token}`
+ }
+
+ const res = await fetch(url, {
+ method: options.method ?? 'GET',
+ headers,
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ })
+
+ const json = await res.json()
+ if (!res.ok) throw new Error(json.message ?? `HTTP ${res.status}`)
+ return (json.data ?? json) as T
+}
+
+export interface UserProfile {
+ userId: string
+ nickname: string
+ avatar: string
+ gender: 'UNKNOWN' | 'MALE' | 'FEMALE'
+ status: string
+}
+
+export interface AuthResult {
+ demoToken: string
+ imToken: string
+ profile: UserProfile
+}
+
+export const demoApi = {
+ register(userId: string, password: string, nickname: string): Promise {
+ return request('/auth/register', {
+ method: 'POST',
+ skipAuth: true,
+ body: { appId: APP_ID, userId, password, nickname },
+ })
+ },
+
+ login(userId: string, password: string): Promise {
+ return request('/auth/login', {
+ method: 'POST',
+ skipAuth: true,
+ body: { appId: APP_ID, userId, password },
+ })
+ },
+
+ getProfile(): Promise {
+ return request('/user/profile')
+ },
+
+ updateProfile(data: Partial>): Promise {
+ return request('/user/profile', { method: 'PUT', body: data })
+ },
+
+ resetPassword(userId: string, newPassword: string): Promise {
+ return request('/auth/reset-password', { method: 'POST', skipAuth: true, body: { appId: APP_ID, userId, newPassword } })
+ },
+
+ changePassword(oldPassword: string, newPassword: string): Promise {
+ return request('/user/change-password', { method: 'POST', body: { oldPassword, newPassword } })
+ },
+
+ searchUsers(keyword: string): Promise {
+ return request('/users/search', { params: { keyword } })
+ },
+}
diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx
new file mode 100644
index 0000000..37b91cc
--- /dev/null
+++ b/src/components/ChatInput.tsx
@@ -0,0 +1,201 @@
+import React, { useRef, useState } from 'react'
+import {
+ View, Text, TextInput, TouchableOpacity, StyleSheet, Alert,
+ Platform, ActivityIndicator,
+} from 'react-native'
+import { launchImageLibrary } from 'react-native-image-picker'
+import DocumentPicker from 'react-native-document-picker'
+import AudioRecorderPlayer from 'react-native-audio-recorder-player'
+import { ImSDK } from '@xuqm/rn-sdk'
+import type { ChatType, ImMessage } from '@xuqm/rn-sdk'
+
+const EMOJI_LIST = [
+ '😀','😂','🥹','😍','🥰','😎','😭','😤','🤔','😴',
+ '👍','👎','❤️','🔥','🎉','💯','🙏','✅','😊','🤣',
+]
+
+interface Props {
+ toId: string
+ chatType: ChatType
+ onSent(msg: ImMessage): void
+}
+
+const recorder = new AudioRecorderPlayer()
+
+export default function ChatInput({ toId, chatType, onSent }: Props) {
+ const [text, setText] = useState('')
+ const [sending, setSending] = useState(false)
+ const [showEmoji, setShowEmoji] = useState(false)
+ const [recording, setRecording] = useState(false)
+ const [recordPath, setRecordPath] = useState(null)
+ const recordStart = useRef(0)
+ const inputRef = useRef(null)
+
+ const send = async () => {
+ const t = text.trim()
+ if (!t || sending) return
+ setSending(true)
+ try {
+ const msg = await ImSDK.sendMessage(toId, chatType, 'TEXT', t)
+ setText('')
+ onSent(msg)
+ } catch (e: any) {
+ Alert.alert('发送失败', e?.message ?? '请重试')
+ } finally {
+ setSending(false)
+ }
+ }
+
+ const sendImage = async () => {
+ const result = await launchImageLibrary({ mediaType: 'photo', quality: 0.85, includeExtra: true })
+ if (!result.assets?.length) return
+ const asset = result.assets[0]
+ if (!asset.uri) return
+ setSending(true)
+ try {
+ const msg = await ImSDK.sendImageMessage(toId, chatType, asset.uri, asset.width, asset.height)
+ onSent(msg)
+ } catch (e: any) {
+ Alert.alert('发送失败', e?.message ?? '图片上传失败')
+ } finally {
+ setSending(false)
+ }
+ }
+
+ const sendVideo = async () => {
+ const result = await launchImageLibrary({ mediaType: 'video', includeExtra: true })
+ if (!result.assets?.length) return
+ const asset = result.assets[0]
+ if (!asset.uri) return
+ setSending(true)
+ try {
+ const msg = await ImSDK.sendVideoMessage(toId, chatType, asset.uri, undefined, asset.duration)
+ onSent(msg)
+ } catch (e: any) {
+ Alert.alert('发送失败', e?.message ?? '视频上传失败')
+ } finally {
+ setSending(false)
+ }
+ }
+
+ const sendFile = async () => {
+ try {
+ const res = await DocumentPicker.pickSingle({ type: [DocumentPicker.types.allFiles] })
+ if (!res.uri) return
+ setSending(true)
+ const msg = await ImSDK.sendFileMessage(toId, chatType, res.uri, res.name ?? 'file', res.size ?? 0)
+ onSent(msg)
+ } catch (e: any) {
+ if (!DocumentPicker.isCancel(e)) {
+ Alert.alert('发送失败', e?.message ?? '文件上传失败')
+ }
+ } finally {
+ setSending(false)
+ }
+ }
+
+ const toggleRecord = async () => {
+ if (recording) {
+ const path = await recorder.stopRecorder()
+ recorder.removeRecordBackListener()
+ setRecording(false)
+ const duration = Math.round((Date.now() - recordStart.current) / 1000)
+ if (duration < 1) { Alert.alert('提示', '录音时间太短'); return }
+ setSending(true)
+ try {
+ const msg = await ImSDK.sendAudioMessage(toId, chatType, path, duration)
+ onSent(msg)
+ } catch (e: any) {
+ Alert.alert('发送失败', e?.message ?? '语音上传失败')
+ } finally {
+ setSending(false)
+ }
+ } else {
+ const path = Platform.OS === 'android' ? 'xuqm_audio.mp4' : 'xuqm_audio.m4a'
+ await recorder.startRecorder(path)
+ recorder.addRecordBackListener(() => {})
+ recordStart.current = Date.now()
+ setRecording(true)
+ setRecordPath(path)
+ }
+ }
+
+ const insertEmoji = (emoji: string) => {
+ setText(prev => prev + emoji)
+ inputRef.current?.focus()
+ }
+
+ return (
+
+ {showEmoji && (
+
+
+ {EMOJI_LIST.map(e => (
+ insertEmoji(e)}>
+ {e}
+
+ ))}
+
+
+ )}
+
+
+
+ {recording ? '⏹' : '🎤'}
+
+
+ setShowEmoji(false)}
+ returnKeyType="default"
+ />
+
+ setShowEmoji(v => !v)}>
+ 😊
+
+
+ 🖼️
+
+
+ 🎬
+
+
+ 📎
+
+
+ {sending
+ ?
+ : (
+
+ 发送
+
+ )
+ }
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ bar: { flexDirection: 'row', alignItems: 'flex-end', padding: 8, gap: 6, backgroundColor: '#fff', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#e0e0e0' },
+ input: { flex: 1, maxHeight: 100, minHeight: 36, borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 18, paddingHorizontal: 12, paddingVertical: 8, fontSize: 15, backgroundColor: '#f9f9f9' },
+ iconBtn: { width: 32, height: 36, alignItems: 'center', justifyContent: 'center' },
+ icon: { fontSize: 20 },
+ sendBtn: { paddingHorizontal: 14, height: 36, backgroundColor: '#07C160', borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
+ sendBtnDisabled: { backgroundColor: '#b2dfdb' },
+ sendBtnText: { color: '#fff', fontSize: 14, fontWeight: '600' },
+ emojiPanel: { backgroundColor: '#f9f9f9', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#e0e0e0' },
+ emojiGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8 },
+ emojiBtn: { width: '10%', aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },
+ emojiText: { fontSize: 24 },
+})
diff --git a/src/components/ConversationItem.tsx b/src/components/ConversationItem.tsx
new file mode 100644
index 0000000..c994dc7
--- /dev/null
+++ b/src/components/ConversationItem.tsx
@@ -0,0 +1,67 @@
+import React from 'react'
+import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'
+import type { ConversationData } from '@xuqm/rn-sdk'
+import { formatTime } from '../utils/format'
+
+interface Props {
+ item: ConversationData & { targetName?: string; targetAvatar?: string }
+ onPress(): void
+}
+
+function Avatar({ name, uri }: { name: string; uri?: string }) {
+ const letter = (name || '?').charAt(0).toUpperCase()
+ return (
+
+ {letter}
+
+ )
+}
+
+function lastMsgPreview(item: ConversationData): string {
+ switch (item.lastMsgType) {
+ case 'IMAGE': return '[图片]'
+ case 'VIDEO': return '[视频]'
+ case 'AUDIO': return '[语音]'
+ case 'FILE': return '[文件]'
+ default: return item.lastMsgContent
+ }
+}
+
+export default function ConversationItem({ item, onPress }: Props) {
+ return (
+
+
+
+
+ {item.targetName ?? item.targetId}
+ {item.lastMsgTime ? formatTime(item.lastMsgTime) : ''}
+
+
+
+ {item.isMuted ? '[已静音] ' : ''}{lastMsgPreview(item)}
+
+ {item.unreadCount > 0 && (
+
+ {item.unreadCount > 99 ? '99+' : item.unreadCount}
+
+ )}
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ row: { flexDirection: 'row', padding: 12, alignItems: 'center', backgroundColor: '#fff' },
+ avatar: { width: 48, height: 48, borderRadius: 8, backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
+ avatarText: { color: '#fff', fontSize: 20, fontWeight: '600' },
+ body: { flex: 1 },
+ topRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
+ name: { flex: 1, fontSize: 16, fontWeight: '500', color: '#111', marginRight: 8 },
+ time: { fontSize: 12, color: '#999' },
+ bottomRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
+ preview: { flex: 1, fontSize: 14, color: '#888', marginRight: 8 },
+ muted: { color: '#bbb' },
+ badge: { backgroundColor: '#ff3b30', borderRadius: 10, minWidth: 20, height: 20, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 4 },
+ badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
+})
diff --git a/src/components/DisconnectBanner.tsx b/src/components/DisconnectBanner.tsx
new file mode 100644
index 0000000..f18ad86
--- /dev/null
+++ b/src/components/DisconnectBanner.tsx
@@ -0,0 +1,32 @@
+import React from 'react'
+import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native'
+import { useIM } from '../context/IMContext'
+
+export default function DisconnectBanner() {
+ const { status, reconnect } = useIM()
+ if (status === 'connected') return null
+
+ return (
+
+ {status === 'connecting' ? '连接中...' : '已断开连接'}
+ {status === 'disconnected' && (
+
+ 重试
+
+ )}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ banner: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: '#ff9500',
+ paddingVertical: 6,
+ gap: 12,
+ },
+ text: { color: '#fff', fontSize: 13 },
+ retry: { color: '#fff', fontSize: 13, textDecorationLine: 'underline' },
+})
diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx
new file mode 100644
index 0000000..cc4a7d7
--- /dev/null
+++ b/src/context/AuthContext.tsx
@@ -0,0 +1,98 @@
+import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
+import { XuqmSDK, ImSDK } from '@xuqm/rn-sdk'
+import { demoApi, type UserProfile } from '../api/demo'
+import { save, load, clearSession, K } from '../utils/storage'
+
+const APP_ID = 'ak_demo_chat'
+const SERVER_URL = 'https://sentry.xuqinmin.com'
+
+interface AuthState {
+ ready: boolean
+ userId: string | null
+ profile: UserProfile | null
+}
+
+interface AuthContextValue extends AuthState {
+ login(userId: string, password: string): Promise
+ register(userId: string, password: string, nickname: string): Promise
+ logout(): Promise
+ refreshProfile(): Promise
+ updateProfile(data: Partial>): Promise
+}
+
+const AuthContext = createContext(null)
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [state, setState] = useState({ ready: false, userId: null, profile: null })
+
+ const initSDK = useCallback(async (userId: string, imToken: string, profile: UserProfile) => {
+ await XuqmSDK.initialize({ appId: APP_ID, serverUrl: SERVER_URL })
+ await ImSDK.loginWithToken(userId, imToken, 'xuqm_im')
+ setState({ ready: true, userId, profile })
+ }, [])
+
+ useEffect(() => {
+ const restore = async () => {
+ try {
+ const demoToken = await load(K.DEMO_TOKEN)
+ const imToken = await load(K.IM_TOKEN)
+ const profile = await load(K.PROFILE)
+ if (demoToken && imToken && profile) {
+ await initSDK(profile.userId, imToken, profile)
+ return
+ }
+ } catch {
+ await clearSession()
+ }
+ setState(s => ({ ...s, ready: true }))
+ }
+ restore()
+ }, [initSDK])
+
+ const handleAuthResult = useCallback(async (result: { demoToken: string; imToken: string; profile: UserProfile }) => {
+ await save(K.DEMO_TOKEN, result.demoToken)
+ await save(K.IM_TOKEN, result.imToken)
+ await save(K.PROFILE, result.profile)
+ await initSDK(result.profile.userId, result.imToken, result.profile)
+ }, [initSDK])
+
+ const login = useCallback(async (userId: string, password: string) => {
+ const result = await demoApi.login(userId, password)
+ await handleAuthResult(result)
+ }, [handleAuthResult])
+
+ const register = useCallback(async (userId: string, password: string, nickname: string) => {
+ const result = await demoApi.register(userId, password, nickname)
+ await handleAuthResult(result)
+ }, [handleAuthResult])
+
+ const logout = useCallback(async () => {
+ ImSDK.disconnect()
+ await clearSession()
+ setState({ ready: true, userId: null, profile: null })
+ }, [])
+
+ const refreshProfile = useCallback(async () => {
+ const profile = await demoApi.getProfile()
+ await save(K.PROFILE, profile)
+ setState(s => ({ ...s, profile }))
+ }, [])
+
+ const updateProfile = useCallback(async (data: Partial>) => {
+ const profile = await demoApi.updateProfile(data)
+ await save(K.PROFILE, profile)
+ setState(s => ({ ...s, profile }))
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useAuth(): AuthContextValue {
+ const ctx = useContext(AuthContext)
+ if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
+ return ctx
+}
diff --git a/src/context/IMContext.tsx b/src/context/IMContext.tsx
new file mode 100644
index 0000000..b7f5a31
--- /dev/null
+++ b/src/context/IMContext.tsx
@@ -0,0 +1,51 @@
+import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
+import { ImSDK, type ImEventListener } from '@xuqm/rn-sdk'
+import { useAuth } from './AuthContext'
+
+type ConnStatus = 'connected' | 'connecting' | 'disconnected'
+
+interface IMContextValue {
+ status: ConnStatus
+ reconnect(): void
+}
+
+const IMContext = createContext({ status: 'connecting', reconnect: () => {} })
+
+export function IMProvider({ children }: { children: React.ReactNode }) {
+ const { userId } = useAuth()
+ const [status, setStatus] = useState('connecting')
+ const retryRef = useRef | null>(null)
+
+ const clearRetry = () => {
+ if (retryRef.current) { clearTimeout(retryRef.current); retryRef.current = null }
+ }
+
+ const reconnect = useCallback(() => {
+ setStatus('connecting')
+ ImSDK.reconnect().catch(() => setStatus('disconnected'))
+ }, [])
+
+ useEffect(() => {
+ if (!userId) return
+
+ const listener: ImEventListener = {
+ onConnected() { clearRetry(); setStatus('connected') },
+ onDisconnected() {
+ setStatus('disconnected')
+ retryRef.current = setTimeout(reconnect, 5000)
+ },
+ onError() { setStatus('disconnected') },
+ }
+ ImSDK.addListener(listener)
+ return () => {
+ clearRetry()
+ ImSDK.removeListener(listener)
+ }
+ }, [userId, reconnect])
+
+ return {children}
+}
+
+export function useIM(): IMContextValue {
+ return useContext(IMContext)
+}
diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
new file mode 100644
index 0000000..381afaf
--- /dev/null
+++ b/src/navigation/AppNavigator.tsx
@@ -0,0 +1,104 @@
+import React from 'react'
+import { NavigationContainer } from '@react-navigation/native'
+import { createNativeStackNavigator } from '@react-navigation/native-stack'
+import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
+import { Text, View, ActivityIndicator, StyleSheet } from 'react-native'
+import { useAuth } from '../context/AuthContext'
+import { IMProvider } from '../context/IMContext'
+import type { AuthStackParams, MainTabParams, RootStackParams } from './types'
+
+import LoginScreen from '../screens/auth/LoginScreen'
+import RegisterScreen from '../screens/auth/RegisterScreen'
+import ResetPasswordScreen from '../screens/auth/ResetPasswordScreen'
+import ConversationListScreen from '../screens/conversation/ConversationListScreen'
+import ContactsScreen from '../screens/contact/ContactsScreen'
+import ProfileScreen from '../screens/profile/ProfileScreen'
+import SingleChatScreen from '../screens/chat/SingleChatScreen'
+import GroupChatScreen from '../screens/chat/GroupChatScreen'
+import UserSearchScreen from '../screens/contact/UserSearchScreen'
+import GroupListScreen from '../screens/group/GroupListScreen'
+import CreateGroupScreen from '../screens/group/CreateGroupScreen'
+import GroupMembersScreen from '../screens/group/GroupMembersScreen'
+import GroupSettingsScreen from '../screens/group/GroupSettingsScreen'
+import EditProfileScreen from '../screens/profile/EditProfileScreen'
+import MessageSearchScreen from '../screens/chat/MessageSearchScreen'
+
+const AuthStack = createNativeStackNavigator()
+const Tab = createBottomTabNavigator()
+const Root = createNativeStackNavigator()
+
+function TabIcon({ name, focused }: { name: string; focused: boolean }) {
+ const icons: Record = {
+ ConversationList: ['💬', '🗨️'],
+ Contacts: ['👥', '👤'],
+ Profile: ['👤', '🧑'],
+ }
+ return {(icons[name] ?? ['?', '?'])[focused ? 0 : 1]}
+}
+
+function MainTabs() {
+ return (
+ ({
+ tabBarIcon: ({ focused }) => ,
+ tabBarActiveTintColor: '#07C160',
+ tabBarInactiveTintColor: '#888',
+ headerShown: false,
+ })}>
+
+
+
+
+ )
+}
+
+function AppStack() {
+ return (
+
+
+
+ ({ title: route.params.targetName })} />
+ ({ title: route.params.groupName })} />
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function AuthFlow() {
+ return (
+
+
+
+
+
+ )
+}
+
+export default function AppNavigator() {
+ const { ready, userId } = useAuth()
+
+ if (!ready) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {userId ? : }
+
+ )
+}
+
+const styles = StyleSheet.create({
+ loading: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#fff' },
+})
diff --git a/src/navigation/types.ts b/src/navigation/types.ts
new file mode 100644
index 0000000..d091e45
--- /dev/null
+++ b/src/navigation/types.ts
@@ -0,0 +1,24 @@
+export type AuthStackParams = {
+ Login: undefined
+ Register: undefined
+ ResetPassword: undefined
+}
+
+export type MainTabParams = {
+ ConversationList: undefined
+ Contacts: undefined
+ Profile: undefined
+}
+
+export type RootStackParams = {
+ Main: undefined
+ SingleChat: { targetId: string; targetName: string; targetAvatar?: string }
+ GroupChat: { groupId: string; groupName: string }
+ UserSearch: undefined
+ GroupList: undefined
+ CreateGroup: undefined
+ GroupMembers: { groupId: string; groupName: string }
+ GroupSettings: { groupId: string; groupName: string; isAdmin: boolean }
+ EditProfile: undefined
+ MessageSearch: { targetId?: string; chatType?: 'SINGLE' | 'GROUP' }
+}
diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx
new file mode 100644
index 0000000..2299aa3
--- /dev/null
+++ b/src/screens/auth/LoginScreen.tsx
@@ -0,0 +1,75 @@
+import React, { useState } from 'react'
+import {
+ View, Text, TextInput, TouchableOpacity, StyleSheet,
+ KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
+} from 'react-native'
+import type { NativeStackScreenProps } from '@react-navigation/native-stack'
+import type { AuthStackParams } from '../../navigation/types'
+import { useAuth } from '../../context/AuthContext'
+
+type Props = NativeStackScreenProps
+
+export default function LoginScreen({ navigation }: Props) {
+ const { login } = useAuth()
+ const [userId, setUserId] = useState('')
+ const [password, setPassword] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ const handleLogin = async () => {
+ const uid = userId.trim()
+ const pwd = password.trim()
+ if (!uid || !pwd) { Alert.alert('提示', '请输入用户名和密码'); return }
+ setLoading(true)
+ try {
+ await login(uid, pwd)
+ } catch (e: any) {
+ Alert.alert('登录失败', e?.message ?? '请检查用户名和密码')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ XuqmChat
+
+
+
+ {loading ? : 登录}
+
+
+ navigation.navigate('Register')}>
+ 注册账号
+
+ navigation.navigate('ResetPassword')}>
+ 忘记密码
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ root: { flex: 1, backgroundColor: '#f5f5f5', justifyContent: 'center' },
+ card: { margin: 24, backgroundColor: '#fff', borderRadius: 12, padding: 24, elevation: 2 },
+ title: { fontSize: 28, fontWeight: '700', color: '#07C160', textAlign: 'center', marginBottom: 32 },
+ input: { borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, padding: 12, fontSize: 16, marginBottom: 16 },
+ btn: { backgroundColor: '#07C160', borderRadius: 8, padding: 14, alignItems: 'center', marginTop: 4 },
+ btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
+ row: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 16 },
+ link: { color: '#07C160', fontSize: 14 },
+})
diff --git a/src/screens/auth/RegisterScreen.tsx b/src/screens/auth/RegisterScreen.tsx
new file mode 100644
index 0000000..55e6193
--- /dev/null
+++ b/src/screens/auth/RegisterScreen.tsx
@@ -0,0 +1,65 @@
+import React, { useState } from 'react'
+import {
+ View, Text, TextInput, TouchableOpacity, StyleSheet,
+ KeyboardAvoidingView, Platform, ActivityIndicator, Alert, ScrollView,
+} from 'react-native'
+import type { NativeStackScreenProps } from '@react-navigation/native-stack'
+import type { AuthStackParams } from '../../navigation/types'
+import { useAuth } from '../../context/AuthContext'
+
+type Props = NativeStackScreenProps
+
+export default function RegisterScreen({ navigation }: Props) {
+ const { register } = useAuth()
+ const [userId, setUserId] = useState('')
+ const [nickname, setNickname] = useState('')
+ const [password, setPassword] = useState('')
+ const [confirm, setConfirm] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ const handleRegister = async () => {
+ const uid = userId.trim()
+ const nick = nickname.trim()
+ const pwd = password.trim()
+ if (!uid || !nick || !pwd) { Alert.alert('提示', '请填写所有字段'); return }
+ if (pwd !== confirm.trim()) { Alert.alert('提示', '两次密码不一致'); return }
+ if (pwd.length < 6) { Alert.alert('提示', '密码至少6位'); return }
+ setLoading(true)
+ try {
+ await register(uid, pwd, nick)
+ } catch (e: any) {
+ Alert.alert('注册失败', e?.message ?? '请稍后重试')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ 创建账号
+
+
+
+
+
+ {loading ? : 注册}
+
+ navigation.goBack()}>
+ 已有账号?去登录
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ root: { flex: 1, backgroundColor: '#f5f5f5' },
+ inner: { padding: 24, paddingTop: 60 },
+ title: { fontSize: 26, fontWeight: '700', color: '#333', marginBottom: 28 },
+ input: { borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, padding: 12, fontSize: 16, marginBottom: 16, backgroundColor: '#fff' },
+ btn: { backgroundColor: '#07C160', borderRadius: 8, padding: 14, alignItems: 'center', marginTop: 4 },
+ btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
+ backBtn: { alignItems: 'center', marginTop: 16 },
+ link: { color: '#07C160', fontSize: 14 },
+})
diff --git a/src/screens/auth/ResetPasswordScreen.tsx b/src/screens/auth/ResetPasswordScreen.tsx
new file mode 100644
index 0000000..9bf7b68
--- /dev/null
+++ b/src/screens/auth/ResetPasswordScreen.tsx
@@ -0,0 +1,62 @@
+import React, { useState } from 'react'
+import {
+ View, Text, TextInput, TouchableOpacity, StyleSheet,
+ KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
+} from 'react-native'
+import type { NativeStackScreenProps } from '@react-navigation/native-stack'
+import type { AuthStackParams } from '../../navigation/types'
+import { demoApi } from '../../api/demo'
+
+type Props = NativeStackScreenProps
+
+export default function ResetPasswordScreen({ navigation }: Props) {
+ const [userId, setUserId] = useState('')
+ const [newPassword, setNewPassword] = useState('')
+ const [confirm, setConfirm] = useState('')
+ const [loading, setLoading] = useState(false)
+
+ const handleReset = async () => {
+ const uid = userId.trim()
+ const pwd = newPassword.trim()
+ if (!uid || !pwd) { Alert.alert('提示', '请填写所有字段'); return }
+ if (pwd !== confirm.trim()) { Alert.alert('提示', '两次密码不一致'); return }
+ if (pwd.length < 6) { Alert.alert('提示', '密码至少6位'); return }
+ setLoading(true)
+ try {
+ await demoApi.resetPassword(uid, pwd)
+ Alert.alert('成功', '密码已重置,请重新登录', [{ text: '确定', onPress: () => navigation.navigate('Login') }])
+ } catch (e: any) {
+ Alert.alert('失败', e?.message ?? '操作失败,请重试')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ 重置密码
+
+
+
+
+ {loading ? : 重置密码}
+
+ navigation.goBack()}>
+ 返回登录
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ root: { flex: 1, backgroundColor: '#f5f5f5', justifyContent: 'center' },
+ inner: { padding: 24 },
+ title: { fontSize: 26, fontWeight: '700', color: '#333', marginBottom: 28 },
+ input: { borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, padding: 12, fontSize: 16, marginBottom: 16, backgroundColor: '#fff' },
+ btn: { backgroundColor: '#07C160', borderRadius: 8, padding: 14, alignItems: 'center', marginTop: 4 },
+ btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
+ backBtn: { alignItems: 'center', marginTop: 16 },
+ link: { color: '#07C160', fontSize: 14 },
+})
diff --git a/src/screens/chat/GroupChatScreen.tsx b/src/screens/chat/GroupChatScreen.tsx
new file mode 100644
index 0000000..74d981a
--- /dev/null
+++ b/src/screens/chat/GroupChatScreen.tsx
@@ -0,0 +1,122 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react'
+import {
+ View, FlatList, StyleSheet, Alert, SafeAreaView,
+ KeyboardAvoidingView, Platform, TouchableOpacity, Text,
+} from 'react-native'
+import type { NativeStackScreenProps } from '@react-navigation/native-stack'
+import { ImSDK } from '@xuqm/rn-sdk'
+import type { ImMessage, ImEventListener } from '@xuqm/rn-sdk'
+import { useAuth } from '../../context/AuthContext'
+import type { RootStackParams } from '../../navigation/types'
+import { MessageBubble } from '../../components/MessageBubble'
+import ChatInput from '../../components/ChatInput'
+import DisconnectBanner from '../../components/DisconnectBanner'
+
+type Props = NativeStackScreenProps
+
+export default function GroupChatScreen({ route, navigation }: Props) {
+ const { groupId, groupName } = route.params
+ const { userId } = useAuth()
+ const [messages, setMessages] = useState([])
+ const [page, setPage] = useState(0)
+ const [loadingMore, setLoadingMore] = useState(false)
+ const listRef = useRef>(null)
+
+ const loadHistory = useCallback(async (p = 0) => {
+ try {
+ const history = await ImSDK.fetchGroupHistory(groupId, p, 30)
+ setMessages(prev => p === 0 ? history : [...history, ...prev])
+ setPage(p)
+ } catch {/* ignore */}
+ }, [groupId])
+
+ useEffect(() => {
+ loadHistory(0)
+ ImSDK.subscribeGroup(groupId)
+ ImSDK.markRead(groupId).catch(() => {})
+
+ const listener: ImEventListener = {
+ onGroupMessage(msg) {
+ if (msg.toId !== groupId) return
+ setMessages(prev => {
+ if (prev.find(m => m.id === msg.id)) return prev
+ return [...prev, msg]
+ })
+ setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
+ },
+ }
+ ImSDK.addListener(listener)
+ return () => ImSDK.removeListener(listener)
+ }, [groupId, loadHistory])
+
+ useEffect(() => {
+ navigation.setOptions({
+ headerRight: () => (
+ navigation.navigate('GroupSettings', { groupId, groupName, isAdmin: false })}>
+ ⚙️
+
+ ),
+ })
+ }, [navigation, groupId, groupName])
+
+ const onSent = (msg: ImMessage) => {
+ setMessages(prev => {
+ if (prev.find(m => m.id === msg.id)) return prev
+ return [...prev, msg]
+ })
+ setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
+ }
+
+ const handleRevoke = async (messageId: string) => {
+ try {
+ const revoked = await ImSDK.revokeMessage(messageId)
+ setMessages(prev => prev.map(m => m.id === revoked.id ? revoked : m))
+ } catch (e: any) {
+ Alert.alert('撤回失败', e?.message ?? '操作失败')
+ }
+ }
+
+ const loadMore = async () => {
+ if (loadingMore) return
+ setLoadingMore(true)
+ await loadHistory(page + 1)
+ setLoadingMore(false)
+ }
+
+ return (
+
+
+
+ m.id}
+ renderItem={({ item }) => (
+
+ )}
+ contentContainerStyle={styles.list}
+ onEndReachedThreshold={0.1}
+ onEndReached={loadMore}
+ maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
+ />
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ root: { flex: 1, backgroundColor: '#f0ede8' },
+ flex: { flex: 1 },
+ list: { padding: 8, paddingBottom: 4 },
+ settingsIcon: { fontSize: 20, marginRight: 8 },
+})
diff --git a/src/screens/chat/MessageSearchScreen.tsx b/src/screens/chat/MessageSearchScreen.tsx
new file mode 100644
index 0000000..447a48b
--- /dev/null
+++ b/src/screens/chat/MessageSearchScreen.tsx
@@ -0,0 +1,111 @@
+import React, { useState, useCallback, useRef } from 'react'
+import {
+ View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity,
+ SafeAreaView, ActivityIndicator,
+} from 'react-native'
+import { useNavigation } from '@react-navigation/native'
+import type { NativeStackScreenProps } from '@react-navigation/native-stack'
+import { ImSDK } from '@xuqm/rn-sdk'
+import type { RootStackParams } from '../../navigation/types'
+import { useAuth } from '../../context/AuthContext'
+import { formatTime } from '../../utils/format'
+
+type Props = NativeStackScreenProps
+
+export default function MessageSearchScreen({ route }: Props) {
+ const { targetId, chatType } = route.params ?? {}
+ const navigation = useNavigation()
+ const { userId } = useAuth()
+ const [keyword, setKeyword] = useState('')
+ const [results, setResults] = useState([])
+ const [loading, setLoading] = useState(false)
+ const debounceRef = useRef | null>(null)
+
+ const search = useCallback((text: string) => {
+ setKeyword(text)
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ if (!text.trim()) { setResults([]); return }
+ debounceRef.current = setTimeout(async () => {
+ setLoading(true)
+ try {
+ const msgs = await ImSDK.searchMessages({
+ keyword: text.trim(),
+ toId: targetId,
+ chatType: chatType,
+ })
+ setResults(msgs)
+ } catch {
+ setResults([])
+ } finally {
+ setLoading(false)
+ }
+ }, 300)
+ }, [targetId, chatType])
+
+ const openChat = (item: any) => {
+ if (item.chatType === 'GROUP') {
+ ;(navigation as any).navigate('GroupChat', { groupId: item.toId, groupName: item.toId })
+ } else {
+ const peerId = item.fromUserId === userId ? item.toId : item.fromUserId
+ ;(navigation as any).navigate('SingleChat', { targetId: peerId, targetName: peerId })
+ }
+ }
+
+ return (
+
+
+
+ {loading && }
+
+
+ item.serverId ?? item.id ?? Math.random().toString()}
+ renderItem={({ item }) => {
+ const isMine = item.fromUserId === userId
+ const time = item.serverCreatedAt ? formatTime(item.serverCreatedAt) : ''
+ return (
+ openChat(item)}>
+
+ {isMine ? '我' : item.fromUserId}
+ {time}
+
+ {item.content}
+
+ {item.chatType === 'GROUP' ? `群: ${item.toId}` : `与: ${item.fromUserId === userId ? item.toId : item.fromUserId}`}
+
+
+ )
+ }}
+ ItemSeparatorComponent={() => }
+ ListEmptyComponent={
+ keyword.trim() && !loading
+ ? 未找到相关消息
+ : null
+ }
+ />
+
+ )
+}
+
+const styles = StyleSheet.create({
+ root: { flex: 1, backgroundColor: '#f5f5f5' },
+ searchBar: { flexDirection: 'row', alignItems: 'center', margin: 12, backgroundColor: '#fff', borderRadius: 8, paddingHorizontal: 12, borderWidth: 1, borderColor: '#e0e0e0' },
+ input: { flex: 1, fontSize: 16, paddingVertical: 10 },
+ row: { backgroundColor: '#fff', padding: 14 },
+ rowTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
+ sender: { fontSize: 13, fontWeight: '600', color: '#555' },
+ time: { fontSize: 12, color: '#999' },
+ content: { fontSize: 15, color: '#111', marginBottom: 4 },
+ target: { fontSize: 12, color: '#aaa' },
+ sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0' },
+ empty: { alignItems: 'center', paddingTop: 60 },
+ emptyText: { color: '#bbb', fontSize: 15 },
+})
diff --git a/src/screens/chat/SingleChatScreen.tsx b/src/screens/chat/SingleChatScreen.tsx
new file mode 100644
index 0000000..e5dc8e6
--- /dev/null
+++ b/src/screens/chat/SingleChatScreen.tsx
@@ -0,0 +1,122 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react'
+import {
+ View, FlatList, StyleSheet, Alert, SafeAreaView,
+ KeyboardAvoidingView, Platform, TouchableOpacity, Text,
+} from 'react-native'
+import type { NativeStackScreenProps } from '@react-navigation/native-stack'
+import { ImSDK } from '@xuqm/rn-sdk'
+import type { ImMessage, ImEventListener } from '@xuqm/rn-sdk'
+import { useAuth } from '../../context/AuthContext'
+import type { RootStackParams } from '../../navigation/types'
+import { MessageBubble } from '../../components/MessageBubble'
+import ChatInput from '../../components/ChatInput'
+import DisconnectBanner from '../../components/DisconnectBanner'
+
+type Props = NativeStackScreenProps
+
+export default function SingleChatScreen({ route, navigation }: Props) {
+ const { targetId, targetName, targetAvatar } = route.params
+ const { userId, profile } = useAuth()
+ const [messages, setMessages] = useState([])
+ const [page, setPage] = useState(0)
+ const [loadingMore, setLoadingMore] = useState(false)
+ const listRef = useRef>(null)
+
+ const loadHistory = useCallback(async (p = 0) => {
+ try {
+ const history = await ImSDK.fetchHistory(targetId, p, 30)
+ setMessages(prev => p === 0 ? history : [...history, ...prev])
+ setPage(p)
+ } catch {/* ignore */}
+ }, [targetId])
+
+ useEffect(() => {
+ loadHistory(0)
+
+ const listener: ImEventListener = {
+ onMessage(msg) {
+ if ((msg.fromUserId === targetId && msg.toId === userId) ||
+ (msg.fromUserId === userId && msg.toId === targetId)) {
+ setMessages(prev => {
+ if (prev.find(m => m.id === msg.id)) return prev
+ return [...prev, msg]
+ })
+ setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
+ }
+ },
+ }
+ ImSDK.addListener(listener)
+ ImSDK.markRead(targetId).catch(() => {})
+
+ return () => ImSDK.removeListener(listener)
+ }, [targetId, userId, loadHistory])
+
+ const onSent = (msg: ImMessage) => {
+ setMessages(prev => {
+ if (prev.find(m => m.id === msg.id)) return prev
+ return [...prev, msg]
+ })
+ setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
+ }
+
+ const handleRevoke = async (messageId: string) => {
+ try {
+ const revoked = await ImSDK.revokeMessage(messageId)
+ setMessages(prev => prev.map(m => m.id === revoked.id ? revoked : m))
+ } catch (e: any) {
+ Alert.alert('撤回失败', e?.message ?? '操作失败')
+ }
+ }
+
+ const handleLongPress = (msg: ImMessage) => {
+ if (msg.fromUserId !== userId) return
+ if (msg.status === 'REVOKED' || msg.msgType === 'REVOKED') return
+ Alert.alert('操作', '撤回这条消息?', [
+ { text: '取消', style: 'cancel' },
+ { text: '撤回', style: 'destructive', onPress: () => handleRevoke(msg.id) },
+ ])
+ }
+
+ const loadMore = async () => {
+ if (loadingMore) return
+ setLoadingMore(true)
+ await loadHistory(page + 1)
+ setLoadingMore(false)
+ }
+
+ return (
+
+
+
+ m.id}
+ renderItem={({ item }) => (
+
+ )}
+ contentContainerStyle={styles.list}
+ onEndReachedThreshold={0.1}
+ onEndReached={loadMore}
+ maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
+ />
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ root: { flex: 1, backgroundColor: '#f0ede8' },
+ flex: { flex: 1 },
+ list: { padding: 8, paddingBottom: 4 },
+})
diff --git a/src/screens/contact/ContactsScreen.tsx b/src/screens/contact/ContactsScreen.tsx
new file mode 100644
index 0000000..c66fa51
--- /dev/null
+++ b/src/screens/contact/ContactsScreen.tsx
@@ -0,0 +1,89 @@
+import React, { useEffect, useState } from 'react'
+import {
+ View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
+ TextInput, Alert,
+} from 'react-native'
+import { useNavigation } from '@react-navigation/native'
+import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
+import type { RootStackParams } from '../../navigation/types'
+import { demoApi, type UserProfile } from '../../api/demo'
+import { load, save, K } from '../../utils/storage'
+
+type Nav = NativeStackNavigationProp
+
+function ContactRow({ user, onPress }: { user: UserProfile; onPress(): void }) {
+ const letter = (user.nickname || user.userId).charAt(0).toUpperCase()
+ return (
+
+ {letter}
+
+ {user.nickname}
+ @{user.userId}
+
+ ›
+
+ )
+}
+
+export default function ContactsScreen() {
+ const navigation = useNavigation