From 069f3454fec219c93b6f3d0826cb42d07017d4db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8B=A4=E6=B0=91?= Date: Tue, 28 Apr 2026 20:11:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=B7=BB=E5=8A=A0=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E7=95=8C=E9=9D=A2=E5=92=8C=E6=96=87=E4=BB=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0SDK=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现完整的聊天界面UI组件,支持文本、图片、视频、音频、文件等多种消息类型 - 集成IM消息收发功能,实现消息气泡显示和用户头像占位符 - 添加媒体文件选择和拍摄功能,支持相册图片、视频及相机拍照录像 - 实现语音录制和播放功能,包含按住说话交互和权限处理 - 添加群组提及功能,支持@用户和提及候选列表显示 - 实现消息回复和引用功能,支持消息长按回复操作 - 添加本地消息搜索功能,支持搜索当前会话的历史消息 - 实现文件上传下载功能,集成FileSDK进行文件传输管理 - 添加应用更新检查功能,集成UpdateSDK支持版本更新 - 实现消息状态显示,包括发送、送达、已读等状态标识 - 添加群组已读人数统计,显示消息在群聊中的阅读情况 - 实现草稿保存和恢复功能,支持断点续聊体验 - 添加连接状态横幅,实时显示IM服务连接状态 - 实现滚动加载更多历史消息,优化大量消息的性能表现 - 添加多媒体文件下载保存功能,支持保存到应用专属目录 --- src/components/ConversationItem.tsx | 2 +- src/components/MessageComposer.tsx | 15 ++ src/navigation/AppNavigator.tsx | 4 + src/navigation/types.ts | 4 +- src/screens/chat/GroupChatScreen.tsx | 42 +++-- src/screens/chat/SingleChatScreen.tsx | 29 ++- src/screens/contact/ContactsScreen.tsx | 83 +++++++-- src/screens/contact/FriendRequestsScreen.tsx | 134 ++++++++++++++ src/screens/contact/UserSearchScreen.tsx | 70 ++++++-- .../conversation/ConversationListScreen.tsx | 8 +- src/screens/group/CreateGroupScreen.tsx | 9 +- src/screens/group/GroupJoinRequestsScreen.tsx | 170 ++++++++++++++++++ src/screens/group/GroupListScreen.tsx | 41 ++++- src/screens/group/GroupMembersScreen.tsx | 17 +- src/screens/group/GroupSettingsScreen.tsx | 39 ++-- src/utils/userProfiles.ts | 25 +++ tsconfig.json | 15 +- 17 files changed, 620 insertions(+), 87 deletions(-) create mode 100644 src/screens/contact/FriendRequestsScreen.tsx create mode 100644 src/screens/group/GroupJoinRequestsScreen.tsx create mode 100644 src/utils/userProfiles.ts diff --git a/src/components/ConversationItem.tsx b/src/components/ConversationItem.tsx index 8fc1959..fd263cd 100644 --- a/src/components/ConversationItem.tsx +++ b/src/components/ConversationItem.tsx @@ -27,7 +27,7 @@ function lastMsgPreview(item: ConversationData): string { case 'VIDEO': return '[视频]' case 'AUDIO': return '[语音]' case 'FILE': return '[文件]' - default: return item.lastMsgContent + default: return item.lastMsgContent ?? '' } } diff --git a/src/components/MessageComposer.tsx b/src/components/MessageComposer.tsx index 7623e9a..65234fb 100644 --- a/src/components/MessageComposer.tsx +++ b/src/components/MessageComposer.tsx @@ -15,6 +15,8 @@ const ALL_MSG_TYPES: MsgType[] = [ 'CALL_AUDIO', 'CALL_VIDEO', 'FORWARD', + 'QUOTE', + 'MERGE', ] const TYPE_LABELS: Record = { @@ -30,6 +32,8 @@ const TYPE_LABELS: Record = { CALL_AUDIO: '语音通话', CALL_VIDEO: '视频通话', FORWARD: '转发', + QUOTE: '引用', + MERGE: '合并', REVOKED: '已撤回', } @@ -46,6 +50,8 @@ const TYPE_ICONS: Record = { CALL_AUDIO: '📞', CALL_VIDEO: '📹', FORWARD: '↪️', + QUOTE: '💭', + MERGE: '🗂️', REVOKED: '🚫', } @@ -110,6 +116,15 @@ export const DEMO_CONTENT: Record = { originalSender: 'demo_alice', originalTime: new Date().toISOString(), }), + QUOTE: JSON.stringify({ + quotedMsgId: 'msg_quote_001', + quotedContent: '这是被引用的消息', + text: '这是一条引用消息', + }), + MERGE: JSON.stringify({ + title: '合并转发预览', + msgList: ['消息 1', '消息 2', '消息 3'], + }), REVOKED: '', } diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index d1572eb..f3d2c45 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -16,10 +16,12 @@ 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 FriendRequestsScreen from '../screens/contact/FriendRequestsScreen' 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 GroupJoinRequestsScreen from '../screens/group/GroupJoinRequestsScreen' import EditProfileScreen from '../screens/profile/EditProfileScreen' import MessageSearchScreen from '../screens/chat/MessageSearchScreen' import DisconnectBanner from '../components/DisconnectBanner' @@ -62,10 +64,12 @@ function AppStack() { ({ title: route.params.targetName })} /> ({ title: route.params.groupName })} /> + + diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 6649692..65c4d25 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -15,10 +15,12 @@ export type RootStackParams = { SingleChat: { targetId: string; targetName: string; targetAvatar?: string } GroupChat: { groupId: string; groupName: string } UserSearch: { addToGroupId?: string } | undefined + FriendRequests: undefined GroupList: undefined CreateGroup: undefined GroupMembers: { groupId: string; groupName: string } - GroupSettings: { groupId: string; groupName: string; isAdmin: boolean } + GroupSettings: { groupId: string; groupName: string; isAdmin?: boolean } + GroupJoinRequests: { groupId: string; groupName: string } EditProfile: undefined MessageSearch: { targetId?: string; chatType?: 'SINGLE' | 'GROUP' } } diff --git a/src/screens/chat/GroupChatScreen.tsx b/src/screens/chat/GroupChatScreen.tsx index e781645..16ccd5d 100644 --- a/src/screens/chat/GroupChatScreen.tsx +++ b/src/screens/chat/GroupChatScreen.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { - View, FlatList, StyleSheet, Alert, SafeAreaView, + FlatList, StyleSheet, Alert, SafeAreaView, KeyboardAvoidingView, Platform, TouchableOpacity, Text, } from 'react-native' import type { NativeStackScreenProps } from '@react-navigation/native-stack' @@ -13,6 +13,28 @@ import ChatInput from '../../components/ChatInput' type Props = NativeStackScreenProps +function GroupHeaderButton({ + navigation, + groupId, + groupName, +}: Pick & { groupId: string; groupName: string }) { + return ( + navigation.navigate('GroupSettings', { groupId, groupName, isAdmin: false })}> + ⚙️ + + ) +} + +function upsertMessage(messages: ImMessage[], message: ImMessage): ImMessage[] { + const index = messages.findIndex(item => item.id === message.id) + if (index >= 0) { + const next = [...messages] + next[index] = message + return next + } + return [...messages, message] +} + export default function GroupChatScreen({ route, navigation }: Props) { const { groupId, groupName } = route.params const { userId } = useAuth() @@ -24,7 +46,10 @@ export default function GroupChatScreen({ route, navigation }: Props) { const loadHistory = useCallback(async (p = 0) => { try { const history = await ImSDK.fetchGroupHistory(groupId, p, 30) - setMessages(prev => p === 0 ? history : [...history, ...prev]) + setMessages(prev => { + const merged = p === 0 ? history : [...history, ...prev] + return merged.reduce((acc, message) => upsertMessage(acc, message), []) + }) setPage(p) } catch {/* ignore */} }, [groupId]) @@ -38,8 +63,7 @@ export default function GroupChatScreen({ route, navigation }: Props) { onGroupMessage(msg) { if (msg.toId !== groupId) return setMessages(prev => { - if (prev.find(m => m.id === msg.id)) return prev - return [...prev, msg] + return upsertMessage(prev, msg) }) setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100) }, @@ -50,18 +74,14 @@ export default function GroupChatScreen({ route, navigation }: Props) { useEffect(() => { navigation.setOptions({ - headerRight: () => ( - navigation.navigate('GroupSettings', { groupId, groupName, isAdmin: false })}> - ⚙️ - - ), + // eslint-disable-next-line react/no-unstable-nested-components + headerRight: () => , }) }, [navigation, groupId, groupName]) const onSent = (msg: ImMessage) => { setMessages(prev => { - if (prev.find(m => m.id === msg.id)) return prev - return [...prev, msg] + return upsertMessage(prev, msg) }) setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100) } diff --git a/src/screens/chat/SingleChatScreen.tsx b/src/screens/chat/SingleChatScreen.tsx index d9810ea..f79bb87 100644 --- a/src/screens/chat/SingleChatScreen.tsx +++ b/src/screens/chat/SingleChatScreen.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { - View, FlatList, StyleSheet, Alert, SafeAreaView, + FlatList, StyleSheet, Alert, SafeAreaView, KeyboardAvoidingView, Platform, } from 'react-native' import type { NativeStackScreenProps } from '@react-navigation/native-stack' @@ -13,9 +13,19 @@ import ChatInput from '../../components/ChatInput' type Props = NativeStackScreenProps -export default function SingleChatScreen({ route, navigation }: Props) { - const { targetId, targetName, targetAvatar } = route.params - const { userId, profile } = useAuth() +function upsertMessage(messages: ImMessage[], message: ImMessage): ImMessage[] { + const index = messages.findIndex(item => item.id === message.id) + if (index >= 0) { + const next = [...messages] + next[index] = message + return next + } + return [...messages, message] +} + +export default function SingleChatScreen({ route }: Props) { + const { targetId, targetName } = route.params + const { userId } = useAuth() const [messages, setMessages] = useState([]) const [page, setPage] = useState(0) const [loadingMore, setLoadingMore] = useState(false) @@ -24,7 +34,10 @@ export default function SingleChatScreen({ route, navigation }: Props) { const loadHistory = useCallback(async (p = 0) => { try { const history = await ImSDK.fetchHistory(targetId, p, 30) - setMessages(prev => p === 0 ? history : [...history, ...prev]) + setMessages(prev => { + const merged = p === 0 ? history : [...history, ...prev] + return merged.reduce((acc, message) => upsertMessage(acc, message), []) + }) setPage(p) } catch {/* ignore */} }, [targetId]) @@ -37,8 +50,7 @@ export default function SingleChatScreen({ route, navigation }: Props) { 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] + return upsertMessage(prev, msg) }) setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100) } @@ -52,8 +64,7 @@ export default function SingleChatScreen({ route, navigation }: Props) { const onSent = (msg: ImMessage) => { setMessages(prev => { - if (prev.find(m => m.id === msg.id)) return prev - return [...prev, msg] + return upsertMessage(prev, msg) }) setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100) } diff --git a/src/screens/contact/ContactsScreen.tsx b/src/screens/contact/ContactsScreen.tsx index f7b2940..eafdebd 100644 --- a/src/screens/contact/ContactsScreen.tsx +++ b/src/screens/contact/ContactsScreen.tsx @@ -6,8 +6,9 @@ import { import { useNavigation, useFocusEffect } from '@react-navigation/native' import type { NativeStackNavigationProp } from '@react-navigation/native-stack' import type { RootStackParams } from '../../navigation/types' -import { ImSDK } from '@xuqm/rn-sdk' -import { demoApi, type UserProfile } from '../../api/demo' +import { ImSDK, type FriendRequest, type ImEventListener, type ImMessage } from '@xuqm/rn-sdk' +import type { UserProfile } from '../../api/demo' +import { toDemoUserProfile } from '../../utils/userProfiles' import { load, save, K } from '../../utils/storage' type Nav = NativeStackNavigationProp @@ -32,11 +33,37 @@ function ContactRow({ user, onPress }: { user: UserProfile; onPress(): void }) { ) } +function ContactsEmpty({ loading, onGoSearch }: { loading: boolean; onGoSearch(): void }) { + if (loading) return null + return ( + + 还没有联系人 + + 搜索添加 + + + ) +} + +function ContactsSeparator() { + return +} + export default function ContactsScreen() { const navigation = useNavigation