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