From e5adc00b448c1729d55ff3fd20890d2a6c2583e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8B=A4=E6=B0=91?= Date: Sat, 25 Apr 2026 16:41:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20complete=20production=20chat=20demo=20?= =?UTF-8?q?=E2=80=94=20all=20screens,=20real=20media,=20SDK=20remote=20ini?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx: replaced dev console with AuthProvider + AppNavigator - Auth: Login, Register, ResetPassword screens via demo-service - Conversations: reactive WatermelonDB subscription with user profile enrichment - Chat: SingleChat + GroupChat with full media (image/video/audio/file), revoke, pull-up load more - Contacts: local contacts list + UserSearch with debounced fuzzy search - Groups: GroupList, CreateGroup (fuzzy member picker), GroupMembers, GroupSettings - Profile: view + EditProfile (nickname, gender) - MessageSearch: local DB full-text search across conversations - ChatInput: text, 20-emoji picker, image/video/audio/file send, tap-to-record audio - DisconnectBanner: connection status with reconnect - AuthContext: uses await XuqmSDK.initialize({ appId, serverUrl }) for remote config Co-Authored-By: Claude Sonnet 4.6 --- App.tsx | 373 +- babel.config.js | 1 + metro.config.js | 22 +- package.json | 13 +- src/api/demo.ts | 85 + src/components/ChatInput.tsx | 201 + src/components/ConversationItem.tsx | 67 + src/components/DisconnectBanner.tsx | 32 + src/context/AuthContext.tsx | 98 + src/context/IMContext.tsx | 51 + src/navigation/AppNavigator.tsx | 104 + src/navigation/types.ts | 24 + src/screens/auth/LoginScreen.tsx | 75 + src/screens/auth/RegisterScreen.tsx | 65 + src/screens/auth/ResetPasswordScreen.tsx | 62 + src/screens/chat/GroupChatScreen.tsx | 122 + src/screens/chat/MessageSearchScreen.tsx | 111 + src/screens/chat/SingleChatScreen.tsx | 122 + src/screens/contact/ContactsScreen.tsx | 89 + src/screens/contact/UserSearchScreen.tsx | 114 + .../conversation/ConversationListScreen.tsx | 84 + src/screens/group/CreateGroupScreen.tsx | 151 + src/screens/group/GroupListScreen.tsx | 93 + src/screens/group/GroupMembersScreen.tsx | 81 + src/screens/group/GroupSettingsScreen.tsx | 65 + src/screens/profile/EditProfileScreen.tsx | 80 + src/screens/profile/ProfileScreen.tsx | 75 + src/utils/format.ts | 33 + src/utils/storage.ts | 26 + yarn.lock | 6708 +++++++++++++++++ 30 files changed, 8855 insertions(+), 372 deletions(-) create mode 100644 src/api/demo.ts create mode 100644 src/components/ChatInput.tsx create mode 100644 src/components/ConversationItem.tsx create mode 100644 src/components/DisconnectBanner.tsx create mode 100644 src/context/AuthContext.tsx create mode 100644 src/context/IMContext.tsx create mode 100644 src/navigation/AppNavigator.tsx create mode 100644 src/navigation/types.ts create mode 100644 src/screens/auth/LoginScreen.tsx create mode 100644 src/screens/auth/RegisterScreen.tsx create mode 100644 src/screens/auth/ResetPasswordScreen.tsx create mode 100644 src/screens/chat/GroupChatScreen.tsx create mode 100644 src/screens/chat/MessageSearchScreen.tsx create mode 100644 src/screens/chat/SingleChatScreen.tsx create mode 100644 src/screens/contact/ContactsScreen.tsx create mode 100644 src/screens/contact/UserSearchScreen.tsx create mode 100644 src/screens/conversation/ConversationListScreen.tsx create mode 100644 src/screens/group/CreateGroupScreen.tsx create mode 100644 src/screens/group/GroupListScreen.tsx create mode 100644 src/screens/group/GroupMembersScreen.tsx create mode 100644 src/screens/group/GroupSettingsScreen.tsx create mode 100644 src/screens/profile/EditProfileScreen.tsx create mode 100644 src/screens/profile/ProfileScreen.tsx create mode 100644 src/utils/format.ts create mode 100644 src/utils/storage.ts create mode 100644 yarn.lock 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