比较提交
没有共同的提交。53097c5a5cf949d1fdb826df013584694e6bdf5f 和 f7d68681b5698d92939dc1361dc2e23492930de3 的历史完全不同。
53097c5a5c
...
f7d68681b5
@ -18,13 +18,11 @@ interface Props {
|
|||||||
toId: string
|
toId: string
|
||||||
chatType: ChatType
|
chatType: ChatType
|
||||||
onSent(msg: ImMessage): void
|
onSent(msg: ImMessage): void
|
||||||
editingMessage?: ImMessage | null
|
|
||||||
onCancelEdit?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const recorder = AudioRecorderPlayer
|
const recorder = AudioRecorderPlayer
|
||||||
|
|
||||||
export default function ChatInput({ toId, chatType, onSent, editingMessage, onCancelEdit }: Props) {
|
export default function ChatInput({ toId, chatType, onSent }: Props) {
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
const [showEmoji, setShowEmoji] = useState(false)
|
const [showEmoji, setShowEmoji] = useState(false)
|
||||||
@ -33,28 +31,16 @@ export default function ChatInput({ toId, chatType, onSent, editingMessage, onCa
|
|||||||
const recordStart = useRef<number>(0)
|
const recordStart = useRef<number>(0)
|
||||||
const inputRef = useRef<TextInput>(null)
|
const inputRef = useRef<TextInput>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setText(editingMessage?.content ?? '')
|
|
||||||
if (editingMessage) {
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 50)
|
|
||||||
}
|
|
||||||
}, [editingMessage])
|
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
const t = text.trim()
|
const t = text.trim()
|
||||||
if (!t || sending) return
|
if (!t || sending) return
|
||||||
setSending(true)
|
setSending(true)
|
||||||
try {
|
try {
|
||||||
const msg = editingMessage
|
const msg = await ImSDK.sendMessage(toId, chatType, 'TEXT', t)
|
||||||
? await ImSDK.editMessage(editingMessage.id, t)
|
|
||||||
: await ImSDK.sendMessage(toId, chatType, 'TEXT', t)
|
|
||||||
setText('')
|
setText('')
|
||||||
if (editingMessage) {
|
|
||||||
onCancelEdit?.()
|
|
||||||
}
|
|
||||||
onSent(msg)
|
onSent(msg)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert(editingMessage ? '保存失败' : '发送失败', e?.message ?? '请重试')
|
Alert.alert('发送失败', e?.message ?? '请重试')
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
@ -141,14 +127,6 @@ export default function ChatInput({ toId, chatType, onSent, editingMessage, onCa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{editingMessage && (
|
|
||||||
<View style={styles.editBanner}>
|
|
||||||
<Text style={styles.editBannerText}>正在编辑消息</Text>
|
|
||||||
<TouchableOpacity onPress={onCancelEdit}>
|
|
||||||
<Text style={styles.editBannerAction}>取消</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{showEmoji && (
|
{showEmoji && (
|
||||||
<View style={styles.emojiPanel}>
|
<View style={styles.emojiPanel}>
|
||||||
<View style={styles.emojiGrid}>
|
<View style={styles.emojiGrid}>
|
||||||
@ -169,7 +147,7 @@ export default function ChatInput({ toId, chatType, onSent, editingMessage, onCa
|
|||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder={editingMessage ? '编辑消息内容' : recording ? '松开停止录音...' : '输入消息'}
|
placeholder={recording ? '松开停止录音...' : '输入消息'}
|
||||||
value={text}
|
value={text}
|
||||||
onChangeText={setText}
|
onChangeText={setText}
|
||||||
multiline
|
multiline
|
||||||
@ -220,7 +198,4 @@ const styles = StyleSheet.create({
|
|||||||
emojiGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8 },
|
emojiGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8 },
|
||||||
emojiBtn: { width: '10%', aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },
|
emojiBtn: { width: '10%', aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
emojiText: { fontSize: 24 },
|
emojiText: { fontSize: 24 },
|
||||||
editBanner: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 12, paddingVertical: 8, backgroundColor: '#fff7ed', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#fed7aa' },
|
|
||||||
editBannerText: { fontSize: 13, color: '#9a3412', fontWeight: '600' },
|
|
||||||
editBannerAction: { fontSize: 13, color: '#ea580c', fontWeight: '700' },
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -27,7 +27,7 @@ function lastMsgPreview(item: ConversationData): string {
|
|||||||
case 'VIDEO': return '[视频]'
|
case 'VIDEO': return '[视频]'
|
||||||
case 'AUDIO': return '[语音]'
|
case 'AUDIO': return '[语音]'
|
||||||
case 'FILE': return '[文件]'
|
case 'FILE': return '[文件]'
|
||||||
default: return item.lastMsgContent ?? ''
|
default: return item.lastMsgContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useRef, useState} from 'react'
|
import React, {useRef, useState} from 'react'
|
||||||
import {Alert, Dimensions, Image, Pressable, StyleSheet, Text, View} from 'react-native'
|
import {Alert, Dimensions, Image, Pressable, StyleSheet, Text, View} from 'react-native'
|
||||||
import type { AlertButton } from 'react-native'
|
|
||||||
import AudioRecorderPlayer from 'react-native-audio-recorder-player'
|
import AudioRecorderPlayer from 'react-native-audio-recorder-player'
|
||||||
import type {ImMessage} from '@xuqm/rn-sdk'
|
import type {ImMessage} from '@xuqm/rn-sdk'
|
||||||
|
|
||||||
@ -9,7 +8,6 @@ interface Props {
|
|||||||
isOwn: boolean
|
isOwn: boolean
|
||||||
peerNickname: string
|
peerNickname: string
|
||||||
onRevoke?: (messageId: string) => void
|
onRevoke?: (messageId: string) => void
|
||||||
onEdit?: (message: ImMessage) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCREEN_W = Dimensions.get('window').width
|
const SCREEN_W = Dimensions.get('window').width
|
||||||
@ -308,20 +306,15 @@ function MessageContent({message}: {message: ImMessage}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({message, isOwn, peerNickname, onRevoke, onEdit}: Props) {
|
export function MessageBubble({message, isOwn, peerNickname, onRevoke}: Props) {
|
||||||
const canRevoke = Boolean(onRevoke) && isOwn && message.status !== 'REVOKED' && message.msgType !== 'REVOKED'
|
const canRevoke = isOwn && message.status !== 'REVOKED' && message.msgType !== 'REVOKED'
|
||||||
const canEdit = Boolean(onEdit) && isOwn && message.msgType === 'TEXT' && message.status !== 'REVOKED'
|
|
||||||
|
|
||||||
const handleLongPress = () => {
|
const handleLongPress = () => {
|
||||||
if (!canEdit && !canRevoke) return
|
if (!canRevoke) return
|
||||||
const buttons: AlertButton[] = [{text: '取消', style: 'cancel'}]
|
Alert.alert('操作', '撤回这条消息?', [
|
||||||
if (canEdit) {
|
{text: '取消', style: 'cancel'},
|
||||||
buttons.push({text: '编辑', onPress: () => onEdit?.(message)})
|
{text: '撤回', style: 'destructive', onPress: () => onRevoke?.(message.id)},
|
||||||
}
|
])
|
||||||
if (canRevoke) {
|
|
||||||
buttons.push({text: '撤回', style: 'destructive', onPress: () => onRevoke?.(message.id)})
|
|
||||||
}
|
|
||||||
Alert.alert('操作', '请选择要执行的操作', buttons)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderLabel = isOwn ? '我' : peerNickname
|
const senderLabel = isOwn ? '我' : peerNickname
|
||||||
@ -340,7 +333,6 @@ export function MessageBubble({message, isOwn, peerNickname, onRevoke, onEdit}:
|
|||||||
<Text style={styles.metaLine}>
|
<Text style={styles.metaLine}>
|
||||||
{senderLabel}
|
{senderLabel}
|
||||||
{message.status === 'READ' ? ' · 已读' : message.status === 'DELIVERED' ? ' · 已送达' : ''}
|
{message.status === 'READ' ? ' · 已读' : message.status === 'DELIVERED' ? ' · 已送达' : ''}
|
||||||
{message.editedAt ? ' · 已编辑' : ''}
|
|
||||||
</Text>
|
</Text>
|
||||||
<MessageContent message={message} />
|
<MessageContent message={message} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@ -15,8 +15,6 @@ const ALL_MSG_TYPES: MsgType[] = [
|
|||||||
'CALL_AUDIO',
|
'CALL_AUDIO',
|
||||||
'CALL_VIDEO',
|
'CALL_VIDEO',
|
||||||
'FORWARD',
|
'FORWARD',
|
||||||
'QUOTE',
|
|
||||||
'MERGE',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const TYPE_LABELS: Record<MsgType, string> = {
|
const TYPE_LABELS: Record<MsgType, string> = {
|
||||||
@ -32,8 +30,6 @@ const TYPE_LABELS: Record<MsgType, string> = {
|
|||||||
CALL_AUDIO: '语音通话',
|
CALL_AUDIO: '语音通话',
|
||||||
CALL_VIDEO: '视频通话',
|
CALL_VIDEO: '视频通话',
|
||||||
FORWARD: '转发',
|
FORWARD: '转发',
|
||||||
QUOTE: '引用',
|
|
||||||
MERGE: '合并',
|
|
||||||
REVOKED: '已撤回',
|
REVOKED: '已撤回',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,8 +46,6 @@ const TYPE_ICONS: Record<MsgType, string> = {
|
|||||||
CALL_AUDIO: '📞',
|
CALL_AUDIO: '📞',
|
||||||
CALL_VIDEO: '📹',
|
CALL_VIDEO: '📹',
|
||||||
FORWARD: '↪️',
|
FORWARD: '↪️',
|
||||||
QUOTE: '💭',
|
|
||||||
MERGE: '🗂️',
|
|
||||||
REVOKED: '🚫',
|
REVOKED: '🚫',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,15 +110,6 @@ export const DEMO_CONTENT: Record<MsgType, string> = {
|
|||||||
originalSender: 'demo_alice',
|
originalSender: 'demo_alice',
|
||||||
originalTime: new Date().toISOString(),
|
originalTime: new Date().toISOString(),
|
||||||
}),
|
}),
|
||||||
QUOTE: JSON.stringify({
|
|
||||||
quotedMsgId: 'msg_quote_001',
|
|
||||||
quotedContent: '这是被引用的消息',
|
|
||||||
text: '这是一条引用消息',
|
|
||||||
}),
|
|
||||||
MERGE: JSON.stringify({
|
|
||||||
title: '合并转发预览',
|
|
||||||
msgList: ['消息 1', '消息 2', '消息 3'],
|
|
||||||
}),
|
|
||||||
REVOKED: '',
|
REVOKED: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,12 +16,10 @@ import ProfileScreen from '../screens/profile/ProfileScreen'
|
|||||||
import SingleChatScreen from '../screens/chat/SingleChatScreen'
|
import SingleChatScreen from '../screens/chat/SingleChatScreen'
|
||||||
import GroupChatScreen from '../screens/chat/GroupChatScreen'
|
import GroupChatScreen from '../screens/chat/GroupChatScreen'
|
||||||
import UserSearchScreen from '../screens/contact/UserSearchScreen'
|
import UserSearchScreen from '../screens/contact/UserSearchScreen'
|
||||||
import FriendRequestsScreen from '../screens/contact/FriendRequestsScreen'
|
|
||||||
import GroupListScreen from '../screens/group/GroupListScreen'
|
import GroupListScreen from '../screens/group/GroupListScreen'
|
||||||
import CreateGroupScreen from '../screens/group/CreateGroupScreen'
|
import CreateGroupScreen from '../screens/group/CreateGroupScreen'
|
||||||
import GroupMembersScreen from '../screens/group/GroupMembersScreen'
|
import GroupMembersScreen from '../screens/group/GroupMembersScreen'
|
||||||
import GroupSettingsScreen from '../screens/group/GroupSettingsScreen'
|
import GroupSettingsScreen from '../screens/group/GroupSettingsScreen'
|
||||||
import GroupJoinRequestsScreen from '../screens/group/GroupJoinRequestsScreen'
|
|
||||||
import EditProfileScreen from '../screens/profile/EditProfileScreen'
|
import EditProfileScreen from '../screens/profile/EditProfileScreen'
|
||||||
import MessageSearchScreen from '../screens/chat/MessageSearchScreen'
|
import MessageSearchScreen from '../screens/chat/MessageSearchScreen'
|
||||||
import DisconnectBanner from '../components/DisconnectBanner'
|
import DisconnectBanner from '../components/DisconnectBanner'
|
||||||
@ -64,12 +62,10 @@ function AppStack() {
|
|||||||
<Root.Screen name="SingleChat" component={SingleChatScreen} options={({ route }) => ({ title: route.params.targetName })} />
|
<Root.Screen name="SingleChat" component={SingleChatScreen} options={({ route }) => ({ title: route.params.targetName })} />
|
||||||
<Root.Screen name="GroupChat" component={GroupChatScreen} options={({ route }) => ({ title: route.params.groupName })} />
|
<Root.Screen name="GroupChat" component={GroupChatScreen} options={({ route }) => ({ title: route.params.groupName })} />
|
||||||
<Root.Screen name="UserSearch" component={UserSearchScreen} options={{ title: '搜索联系人' }} />
|
<Root.Screen name="UserSearch" component={UserSearchScreen} options={{ title: '搜索联系人' }} />
|
||||||
<Root.Screen name="FriendRequests" component={FriendRequestsScreen} options={{ title: '好友申请' }} />
|
|
||||||
<Root.Screen name="GroupList" component={GroupListScreen} options={{ title: '群聊' }} />
|
<Root.Screen name="GroupList" component={GroupListScreen} options={{ title: '群聊' }} />
|
||||||
<Root.Screen name="CreateGroup" component={CreateGroupScreen} options={{ title: '创建群聊' }} />
|
<Root.Screen name="CreateGroup" component={CreateGroupScreen} options={{ title: '创建群聊' }} />
|
||||||
<Root.Screen name="GroupMembers" component={GroupMembersScreen} options={{ title: '群成员' }} />
|
<Root.Screen name="GroupMembers" component={GroupMembersScreen} options={{ title: '群成员' }} />
|
||||||
<Root.Screen name="GroupSettings" component={GroupSettingsScreen} options={{ title: '群设置' }} />
|
<Root.Screen name="GroupSettings" component={GroupSettingsScreen} options={{ title: '群设置' }} />
|
||||||
<Root.Screen name="GroupJoinRequests" component={GroupJoinRequestsScreen} options={{ title: '入群申请' }} />
|
|
||||||
<Root.Screen name="EditProfile" component={EditProfileScreen} options={{ title: '编辑资料' }} />
|
<Root.Screen name="EditProfile" component={EditProfileScreen} options={{ title: '编辑资料' }} />
|
||||||
<Root.Screen name="MessageSearch" component={MessageSearchScreen} options={{ title: '搜索消息' }} />
|
<Root.Screen name="MessageSearch" component={MessageSearchScreen} options={{ title: '搜索消息' }} />
|
||||||
</Root.Navigator>
|
</Root.Navigator>
|
||||||
|
|||||||
@ -15,12 +15,10 @@ export type RootStackParams = {
|
|||||||
SingleChat: { targetId: string; targetName: string; targetAvatar?: string }
|
SingleChat: { targetId: string; targetName: string; targetAvatar?: string }
|
||||||
GroupChat: { groupId: string; groupName: string }
|
GroupChat: { groupId: string; groupName: string }
|
||||||
UserSearch: { addToGroupId?: string } | undefined
|
UserSearch: { addToGroupId?: string } | undefined
|
||||||
FriendRequests: undefined
|
|
||||||
GroupList: undefined
|
GroupList: undefined
|
||||||
CreateGroup: undefined
|
CreateGroup: undefined
|
||||||
GroupMembers: { groupId: string; groupName: string }
|
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
|
EditProfile: undefined
|
||||||
MessageSearch: { targetId?: string; chatType?: 'SINGLE' | 'GROUP' }
|
MessageSearch: { targetId?: string; chatType?: 'SINGLE' | 'GROUP' }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
FlatList, StyleSheet, Alert, SafeAreaView,
|
View, FlatList, StyleSheet, Alert, SafeAreaView,
|
||||||
KeyboardAvoidingView, Platform, TouchableOpacity, Text,
|
KeyboardAvoidingView, Platform, TouchableOpacity, Text,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
@ -13,44 +13,18 @@ import ChatInput from '../../components/ChatInput'
|
|||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParams, 'GroupChat'>
|
type Props = NativeStackScreenProps<RootStackParams, 'GroupChat'>
|
||||||
|
|
||||||
function GroupHeaderButton({
|
|
||||||
navigation,
|
|
||||||
groupId,
|
|
||||||
groupName,
|
|
||||||
}: Pick<Props, 'navigation'> & { groupId: string; groupName: string }) {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity onPress={() => navigation.navigate('GroupSettings', { groupId, groupName, isAdmin: false })}>
|
|
||||||
<Text style={styles.settingsIcon}>⚙️</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
export default function GroupChatScreen({ route, navigation }: Props) {
|
||||||
const { groupId, groupName } = route.params
|
const { groupId, groupName } = route.params
|
||||||
const { userId } = useAuth()
|
const { userId } = useAuth()
|
||||||
const [messages, setMessages] = useState<ImMessage[]>([])
|
const [messages, setMessages] = useState<ImMessage[]>([])
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [editingMessage, setEditingMessage] = useState<ImMessage | null>(null)
|
|
||||||
const listRef = useRef<FlatList<ImMessage>>(null)
|
const listRef = useRef<FlatList<ImMessage>>(null)
|
||||||
|
|
||||||
const loadHistory = useCallback(async (p = 0) => {
|
const loadHistory = useCallback(async (p = 0) => {
|
||||||
try {
|
try {
|
||||||
const history = await ImSDK.fetchGroupHistory(groupId, p, 30)
|
const history = await ImSDK.fetchGroupHistory(groupId, p, 30)
|
||||||
setMessages(prev => {
|
setMessages(prev => p === 0 ? history : [...history, ...prev])
|
||||||
const merged = p === 0 ? history : [...history, ...prev]
|
|
||||||
return merged.reduce<ImMessage[]>((acc, message) => upsertMessage(acc, message), [])
|
|
||||||
})
|
|
||||||
setPage(p)
|
setPage(p)
|
||||||
} catch {/* ignore */}
|
} catch {/* ignore */}
|
||||||
}, [groupId])
|
}, [groupId])
|
||||||
@ -64,7 +38,8 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
|||||||
onGroupMessage(msg) {
|
onGroupMessage(msg) {
|
||||||
if (msg.toId !== groupId) return
|
if (msg.toId !== groupId) return
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
return upsertMessage(prev, msg)
|
if (prev.find(m => m.id === msg.id)) return prev
|
||||||
|
return [...prev, msg]
|
||||||
})
|
})
|
||||||
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
||||||
},
|
},
|
||||||
@ -75,14 +50,18 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
// eslint-disable-next-line react/no-unstable-nested-components
|
headerRight: () => (
|
||||||
headerRight: () => <GroupHeaderButton navigation={navigation} groupId={groupId} groupName={groupName} />,
|
<TouchableOpacity onPress={() => navigation.navigate('GroupSettings', { groupId, groupName, isAdmin: false })}>
|
||||||
|
<Text style={styles.settingsIcon}>⚙️</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}, [navigation, groupId, groupName])
|
}, [navigation, groupId, groupName])
|
||||||
|
|
||||||
const onSent = (msg: ImMessage) => {
|
const onSent = (msg: ImMessage) => {
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
return upsertMessage(prev, msg)
|
if (prev.find(m => m.id === msg.id)) return prev
|
||||||
|
return [...prev, msg]
|
||||||
})
|
})
|
||||||
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
||||||
}
|
}
|
||||||
@ -96,10 +75,6 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (message: ImMessage) => {
|
|
||||||
setEditingMessage(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
if (loadingMore) return
|
if (loadingMore) return
|
||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
@ -124,7 +99,6 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
|||||||
isOwn={item.fromUserId === userId}
|
isOwn={item.fromUserId === userId}
|
||||||
peerNickname={item.fromUserId}
|
peerNickname={item.fromUserId}
|
||||||
onRevoke={handleRevoke}
|
onRevoke={handleRevoke}
|
||||||
onEdit={handleEdit}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
@ -132,13 +106,7 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
|||||||
onEndReached={loadMore}
|
onEndReached={loadMore}
|
||||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
/>
|
/>
|
||||||
<ChatInput
|
<ChatInput toId={groupId} chatType="GROUP" onSent={onSent} />
|
||||||
toId={groupId}
|
|
||||||
chatType="GROUP"
|
|
||||||
onSent={onSent}
|
|
||||||
editingMessage={editingMessage}
|
|
||||||
onCancelEdit={() => setEditingMessage(null)}
|
|
||||||
/>
|
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
FlatList, StyleSheet, Alert, SafeAreaView,
|
View, FlatList, StyleSheet, Alert, SafeAreaView,
|
||||||
KeyboardAvoidingView, Platform,
|
KeyboardAvoidingView, Platform,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
@ -13,32 +13,18 @@ import ChatInput from '../../components/ChatInput'
|
|||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParams, 'SingleChat'>
|
type Props = NativeStackScreenProps<RootStackParams, 'SingleChat'>
|
||||||
|
|
||||||
function upsertMessage(messages: ImMessage[], message: ImMessage): ImMessage[] {
|
export default function SingleChatScreen({ route, navigation }: Props) {
|
||||||
const index = messages.findIndex(item => item.id === message.id)
|
const { targetId, targetName, targetAvatar } = route.params
|
||||||
if (index >= 0) {
|
const { userId, profile } = useAuth()
|
||||||
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<ImMessage[]>([])
|
const [messages, setMessages] = useState<ImMessage[]>([])
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [editingMessage, setEditingMessage] = useState<ImMessage | null>(null)
|
|
||||||
const listRef = useRef<FlatList<ImMessage>>(null)
|
const listRef = useRef<FlatList<ImMessage>>(null)
|
||||||
|
|
||||||
const loadHistory = useCallback(async (p = 0) => {
|
const loadHistory = useCallback(async (p = 0) => {
|
||||||
try {
|
try {
|
||||||
const history = await ImSDK.fetchHistory(targetId, p, 30)
|
const history = await ImSDK.fetchHistory(targetId, p, 30)
|
||||||
setMessages(prev => {
|
setMessages(prev => p === 0 ? history : [...history, ...prev])
|
||||||
const merged = p === 0 ? history : [...history, ...prev]
|
|
||||||
return merged.reduce<ImMessage[]>((acc, message) => upsertMessage(acc, message), [])
|
|
||||||
})
|
|
||||||
setPage(p)
|
setPage(p)
|
||||||
} catch {/* ignore */}
|
} catch {/* ignore */}
|
||||||
}, [targetId])
|
}, [targetId])
|
||||||
@ -51,7 +37,8 @@ export default function SingleChatScreen({ route }: Props) {
|
|||||||
if ((msg.fromUserId === targetId && msg.toId === userId) ||
|
if ((msg.fromUserId === targetId && msg.toId === userId) ||
|
||||||
(msg.fromUserId === userId && msg.toId === targetId)) {
|
(msg.fromUserId === userId && msg.toId === targetId)) {
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
return upsertMessage(prev, msg)
|
if (prev.find(m => m.id === msg.id)) return prev
|
||||||
|
return [...prev, msg]
|
||||||
})
|
})
|
||||||
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
||||||
}
|
}
|
||||||
@ -65,7 +52,8 @@ export default function SingleChatScreen({ route }: Props) {
|
|||||||
|
|
||||||
const onSent = (msg: ImMessage) => {
|
const onSent = (msg: ImMessage) => {
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
return upsertMessage(prev, msg)
|
if (prev.find(m => m.id === msg.id)) return prev
|
||||||
|
return [...prev, msg]
|
||||||
})
|
})
|
||||||
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
||||||
}
|
}
|
||||||
@ -79,10 +67,6 @@ export default function SingleChatScreen({ route }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (message: ImMessage) => {
|
|
||||||
setEditingMessage(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
if (loadingMore) return
|
if (loadingMore) return
|
||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
@ -107,7 +91,6 @@ export default function SingleChatScreen({ route }: Props) {
|
|||||||
isOwn={item.fromUserId === userId}
|
isOwn={item.fromUserId === userId}
|
||||||
peerNickname={targetName}
|
peerNickname={targetName}
|
||||||
onRevoke={handleRevoke}
|
onRevoke={handleRevoke}
|
||||||
onEdit={handleEdit}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
@ -115,13 +98,7 @@ export default function SingleChatScreen({ route }: Props) {
|
|||||||
onEndReached={loadMore}
|
onEndReached={loadMore}
|
||||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
/>
|
/>
|
||||||
<ChatInput
|
<ChatInput toId={targetId} chatType="SINGLE" onSent={onSent} />
|
||||||
toId={targetId}
|
|
||||||
chatType="SINGLE"
|
|
||||||
onSent={onSent}
|
|
||||||
editingMessage={editingMessage}
|
|
||||||
onCancelEdit={() => setEditingMessage(null)}
|
|
||||||
/>
|
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,9 +6,8 @@ import {
|
|||||||
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||||
import type { RootStackParams } from '../../navigation/types'
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
import { ImSDK, type FriendRequest, type ImEventListener, type ImMessage } from '@xuqm/rn-sdk'
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
import type { UserProfile } from '../../api/demo'
|
import { demoApi, type UserProfile } from '../../api/demo'
|
||||||
import { toDemoUserProfile } from '../../utils/userProfiles'
|
|
||||||
import { load, save, K } from '../../utils/storage'
|
import { load, save, K } from '../../utils/storage'
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParams>
|
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||||
@ -33,37 +32,11 @@ function ContactRow({ user, onPress }: { user: UserProfile; onPress(): void }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContactsEmpty({ loading, onGoSearch }: { loading: boolean; onGoSearch(): void }) {
|
|
||||||
if (loading) return null
|
|
||||||
return (
|
|
||||||
<View style={styles.empty}>
|
|
||||||
<Text style={styles.emptyText}>还没有联系人</Text>
|
|
||||||
<TouchableOpacity onPress={onGoSearch}>
|
|
||||||
<Text style={styles.emptyLink}>搜索添加</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContactsSeparator() {
|
|
||||||
return <View style={styles.sep} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContactsScreen() {
|
export default function ContactsScreen() {
|
||||||
const navigation = useNavigation<Nav>()
|
const navigation = useNavigation<Nav>()
|
||||||
const [contacts, setContacts] = useState<UserProfile[]>([])
|
const [contacts, setContacts] = useState<UserProfile[]>([])
|
||||||
const [friendRequests, setFriendRequests] = useState<FriendRequest[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const refreshFriendRequests = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const list = await ImSDK.listFriendRequests('incoming')
|
|
||||||
setFriendRequests(list)
|
|
||||||
} catch {
|
|
||||||
setFriendRequests([])
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchContacts = useCallback(async () => {
|
const fetchContacts = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@ -72,9 +45,9 @@ export default function ContactsScreen() {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
friendIds.map(async (id) => {
|
friendIds.map(async (id) => {
|
||||||
try {
|
try {
|
||||||
const results = await ImSDK.searchUsers(id)
|
const results = await demoApi.searchUsers(id)
|
||||||
const match = results.find(u => u.userId === id)
|
const match = results.find(u => u.userId === id)
|
||||||
if (match) profiles.push(toDemoUserProfile(match))
|
if (match) profiles.push(match)
|
||||||
} catch {/* skip individual failures */}
|
} catch {/* skip individual failures */}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@ -89,29 +62,10 @@ export default function ContactsScreen() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const listener: ImEventListener = {
|
|
||||||
onSystemMessage(msg: ImMessage) {
|
|
||||||
if (msg.msgType !== 'NOTIFY') return
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(msg.content || '{}')
|
|
||||||
if (payload.type === 'FRIEND_REQUEST' || payload.type === 'FRIEND_REQUEST_STATUS') {
|
|
||||||
refreshFriendRequests()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
ImSDK.addListener(listener)
|
|
||||||
return () => ImSDK.removeListener(listener)
|
|
||||||
}, [refreshFriendRequests])
|
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
fetchContacts()
|
fetchContacts()
|
||||||
refreshFriendRequests()
|
}, [fetchContacts]),
|
||||||
}, [fetchContacts, refreshFriendRequests]),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const openChat = (user: UserProfile) => {
|
const openChat = (user: UserProfile) => {
|
||||||
@ -123,9 +77,6 @@ export default function ContactsScreen() {
|
|||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>通讯录</Text>
|
<Text style={styles.title}>通讯录</Text>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
<TouchableOpacity onPress={() => navigation.navigate('FriendRequests')} style={styles.headerBtn}>
|
|
||||||
<Text style={styles.headerBtnText}>申请{friendRequests.length > 0 ? `(${friendRequests.length})` : ''}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={() => navigation.navigate('GroupList')} style={styles.headerBtn}>
|
<TouchableOpacity onPress={() => navigation.navigate('GroupList')} style={styles.headerBtn}>
|
||||||
<Text style={styles.headerBtnText}>群聊</Text>
|
<Text style={styles.headerBtnText}>群聊</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -134,21 +85,22 @@ export default function ContactsScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{friendRequests.length > 0 && (
|
|
||||||
<View style={styles.requestBanner}>
|
|
||||||
<Text style={styles.requestText}>有 {friendRequests.length} 条好友申请待处理</Text>
|
|
||||||
<TouchableOpacity onPress={() => navigation.navigate('FriendRequests')}>
|
|
||||||
<Text style={styles.requestLink}>去处理</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{loading && <ActivityIndicator style={styles.loadingIndicator} color="#07C160" />}
|
{loading && <ActivityIndicator style={styles.loadingIndicator} color="#07C160" />}
|
||||||
<FlatList
|
<FlatList
|
||||||
data={contacts}
|
data={contacts}
|
||||||
keyExtractor={u => u.userId}
|
keyExtractor={u => u.userId}
|
||||||
renderItem={({ item }) => <ContactRow user={item} onPress={() => openChat(item)} />}
|
renderItem={({ item }) => <ContactRow user={item} onPress={() => openChat(item)} />}
|
||||||
ItemSeparatorComponent={ContactsSeparator}
|
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
||||||
ListEmptyComponent={<ContactsEmpty loading={loading} onGoSearch={() => navigation.navigate('UserSearch')} />}
|
ListEmptyComponent={
|
||||||
|
!loading ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyText}>还没有联系人</Text>
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('UserSearch')}>
|
||||||
|
<Text style={styles.emptyLink}>搜索添加</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
@ -161,9 +113,6 @@ const styles = StyleSheet.create({
|
|||||||
headerActions: { flexDirection: 'row', gap: 12 },
|
headerActions: { flexDirection: 'row', gap: 12 },
|
||||||
headerBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 },
|
headerBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 },
|
||||||
headerBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
headerBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||||||
requestBanner: { marginHorizontal: 12, marginTop: 12, padding: 12, backgroundColor: '#fff7e6', borderRadius: 8, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
|
||||||
requestText: { color: '#8a5a00', fontSize: 13, flex: 1, marginRight: 8 },
|
|
||||||
requestLink: { color: '#07C160', fontSize: 13, fontWeight: '700' },
|
|
||||||
loadingIndicator: { marginVertical: 8 },
|
loadingIndicator: { marginVertical: 8 },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
||||||
avatar: { width: 46, height: 46, borderRadius: 8, marginRight: 12 },
|
avatar: { width: 46, height: 46, borderRadius: 8, marginRight: 12 },
|
||||||
|
|||||||
@ -1,134 +0,0 @@
|
|||||||
import React, { useCallback, useState } from 'react'
|
|
||||||
import {
|
|
||||||
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
|
||||||
ActivityIndicator, Alert,
|
|
||||||
} from 'react-native'
|
|
||||||
import { useFocusEffect } from '@react-navigation/native'
|
|
||||||
import { ImSDK, type FriendRequest } from '@xuqm/rn-sdk'
|
|
||||||
import type { UserProfile } from '../../api/demo'
|
|
||||||
import { toDemoUserProfile } from '../../utils/userProfiles'
|
|
||||||
|
|
||||||
function RequestRow({
|
|
||||||
request,
|
|
||||||
profile,
|
|
||||||
onAccept,
|
|
||||||
onReject,
|
|
||||||
}: {
|
|
||||||
request: FriendRequest
|
|
||||||
profile?: UserProfile
|
|
||||||
onAccept(): void
|
|
||||||
onReject(): void
|
|
||||||
}) {
|
|
||||||
const name = profile?.nickname || request.fromUserId
|
|
||||||
return (
|
|
||||||
<View style={styles.row}>
|
|
||||||
<View style={styles.body}>
|
|
||||||
<Text style={styles.name}>{name}</Text>
|
|
||||||
<Text style={styles.uid}>申请人:@{request.fromUserId}</Text>
|
|
||||||
{!!request.remark && <Text style={styles.remark}>附言:{request.remark}</Text>}
|
|
||||||
<Text style={styles.time}>状态:{request.status}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.actions}>
|
|
||||||
<TouchableOpacity style={[styles.actionBtn, styles.acceptBtn]} onPress={onAccept}>
|
|
||||||
<Text style={styles.actionText}>接受</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={[styles.actionBtn, styles.rejectBtn]} onPress={onReject}>
|
|
||||||
<Text style={styles.actionText}>拒绝</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FriendRequestsScreen() {
|
|
||||||
const [requests, setRequests] = useState<FriendRequest[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [profiles, setProfiles] = useState<Record<string, UserProfile>>({})
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const list = await ImSDK.listFriendRequests('incoming')
|
|
||||||
setRequests(list)
|
|
||||||
const nextProfiles: Record<string, UserProfile> = {}
|
|
||||||
await Promise.all(list.map(async (req) => {
|
|
||||||
try {
|
|
||||||
const result = await ImSDK.searchUsers(req.fromUserId)
|
|
||||||
const match = result.find(u => u.userId === req.fromUserId)
|
|
||||||
if (match) nextProfiles[req.fromUserId] = toDemoUserProfile(match)
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
setProfiles(nextProfiles)
|
|
||||||
} catch {
|
|
||||||
setRequests([])
|
|
||||||
setProfiles({})
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useFocusEffect(useCallback(() => { load() }, [load]))
|
|
||||||
|
|
||||||
const handleAccept = async (request: FriendRequest) => {
|
|
||||||
try {
|
|
||||||
await ImSDK.acceptFriendRequest(request.id)
|
|
||||||
await load()
|
|
||||||
} catch (e: any) {
|
|
||||||
Alert.alert('处理失败', e?.message ?? '请稍后重试')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReject = async (request: FriendRequest) => {
|
|
||||||
try {
|
|
||||||
await ImSDK.rejectFriendRequest(request.id)
|
|
||||||
await load()
|
|
||||||
} catch (e: any) {
|
|
||||||
Alert.alert('处理失败', e?.message ?? '请稍后重试')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.root}>
|
|
||||||
{loading ? (
|
|
||||||
<ActivityIndicator style={{ flex: 1 }} color="#07C160" />
|
|
||||||
) : (
|
|
||||||
<FlatList
|
|
||||||
data={requests}
|
|
||||||
keyExtractor={item => item.id}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<RequestRow
|
|
||||||
request={item}
|
|
||||||
profile={profiles[item.fromUserId]}
|
|
||||||
onAccept={() => handleAccept(item)}
|
|
||||||
onReject={() => handleReject(item)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
|
||||||
ListEmptyComponent={<View style={styles.empty}><Text style={styles.emptyText}>暂无好友申请</Text></View>}
|
|
||||||
contentContainerStyle={requests.length === 0 ? styles.emptyContainer : undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
|
||||||
row: { flexDirection: 'row', padding: 12, backgroundColor: '#fff', alignItems: 'center' },
|
|
||||||
body: { flex: 1, paddingRight: 8 },
|
|
||||||
name: { fontSize: 16, fontWeight: '600', color: '#111' },
|
|
||||||
uid: { fontSize: 13, color: '#888', marginTop: 2 },
|
|
||||||
remark: { fontSize: 13, color: '#555', marginTop: 4 },
|
|
||||||
time: { fontSize: 12, color: '#999', marginTop: 4 },
|
|
||||||
actions: { gap: 8 },
|
|
||||||
actionBtn: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6 },
|
|
||||||
acceptBtn: { backgroundColor: '#07C160' },
|
|
||||||
rejectBtn: { backgroundColor: '#ff3b30' },
|
|
||||||
actionText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
|
||||||
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 12 },
|
|
||||||
emptyContainer: { flexGrow: 1 },
|
|
||||||
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
emptyText: { color: '#bbb', fontSize: 15 },
|
|
||||||
})
|
|
||||||
@ -1,14 +1,14 @@
|
|||||||
import React, { useState, useCallback, useRef } from 'react'
|
import React, { useState, useCallback, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity,
|
View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity,
|
||||||
SafeAreaView, ActivityIndicator, Alert, Image, Modal,
|
SafeAreaView, ActivityIndicator, Alert, Image,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { useNavigation, useRoute } from '@react-navigation/native'
|
import { useNavigation, useRoute } from '@react-navigation/native'
|
||||||
import type { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'
|
import type { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
import { ImSDK } from '@xuqm/rn-sdk'
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
import type { UserProfile } from '../../api/demo'
|
import { demoApi, type UserProfile } from '../../api/demo'
|
||||||
|
import { load, save, K } from '../../utils/storage'
|
||||||
import type { RootStackParams } from '../../navigation/types'
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
import { toDemoUserProfiles } from '../../utils/userProfiles'
|
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParams>
|
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||||
type Props = NativeStackScreenProps<RootStackParams, 'UserSearch'>
|
type Props = NativeStackScreenProps<RootStackParams, 'UserSearch'>
|
||||||
@ -20,8 +20,6 @@ export default function UserSearchScreen() {
|
|||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
const [results, setResults] = useState<UserProfile[]>([])
|
const [results, setResults] = useState<UserProfile[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [pendingUser, setPendingUser] = useState<UserProfile | null>(null)
|
|
||||||
const [friendRemark, setFriendRemark] = useState('')
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const search = useCallback((text: string) => {
|
const search = useCallback((text: string) => {
|
||||||
@ -31,8 +29,8 @@ export default function UserSearchScreen() {
|
|||||||
debounceRef.current = setTimeout(async () => {
|
debounceRef.current = setTimeout(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await ImSDK.searchUsers(text.trim())
|
const res = await demoApi.searchUsers(text.trim())
|
||||||
setResults(toDemoUserProfiles(res))
|
setResults(res)
|
||||||
} catch {
|
} catch {
|
||||||
setResults([])
|
setResults([])
|
||||||
} finally {
|
} finally {
|
||||||
@ -50,20 +48,16 @@ export default function UserSearchScreen() {
|
|||||||
Alert.alert('添加失败', e?.message ?? '请重试')
|
Alert.alert('添加失败', e?.message ?? '请重试')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setPendingUser(user)
|
|
||||||
setFriendRemark('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendFriendRequest = async () => {
|
|
||||||
if (!pendingUser) return
|
|
||||||
try {
|
try {
|
||||||
await ImSDK.sendFriendRequest(pendingUser.userId, friendRemark.trim() || undefined)
|
await ImSDK.addFriend(user.userId)
|
||||||
Alert.alert('好友申请已发送')
|
const current = (await load<UserProfile[]>(K.CONTACTS)) ?? []
|
||||||
setPendingUser(null)
|
if (!current.find(c => c.userId === user.userId)) {
|
||||||
setFriendRemark('')
|
await save(K.CONTACTS, [...current, user])
|
||||||
} catch (e: any) {
|
}
|
||||||
Alert.alert('添加失败', e?.message ?? '请重试')
|
Alert.alert('已添加为好友')
|
||||||
|
} catch {
|
||||||
|
Alert.alert('添加失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,29 +111,6 @@ export default function UserSearchScreen() {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Modal transparent visible={pendingUser !== null} animationType="fade" onRequestClose={() => setPendingUser(null)}>
|
|
||||||
<View style={styles.modalMask}>
|
|
||||||
<View style={styles.modalCard}>
|
|
||||||
<Text style={styles.modalTitle}>发送好友申请</Text>
|
|
||||||
<Text style={styles.modalSubTitle}>对象:{pendingUser?.nickname ?? pendingUser?.userId ?? ''}</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.modalInput}
|
|
||||||
placeholder="填写申请信息(可选)"
|
|
||||||
value={friendRemark}
|
|
||||||
onChangeText={setFriendRemark}
|
|
||||||
multiline
|
|
||||||
/>
|
|
||||||
<View style={styles.modalActions}>
|
|
||||||
<TouchableOpacity style={[styles.modalBtn, styles.modalCancel]} onPress={() => setPendingUser(null)}>
|
|
||||||
<Text style={styles.modalCancelText}>取消</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={[styles.modalBtn, styles.modalConfirm]} onPress={sendFriendRequest}>
|
|
||||||
<Text style={styles.modalConfirmText}>发送</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -161,15 +132,4 @@ const styles = StyleSheet.create({
|
|||||||
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 68 },
|
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 68 },
|
||||||
empty: { alignItems: 'center', paddingTop: 60 },
|
empty: { alignItems: 'center', paddingTop: 60 },
|
||||||
emptyText: { color: '#bbb', fontSize: 15 },
|
emptyText: { color: '#bbb', fontSize: 15 },
|
||||||
modalMask: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'center', padding: 24 },
|
|
||||||
modalCard: { width: '100%', backgroundColor: '#fff', borderRadius: 12, padding: 16 },
|
|
||||||
modalTitle: { fontSize: 17, fontWeight: '700', color: '#111' },
|
|
||||||
modalSubTitle: { marginTop: 6, color: '#666', fontSize: 13 },
|
|
||||||
modalInput: { minHeight: 90, borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, marginTop: 12, padding: 10, textAlignVertical: 'top' },
|
|
||||||
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 10, marginTop: 14 },
|
|
||||||
modalBtn: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 8 },
|
|
||||||
modalCancel: { backgroundColor: '#f2f2f2' },
|
|
||||||
modalConfirm: { backgroundColor: '#07C160' },
|
|
||||||
modalCancelText: { color: '#333', fontWeight: '600' },
|
|
||||||
modalConfirmText: { color: '#fff', fontWeight: '600' },
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -8,8 +8,7 @@ import { ImSDK, type ConversationData } from '@xuqm/rn-sdk'
|
|||||||
import type { RootStackParams } from '../../navigation/types'
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
import DisconnectBanner from '../../components/DisconnectBanner'
|
import DisconnectBanner from '../../components/DisconnectBanner'
|
||||||
import ConversationItem from '../../components/ConversationItem'
|
import ConversationItem from '../../components/ConversationItem'
|
||||||
import type { UserProfile } from '../../api/demo'
|
import { demoApi, type UserProfile } from '../../api/demo'
|
||||||
import { toDemoUserProfile } from '../../utils/userProfiles'
|
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParams>
|
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||||
|
|
||||||
@ -28,9 +27,8 @@ export default function ConversationListScreen() {
|
|||||||
let profile = profileCache.current[conv.targetId]
|
let profile = profileCache.current[conv.targetId]
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
try {
|
try {
|
||||||
const results = await ImSDK.searchUsers(conv.targetId)
|
const results = await demoApi.searchUsers(conv.targetId)
|
||||||
const match = results.find(u => u.userId === conv.targetId)
|
profile = results.find(u => u.userId === conv.targetId) ?? { appId, userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' }
|
||||||
profile = match ? toDemoUserProfile(match) : { appId, userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' }
|
|
||||||
profileCache.current[conv.targetId] = profile
|
profileCache.current[conv.targetId] = profile
|
||||||
} catch {
|
} catch {
|
||||||
profile = { appId, userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' }
|
profile = { appId, userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' }
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import React, { useState, useCallback, useRef } from 'react'
|
import React, { useState, useCallback, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
View, Text, TextInput, StyleSheet, TouchableOpacity, SafeAreaView,
|
View, Text, TextInput, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||||
ActivityIndicator, Alert, ScrollView,
|
FlatList, ActivityIndicator, Alert, ScrollView,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { useNavigation } from '@react-navigation/native'
|
import { useNavigation } from '@react-navigation/native'
|
||||||
import { ImSDK } from '@xuqm/rn-sdk'
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
import type { UserProfile } from '../../api/demo'
|
import { demoApi, type UserProfile } from '../../api/demo'
|
||||||
import { toDemoUserProfiles } from '../../utils/userProfiles'
|
|
||||||
|
|
||||||
export default function CreateGroupScreen() {
|
export default function CreateGroupScreen() {
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
@ -25,8 +24,8 @@ export default function CreateGroupScreen() {
|
|||||||
debounceRef.current = setTimeout(async () => {
|
debounceRef.current = setTimeout(async () => {
|
||||||
setSearching(true)
|
setSearching(true)
|
||||||
try {
|
try {
|
||||||
const res = await ImSDK.searchUsers(text.trim())
|
const res = await demoApi.searchUsers(text.trim())
|
||||||
setSearchResults(toDemoUserProfiles(res))
|
setSearchResults(res)
|
||||||
} catch {
|
} catch {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -1,170 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react'
|
|
||||||
import {
|
|
||||||
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
|
||||||
ActivityIndicator, Alert,
|
|
||||||
} from 'react-native'
|
|
||||||
import { useFocusEffect } from '@react-navigation/native'
|
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
|
||||||
import { ImSDK, type GroupJoinRequest, type ImEventListener, type ImMessage } from '@xuqm/rn-sdk'
|
|
||||||
import type { UserProfile } from '../../api/demo'
|
|
||||||
import type { RootStackParams } from '../../navigation/types'
|
|
||||||
import { toDemoUserProfile } from '../../utils/userProfiles'
|
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParams, 'GroupJoinRequests'>
|
|
||||||
|
|
||||||
function RequestRow({
|
|
||||||
request,
|
|
||||||
profile,
|
|
||||||
onAccept,
|
|
||||||
onReject,
|
|
||||||
}: {
|
|
||||||
request: GroupJoinRequest
|
|
||||||
profile?: UserProfile
|
|
||||||
onAccept(): void
|
|
||||||
onReject(): void
|
|
||||||
}) {
|
|
||||||
const name = profile?.nickname || request.requesterId
|
|
||||||
return (
|
|
||||||
<View style={styles.row}>
|
|
||||||
<View style={styles.body}>
|
|
||||||
<Text style={styles.name}>{name}</Text>
|
|
||||||
<Text style={styles.uid}>申请人:@{request.requesterId}</Text>
|
|
||||||
{!!request.remark && <Text style={styles.remark}>附言:{request.remark}</Text>}
|
|
||||||
<Text style={styles.time}>状态:{request.status}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.actions}>
|
|
||||||
<TouchableOpacity style={[styles.actionBtn, styles.acceptBtn]} onPress={onAccept}>
|
|
||||||
<Text style={styles.actionText}>接受</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={[styles.actionBtn, styles.rejectBtn]} onPress={onReject}>
|
|
||||||
<Text style={styles.actionText}>拒绝</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RequestsSeparator() {
|
|
||||||
return <View style={styles.sep} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function RequestsEmpty() {
|
|
||||||
return (
|
|
||||||
<View style={styles.empty}>
|
|
||||||
<Text style={styles.emptyText}>暂无入群申请</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GroupJoinRequestsScreen({ route }: Props) {
|
|
||||||
const { groupId } = route.params
|
|
||||||
const [requests, setRequests] = useState<GroupJoinRequest[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [profiles, setProfiles] = useState<Record<string, UserProfile>>({})
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const list = await ImSDK.listGroupJoinRequests(groupId)
|
|
||||||
setRequests(list)
|
|
||||||
const nextProfiles: Record<string, UserProfile> = {}
|
|
||||||
await Promise.all(list.map(async (req) => {
|
|
||||||
try {
|
|
||||||
const result = await ImSDK.searchUsers(req.requesterId)
|
|
||||||
const match = result.find(u => u.userId === req.requesterId)
|
|
||||||
if (match) nextProfiles[req.requesterId] = toDemoUserProfile(match)
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
setProfiles(nextProfiles)
|
|
||||||
} catch {
|
|
||||||
setRequests([])
|
|
||||||
setProfiles({})
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [groupId])
|
|
||||||
|
|
||||||
useFocusEffect(useCallback(() => { load() }, [load]))
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const listener: ImEventListener = {
|
|
||||||
onSystemMessage(msg: ImMessage) {
|
|
||||||
if (msg.msgType !== 'NOTIFY') return
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(msg.content || '{}')
|
|
||||||
if (payload.type === 'GROUP_JOIN_REQUEST' || payload.type === 'GROUP_JOIN_REQUEST_STATUS') {
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
ImSDK.addListener(listener)
|
|
||||||
return () => ImSDK.removeListener(listener)
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
const handleAccept = async (request: GroupJoinRequest) => {
|
|
||||||
try {
|
|
||||||
await ImSDK.acceptGroupJoinRequest(groupId, request.id)
|
|
||||||
await load()
|
|
||||||
} catch (e: any) {
|
|
||||||
Alert.alert('处理失败', e?.message ?? '请稍后重试')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReject = async (request: GroupJoinRequest) => {
|
|
||||||
try {
|
|
||||||
await ImSDK.rejectGroupJoinRequest(groupId, request.id)
|
|
||||||
await load()
|
|
||||||
} catch (e: any) {
|
|
||||||
Alert.alert('处理失败', e?.message ?? '请稍后重试')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.root}>
|
|
||||||
{loading ? (
|
|
||||||
<ActivityIndicator style={styles.loading} color="#07C160" />
|
|
||||||
) : (
|
|
||||||
<FlatList
|
|
||||||
data={requests}
|
|
||||||
keyExtractor={item => item.id}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<RequestRow
|
|
||||||
request={item}
|
|
||||||
profile={profiles[item.requesterId]}
|
|
||||||
onAccept={() => handleAccept(item)}
|
|
||||||
onReject={() => handleReject(item)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
ItemSeparatorComponent={RequestsSeparator}
|
|
||||||
ListEmptyComponent={RequestsEmpty}
|
|
||||||
contentContainerStyle={requests.length === 0 ? styles.emptyContainer : undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
|
||||||
row: { flexDirection: 'row', padding: 12, backgroundColor: '#fff', alignItems: 'center' },
|
|
||||||
body: { flex: 1, paddingRight: 8 },
|
|
||||||
name: { fontSize: 16, fontWeight: '600', color: '#111' },
|
|
||||||
uid: { fontSize: 13, color: '#888', marginTop: 2 },
|
|
||||||
remark: { fontSize: 13, color: '#555', marginTop: 4 },
|
|
||||||
time: { fontSize: 12, color: '#999', marginTop: 4 },
|
|
||||||
actions: { gap: 8 },
|
|
||||||
actionBtn: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6 },
|
|
||||||
acceptBtn: { backgroundColor: '#07C160' },
|
|
||||||
rejectBtn: { backgroundColor: '#ff3b30' },
|
|
||||||
actionText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
|
||||||
loading: { flex: 1 },
|
|
||||||
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 12 },
|
|
||||||
emptyContainer: { flexGrow: 1 },
|
|
||||||
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
emptyText: { color: '#bbb', fontSize: 15 },
|
|
||||||
})
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
||||||
@ -8,6 +8,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
|||||||
import { ImSDK } from '@xuqm/rn-sdk'
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
import type { ImGroup } from '@xuqm/rn-sdk'
|
import type { ImGroup } from '@xuqm/rn-sdk'
|
||||||
import type { RootStackParams } from '../../navigation/types'
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParams>
|
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||||
|
|
||||||
@ -34,34 +35,20 @@ export default function GroupListScreen() {
|
|||||||
const navigation = useNavigation<Nav>()
|
const navigation = useNavigation<Nav>()
|
||||||
const [groups, setGroups] = useState<ImGroup[]>([])
|
const [groups, setGroups] = useState<ImGroup[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [keyword, setKeyword] = useState('')
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
const fetchGroups = useCallback(async (text = keyword) => {
|
const fetchGroups = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const list = text.trim()
|
const list = await ImSDK.listGroups()
|
||||||
? await ImSDK.searchGroups(text.trim())
|
|
||||||
: await ImSDK.listGroups()
|
|
||||||
setGroups(list)
|
setGroups(list)
|
||||||
} catch {
|
} catch {
|
||||||
/* silently fail */
|
/* silently fail */
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [keyword])
|
}
|
||||||
|
|
||||||
const handleSearch = useCallback((text: string) => {
|
useFocusEffect(useCallback(() => { fetchGroups() }, []))
|
||||||
setKeyword(text)
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
fetchGroups(text)
|
|
||||||
}, 300)
|
|
||||||
}, [fetchGroups])
|
|
||||||
|
|
||||||
useFocusEffect(useCallback(() => {
|
|
||||||
fetchGroups()
|
|
||||||
}, [fetchGroups]))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.root}>
|
<SafeAreaView style={styles.root}>
|
||||||
@ -71,17 +58,8 @@ export default function GroupListScreen() {
|
|||||||
<Text style={styles.createBtnText}>+ 创建</Text>
|
<Text style={styles.createBtnText}>+ 创建</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.searchBar}>
|
|
||||||
<TextInput
|
|
||||||
style={styles.searchInput}
|
|
||||||
placeholder="搜索群名称或群号"
|
|
||||||
value={keyword}
|
|
||||||
onChangeText={handleSearch}
|
|
||||||
clearButtonMode="while-editing"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{loading
|
{loading
|
||||||
? <ActivityIndicator style={styles.loading} color="#07C160" />
|
? <ActivityIndicator style={{ flex: 1 }} color="#07C160" />
|
||||||
: (
|
: (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={groups}
|
data={groups}
|
||||||
@ -107,9 +85,6 @@ const styles = StyleSheet.create({
|
|||||||
title: { fontSize: 18, fontWeight: '700', color: '#111' },
|
title: { fontSize: 18, fontWeight: '700', color: '#111' },
|
||||||
createBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 },
|
createBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 },
|
||||||
createBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
createBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||||||
searchBar: { margin: 12, backgroundColor: '#fff', borderRadius: 8, paddingHorizontal: 12, borderWidth: 1, borderColor: '#e0e0e0' },
|
|
||||||
searchInput: { fontSize: 15, paddingVertical: 10 },
|
|
||||||
loading: { flex: 1 },
|
|
||||||
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
||||||
avatar: { width: 46, height: 46, borderRadius: 8, backgroundColor: '#5856d6', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
|
avatar: { width: 46, height: 46, borderRadius: 8, backgroundColor: '#5856d6', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
|
||||||
avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' },
|
avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' },
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
View, Text, FlatList, StyleSheet, SafeAreaView, ActivityIndicator, Image,
|
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView, ActivityIndicator, Alert, Image,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { useFocusEffect } from '@react-navigation/native'
|
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
import { ImSDK } from '@xuqm/rn-sdk'
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
import type { UserProfile } from '../../api/demo'
|
import { demoApi, type UserProfile } from '../../api/demo'
|
||||||
import type { RootStackParams } from '../../navigation/types'
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
import { toDemoUserProfile } from '../../utils/userProfiles'
|
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParams, 'GroupMembers'>
|
type Props = NativeStackScreenProps<RootStackParams, 'GroupMembers'>
|
||||||
|
|
||||||
export default function GroupMembersScreen({ route }: Props) {
|
export default function GroupMembersScreen({ route }: Props) {
|
||||||
const { groupId } = route.params
|
const { groupId, groupName } = route.params
|
||||||
const appId = 'ak_demo_chat'
|
const appId = 'ak_demo_chat'
|
||||||
|
const navigation = useNavigation()
|
||||||
const [members, setMembers] = useState<UserProfile[]>([])
|
const [members, setMembers] = useState<UserProfile[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@ -28,9 +28,8 @@ export default function GroupMembersScreen({ route }: Props) {
|
|||||||
catch { ids = group.memberIds ? group.memberIds.split(',').filter(Boolean) : [] }
|
catch { ids = group.memberIds ? group.memberIds.split(',').filter(Boolean) : [] }
|
||||||
const profiles = await Promise.all(
|
const profiles = await Promise.all(
|
||||||
ids.map(async id => {
|
ids.map(async id => {
|
||||||
const res = await ImSDK.searchUsers(id)
|
const res = await demoApi.searchUsers(id)
|
||||||
const match = res.find(u => u.userId === id)
|
return res.find(u => u.userId === id) ?? { appId, userId: id, nickname: id, avatar: '', gender: 'UNKNOWN' as const, status: '' }
|
||||||
return match ? toDemoUserProfile(match, appId) : { appId, userId: id, nickname: id, avatar: '', gender: 'UNKNOWN' as const, status: '' }
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
setMembers(profiles)
|
setMembers(profiles)
|
||||||
|
|||||||
@ -1,32 +1,16 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView, Alert, ActivityIndicator } from 'react-native'
|
import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView, Alert, ActivityIndicator } from 'react-native'
|
||||||
import { useNavigation } from '@react-navigation/native'
|
import { useNavigation } from '@react-navigation/native'
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
import { ImSDK } from '@xuqm/rn-sdk'
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
import type { RootStackParams } from '../../navigation/types'
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParams, 'GroupSettings'>
|
type Props = NativeStackScreenProps<RootStackParams, 'GroupSettings'>
|
||||||
|
|
||||||
export default function GroupSettingsScreen({ route }: Props) {
|
export default function GroupSettingsScreen({ route }: Props) {
|
||||||
const { groupId, groupName } = route.params
|
const { groupId, groupName, isAdmin } = route.params
|
||||||
const nav = useNavigation()
|
const nav = useNavigation()
|
||||||
const { userId } = useAuth()
|
|
||||||
const [leaving, setLeaving] = useState(false)
|
const [leaving, setLeaving] = useState(false)
|
||||||
const [isAdmin, setIsAdmin] = useState(Boolean(route.params.isAdmin))
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true
|
|
||||||
ImSDK.getGroupInfo(groupId)
|
|
||||||
.then(group => {
|
|
||||||
if (!mounted) return
|
|
||||||
const admins = new Set((group.adminIds || '').split(',').map(item => item.trim()).filter(Boolean))
|
|
||||||
const creator = group.creatorId?.trim()
|
|
||||||
setIsAdmin(Boolean(userId && (admins.has(userId) || creator === userId)))
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
return () => { mounted = false }
|
|
||||||
}, [groupId, userId])
|
|
||||||
|
|
||||||
function Row({ label, value, onPress }: { label: string; value?: string; onPress?: () => void }) {
|
function Row({ label, value, onPress }: { label: string; value?: string; onPress?: () => void }) {
|
||||||
return (
|
return (
|
||||||
@ -41,7 +25,6 @@ export default function GroupSettingsScreen({ route }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openMembers = () => (nav as any).navigate('GroupMembers', { groupId, groupName })
|
const openMembers = () => (nav as any).navigate('GroupMembers', { groupId, groupName })
|
||||||
const openJoinRequests = () => (nav as any).navigate('GroupJoinRequests', { groupId, groupName })
|
|
||||||
|
|
||||||
const handleLeave = () => {
|
const handleLeave = () => {
|
||||||
Alert.alert('退出群聊', '退出后将不再收到该群消息,确定吗?', [
|
Alert.alert('退出群聊', '退出后将不再收到该群消息,确定吗?', [
|
||||||
@ -75,8 +58,6 @@ export default function GroupSettingsScreen({ route }: Props) {
|
|||||||
<>
|
<>
|
||||||
<View style={styles.sep} />
|
<View style={styles.sep} />
|
||||||
<Row label="添加成员" onPress={() => (nav as any).navigate('UserSearch', { addToGroupId: groupId })} />
|
<Row label="添加成员" onPress={() => (nav as any).navigate('UserSearch', { addToGroupId: groupId })} />
|
||||||
<View style={styles.sep} />
|
|
||||||
<Row label="入群申请" onPress={openJoinRequests} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
import type { UserProfile } from '../api/demo'
|
|
||||||
|
|
||||||
export interface SearchUserProfileLike {
|
|
||||||
appId?: string
|
|
||||||
userId: string
|
|
||||||
nickname?: string | null
|
|
||||||
avatar?: string | null
|
|
||||||
gender?: string | null
|
|
||||||
status?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toDemoUserProfile(user: SearchUserProfileLike, appId = 'ak_demo_chat'): UserProfile {
|
|
||||||
return {
|
|
||||||
appId: user.appId ?? appId,
|
|
||||||
userId: user.userId,
|
|
||||||
nickname: user.nickname ?? user.userId,
|
|
||||||
avatar: user.avatar ?? '',
|
|
||||||
gender: (user.gender as UserProfile['gender']) ?? 'UNKNOWN',
|
|
||||||
status: user.status ?? '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toDemoUserProfiles(users: SearchUserProfileLike[], appId = 'ak_demo_chat'): UserProfile[] {
|
|
||||||
return users.map(user => toDemoUserProfile(user, appId))
|
|
||||||
}
|
|
||||||
@ -1,20 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "@react-native/typescript-config",
|
"extends": "@react-native/typescript-config",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"types": ["jest"]
|
||||||
"paths": {
|
|
||||||
"@xuqm/rn-sdk": ["../XuqmGroup-RNSDK/src/index.ts"],
|
|
||||||
"@xuqm/rn-common": ["../XuqmGroup-RNSDK/packages/common/src"],
|
|
||||||
"@xuqm/rn-im": ["../XuqmGroup-RNSDK/packages/im/src"],
|
|
||||||
"@xuqm/rn-push": ["../XuqmGroup-RNSDK/packages/push/src"],
|
|
||||||
"@xuqm/rn-update": ["../XuqmGroup-RNSDK/packages/update/src"],
|
|
||||||
"@nozbe/watermelondb": ["./node_modules/@nozbe/watermelondb/index.d.ts"],
|
|
||||||
"@nozbe/watermelondb/decorators": ["./node_modules/@nozbe/watermelondb/decorators/index.d.ts"],
|
|
||||||
"@nozbe/watermelondb/adapters/sqlite": ["./node_modules/@nozbe/watermelondb/adapters/sqlite/index.d.ts"]
|
|
||||||
},
|
|
||||||
"types": ["react", "react-native", "jest"],
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"skipLibCheck": true
|
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.tsx"],
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
"exclude": ["**/node_modules", "**/Pods"]
|
"exclude": ["**/node_modules", "**/Pods"]
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户