比较提交
没有共同的提交。53097c5a5cf949d1fdb826df013584694e6bdf5f 和 f7d68681b5698d92939dc1361dc2e23492930de3 的历史完全不同。
53097c5a5c
...
f7d68681b5
@ -18,13 +18,11 @@ interface Props {
|
||||
toId: string
|
||||
chatType: ChatType
|
||||
onSent(msg: ImMessage): void
|
||||
editingMessage?: ImMessage | null
|
||||
onCancelEdit?: () => void
|
||||
}
|
||||
|
||||
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 [sending, setSending] = 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 inputRef = useRef<TextInput>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
setText(editingMessage?.content ?? '')
|
||||
if (editingMessage) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [editingMessage])
|
||||
|
||||
const send = async () => {
|
||||
const t = text.trim()
|
||||
if (!t || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
const msg = editingMessage
|
||||
? await ImSDK.editMessage(editingMessage.id, t)
|
||||
: await ImSDK.sendMessage(toId, chatType, 'TEXT', t)
|
||||
const msg = await ImSDK.sendMessage(toId, chatType, 'TEXT', t)
|
||||
setText('')
|
||||
if (editingMessage) {
|
||||
onCancelEdit?.()
|
||||
}
|
||||
onSent(msg)
|
||||
} catch (e: any) {
|
||||
Alert.alert(editingMessage ? '保存失败' : '发送失败', e?.message ?? '请重试')
|
||||
Alert.alert('发送失败', e?.message ?? '请重试')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@ -141,14 +127,6 @@ export default function ChatInput({ toId, chatType, onSent, editingMessage, onCa
|
||||
|
||||
return (
|
||||
<View>
|
||||
{editingMessage && (
|
||||
<View style={styles.editBanner}>
|
||||
<Text style={styles.editBannerText}>正在编辑消息</Text>
|
||||
<TouchableOpacity onPress={onCancelEdit}>
|
||||
<Text style={styles.editBannerAction}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{showEmoji && (
|
||||
<View style={styles.emojiPanel}>
|
||||
<View style={styles.emojiGrid}>
|
||||
@ -169,7 +147,7 @@ export default function ChatInput({ toId, chatType, onSent, editingMessage, onCa
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.input}
|
||||
placeholder={editingMessage ? '编辑消息内容' : recording ? '松开停止录音...' : '输入消息'}
|
||||
placeholder={recording ? '松开停止录音...' : '输入消息'}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
multiline
|
||||
@ -220,7 +198,4 @@ const styles = StyleSheet.create({
|
||||
emojiGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8 },
|
||||
emojiBtn: { width: '10%', aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
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 'AUDIO': return '[语音]'
|
||||
case 'FILE': return '[文件]'
|
||||
default: return item.lastMsgContent ?? ''
|
||||
default: return item.lastMsgContent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useRef, useState} from 'react'
|
||||
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 type {ImMessage} from '@xuqm/rn-sdk'
|
||||
|
||||
@ -9,7 +8,6 @@ interface Props {
|
||||
isOwn: boolean
|
||||
peerNickname: string
|
||||
onRevoke?: (messageId: string) => void
|
||||
onEdit?: (message: ImMessage) => void
|
||||
}
|
||||
|
||||
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) {
|
||||
const canRevoke = Boolean(onRevoke) && isOwn && message.status !== 'REVOKED' && message.msgType !== 'REVOKED'
|
||||
const canEdit = Boolean(onEdit) && isOwn && message.msgType === 'TEXT' && message.status !== 'REVOKED'
|
||||
export function MessageBubble({message, isOwn, peerNickname, onRevoke}: Props) {
|
||||
const canRevoke = isOwn && message.status !== 'REVOKED' && message.msgType !== 'REVOKED'
|
||||
|
||||
const handleLongPress = () => {
|
||||
if (!canEdit && !canRevoke) return
|
||||
const buttons: AlertButton[] = [{text: '取消', style: 'cancel'}]
|
||||
if (canEdit) {
|
||||
buttons.push({text: '编辑', onPress: () => onEdit?.(message)})
|
||||
}
|
||||
if (canRevoke) {
|
||||
buttons.push({text: '撤回', style: 'destructive', onPress: () => onRevoke?.(message.id)})
|
||||
}
|
||||
Alert.alert('操作', '请选择要执行的操作', buttons)
|
||||
if (!canRevoke) return
|
||||
Alert.alert('操作', '撤回这条消息?', [
|
||||
{text: '取消', style: 'cancel'},
|
||||
{text: '撤回', style: 'destructive', onPress: () => onRevoke?.(message.id)},
|
||||
])
|
||||
}
|
||||
|
||||
const senderLabel = isOwn ? '我' : peerNickname
|
||||
@ -340,7 +333,6 @@ export function MessageBubble({message, isOwn, peerNickname, onRevoke, onEdit}:
|
||||
<Text style={styles.metaLine}>
|
||||
{senderLabel}
|
||||
{message.status === 'READ' ? ' · 已读' : message.status === 'DELIVERED' ? ' · 已送达' : ''}
|
||||
{message.editedAt ? ' · 已编辑' : ''}
|
||||
</Text>
|
||||
<MessageContent message={message} />
|
||||
</Pressable>
|
||||
|
||||
@ -15,8 +15,6 @@ const ALL_MSG_TYPES: MsgType[] = [
|
||||
'CALL_AUDIO',
|
||||
'CALL_VIDEO',
|
||||
'FORWARD',
|
||||
'QUOTE',
|
||||
'MERGE',
|
||||
]
|
||||
|
||||
const TYPE_LABELS: Record<MsgType, string> = {
|
||||
@ -32,8 +30,6 @@ const TYPE_LABELS: Record<MsgType, string> = {
|
||||
CALL_AUDIO: '语音通话',
|
||||
CALL_VIDEO: '视频通话',
|
||||
FORWARD: '转发',
|
||||
QUOTE: '引用',
|
||||
MERGE: '合并',
|
||||
REVOKED: '已撤回',
|
||||
}
|
||||
|
||||
@ -50,8 +46,6 @@ const TYPE_ICONS: Record<MsgType, string> = {
|
||||
CALL_AUDIO: '📞',
|
||||
CALL_VIDEO: '📹',
|
||||
FORWARD: '↪️',
|
||||
QUOTE: '💭',
|
||||
MERGE: '🗂️',
|
||||
REVOKED: '🚫',
|
||||
}
|
||||
|
||||
@ -116,15 +110,6 @@ export const DEMO_CONTENT: Record<MsgType, string> = {
|
||||
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: '',
|
||||
}
|
||||
|
||||
|
||||
@ -16,12 +16,10 @@ 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'
|
||||
@ -64,12 +62,10 @@ function AppStack() {
|
||||
<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="UserSearch" component={UserSearchScreen} options={{ title: '搜索联系人' }} />
|
||||
<Root.Screen name="FriendRequests" component={FriendRequestsScreen} options={{ title: '好友申请' }} />
|
||||
<Root.Screen name="GroupList" component={GroupListScreen} options={{ title: '群聊' }} />
|
||||
<Root.Screen name="CreateGroup" component={CreateGroupScreen} options={{ title: '创建群聊' }} />
|
||||
<Root.Screen name="GroupMembers" component={GroupMembersScreen} 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="MessageSearch" component={MessageSearchScreen} options={{ title: '搜索消息' }} />
|
||||
</Root.Navigator>
|
||||
|
||||
@ -15,12 +15,10 @@ 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 }
|
||||
GroupJoinRequests: { groupId: string; groupName: string }
|
||||
GroupSettings: { groupId: string; groupName: string; isAdmin: boolean }
|
||||
EditProfile: undefined
|
||||
MessageSearch: { targetId?: string; chatType?: 'SINGLE' | 'GROUP' }
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
FlatList, StyleSheet, Alert, SafeAreaView,
|
||||
View, FlatList, StyleSheet, Alert, SafeAreaView,
|
||||
KeyboardAvoidingView, Platform, TouchableOpacity, Text,
|
||||
} from 'react-native'
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||
@ -13,44 +13,18 @@ import ChatInput from '../../components/ChatInput'
|
||||
|
||||
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) {
|
||||
const { groupId, groupName } = route.params
|
||||
const { userId } = useAuth()
|
||||
const [messages, setMessages] = useState<ImMessage[]>([])
|
||||
const [page, setPage] = useState(0)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [editingMessage, setEditingMessage] = useState<ImMessage | null>(null)
|
||||
const listRef = useRef<FlatList<ImMessage>>(null)
|
||||
|
||||
const loadHistory = useCallback(async (p = 0) => {
|
||||
try {
|
||||
const history = await ImSDK.fetchGroupHistory(groupId, p, 30)
|
||||
setMessages(prev => {
|
||||
const merged = p === 0 ? history : [...history, ...prev]
|
||||
return merged.reduce<ImMessage[]>((acc, message) => upsertMessage(acc, message), [])
|
||||
})
|
||||
setMessages(prev => p === 0 ? history : [...history, ...prev])
|
||||
setPage(p)
|
||||
} catch {/* ignore */}
|
||||
}, [groupId])
|
||||
@ -64,7 +38,8 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
||||
onGroupMessage(msg) {
|
||||
if (msg.toId !== groupId) return
|
||||
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)
|
||||
},
|
||||
@ -75,14 +50,18 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
headerRight: () => <GroupHeaderButton navigation={navigation} groupId={groupId} groupName={groupName} />,
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => navigation.navigate('GroupSettings', { groupId, groupName, isAdmin: false })}>
|
||||
<Text style={styles.settingsIcon}>⚙️</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
})
|
||||
}, [navigation, groupId, groupName])
|
||||
|
||||
const onSent = (msg: ImMessage) => {
|
||||
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)
|
||||
}
|
||||
@ -96,10 +75,6 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (message: ImMessage) => {
|
||||
setEditingMessage(message)
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore) return
|
||||
setLoadingMore(true)
|
||||
@ -124,7 +99,6 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
||||
isOwn={item.fromUserId === userId}
|
||||
peerNickname={item.fromUserId}
|
||||
onRevoke={handleRevoke}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.list}
|
||||
@ -132,13 +106,7 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
||||
onEndReached={loadMore}
|
||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||
/>
|
||||
<ChatInput
|
||||
toId={groupId}
|
||||
chatType="GROUP"
|
||||
onSent={onSent}
|
||||
editingMessage={editingMessage}
|
||||
onCancelEdit={() => setEditingMessage(null)}
|
||||
/>
|
||||
<ChatInput toId={groupId} chatType="GROUP" onSent={onSent} />
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
FlatList, StyleSheet, Alert, SafeAreaView,
|
||||
View, FlatList, StyleSheet, Alert, SafeAreaView,
|
||||
KeyboardAvoidingView, Platform,
|
||||
} from 'react-native'
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||
@ -13,32 +13,18 @@ import ChatInput from '../../components/ChatInput'
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParams, 'SingleChat'>
|
||||
|
||||
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()
|
||||
export default function SingleChatScreen({ route, navigation }: Props) {
|
||||
const { targetId, targetName, targetAvatar } = route.params
|
||||
const { userId, profile } = useAuth()
|
||||
const [messages, setMessages] = useState<ImMessage[]>([])
|
||||
const [page, setPage] = useState(0)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [editingMessage, setEditingMessage] = useState<ImMessage | null>(null)
|
||||
const listRef = useRef<FlatList<ImMessage>>(null)
|
||||
|
||||
const loadHistory = useCallback(async (p = 0) => {
|
||||
try {
|
||||
const history = await ImSDK.fetchHistory(targetId, p, 30)
|
||||
setMessages(prev => {
|
||||
const merged = p === 0 ? history : [...history, ...prev]
|
||||
return merged.reduce<ImMessage[]>((acc, message) => upsertMessage(acc, message), [])
|
||||
})
|
||||
setMessages(prev => p === 0 ? history : [...history, ...prev])
|
||||
setPage(p)
|
||||
} catch {/* ignore */}
|
||||
}, [targetId])
|
||||
@ -51,7 +37,8 @@ export default function SingleChatScreen({ route }: Props) {
|
||||
if ((msg.fromUserId === targetId && msg.toId === userId) ||
|
||||
(msg.fromUserId === userId && msg.toId === targetId)) {
|
||||
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)
|
||||
}
|
||||
@ -65,7 +52,8 @@ export default function SingleChatScreen({ route }: Props) {
|
||||
|
||||
const onSent = (msg: ImMessage) => {
|
||||
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)
|
||||
}
|
||||
@ -79,10 +67,6 @@ export default function SingleChatScreen({ route }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (message: ImMessage) => {
|
||||
setEditingMessage(message)
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore) return
|
||||
setLoadingMore(true)
|
||||
@ -107,7 +91,6 @@ export default function SingleChatScreen({ route }: Props) {
|
||||
isOwn={item.fromUserId === userId}
|
||||
peerNickname={targetName}
|
||||
onRevoke={handleRevoke}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.list}
|
||||
@ -115,13 +98,7 @@ export default function SingleChatScreen({ route }: Props) {
|
||||
onEndReached={loadMore}
|
||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||
/>
|
||||
<ChatInput
|
||||
toId={targetId}
|
||||
chatType="SINGLE"
|
||||
onSent={onSent}
|
||||
editingMessage={editingMessage}
|
||||
onCancelEdit={() => setEditingMessage(null)}
|
||||
/>
|
||||
<ChatInput toId={targetId} chatType="SINGLE" onSent={onSent} />
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
|
||||
@ -6,9 +6,8 @@ import {
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import type { RootStackParams } from '../../navigation/types'
|
||||
import { ImSDK, type FriendRequest, type ImEventListener, type ImMessage } from '@xuqm/rn-sdk'
|
||||
import type { UserProfile } from '../../api/demo'
|
||||
import { toDemoUserProfile } from '../../utils/userProfiles'
|
||||
import { ImSDK } from '@xuqm/rn-sdk'
|
||||
import { demoApi, type UserProfile } from '../../api/demo'
|
||||
import { load, save, K } from '../../utils/storage'
|
||||
|
||||
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() {
|
||||
const navigation = useNavigation<Nav>()
|
||||
const [contacts, setContacts] = useState<UserProfile[]>([])
|
||||
const [friendRequests, setFriendRequests] = useState<FriendRequest[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const refreshFriendRequests = useCallback(async () => {
|
||||
try {
|
||||
const list = await ImSDK.listFriendRequests('incoming')
|
||||
setFriendRequests(list)
|
||||
} catch {
|
||||
setFriendRequests([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchContacts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@ -72,9 +45,9 @@ export default function ContactsScreen() {
|
||||
await Promise.all(
|
||||
friendIds.map(async (id) => {
|
||||
try {
|
||||
const results = await ImSDK.searchUsers(id)
|
||||
const results = await demoApi.searchUsers(id)
|
||||
const match = results.find(u => u.userId === id)
|
||||
if (match) profiles.push(toDemoUserProfile(match))
|
||||
if (match) profiles.push(match)
|
||||
} 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(
|
||||
useCallback(() => {
|
||||
fetchContacts()
|
||||
refreshFriendRequests()
|
||||
}, [fetchContacts, refreshFriendRequests]),
|
||||
}, [fetchContacts]),
|
||||
)
|
||||
|
||||
const openChat = (user: UserProfile) => {
|
||||
@ -123,9 +77,6 @@ export default function ContactsScreen() {
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>通讯录</Text>
|
||||
<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}>
|
||||
<Text style={styles.headerBtnText}>群聊</Text>
|
||||
</TouchableOpacity>
|
||||
@ -134,21 +85,22 @@ export default function ContactsScreen() {
|
||||
</TouchableOpacity>
|
||||
</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" />}
|
||||
<FlatList
|
||||
data={contacts}
|
||||
keyExtractor={u => u.userId}
|
||||
renderItem={({ item }) => <ContactRow user={item} onPress={() => openChat(item)} />}
|
||||
ItemSeparatorComponent={ContactsSeparator}
|
||||
ListEmptyComponent={<ContactsEmpty loading={loading} onGoSearch={() => navigation.navigate('UserSearch')} />}
|
||||
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
||||
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>
|
||||
)
|
||||
@ -161,9 +113,6 @@ const styles = StyleSheet.create({
|
||||
headerActions: { flexDirection: 'row', gap: 12 },
|
||||
headerBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 },
|
||||
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 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
||||
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 {
|
||||
View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity,
|
||||
SafeAreaView, ActivityIndicator, Alert, Image, Modal,
|
||||
SafeAreaView, ActivityIndicator, Alert, Image,
|
||||
} from 'react-native'
|
||||
import { useNavigation, useRoute } from '@react-navigation/native'
|
||||
import type { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||
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 { toDemoUserProfiles } from '../../utils/userProfiles'
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||
type Props = NativeStackScreenProps<RootStackParams, 'UserSearch'>
|
||||
@ -20,8 +20,6 @@ export default function UserSearchScreen() {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [results, setResults] = useState<UserProfile[]>([])
|
||||
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 search = useCallback((text: string) => {
|
||||
@ -31,8 +29,8 @@ export default function UserSearchScreen() {
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await ImSDK.searchUsers(text.trim())
|
||||
setResults(toDemoUserProfiles(res))
|
||||
const res = await demoApi.searchUsers(text.trim())
|
||||
setResults(res)
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
@ -50,20 +48,16 @@ export default function UserSearchScreen() {
|
||||
Alert.alert('添加失败', e?.message ?? '请重试')
|
||||
}
|
||||
} else {
|
||||
setPendingUser(user)
|
||||
setFriendRemark('')
|
||||
}
|
||||
}
|
||||
|
||||
const sendFriendRequest = async () => {
|
||||
if (!pendingUser) return
|
||||
try {
|
||||
await ImSDK.sendFriendRequest(pendingUser.userId, friendRemark.trim() || undefined)
|
||||
Alert.alert('好友申请已发送')
|
||||
setPendingUser(null)
|
||||
setFriendRemark('')
|
||||
} catch (e: any) {
|
||||
Alert.alert('添加失败', e?.message ?? '请重试')
|
||||
await ImSDK.addFriend(user.userId)
|
||||
const current = (await load<UserProfile[]>(K.CONTACTS)) ?? []
|
||||
if (!current.find(c => c.userId === user.userId)) {
|
||||
await save(K.CONTACTS, [...current, user])
|
||||
}
|
||||
Alert.alert('已添加为好友')
|
||||
} catch {
|
||||
Alert.alert('添加失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -117,29 +111,6 @@ export default function UserSearchScreen() {
|
||||
: 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>
|
||||
)
|
||||
}
|
||||
@ -161,15 +132,4 @@ const styles = StyleSheet.create({
|
||||
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 68 },
|
||||
empty: { alignItems: 'center', paddingTop: 60 },
|
||||
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 DisconnectBanner from '../../components/DisconnectBanner'
|
||||
import ConversationItem from '../../components/ConversationItem'
|
||||
import type { UserProfile } from '../../api/demo'
|
||||
import { toDemoUserProfile } from '../../utils/userProfiles'
|
||||
import { demoApi, type UserProfile } from '../../api/demo'
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||
|
||||
@ -28,9 +27,8 @@ export default function ConversationListScreen() {
|
||||
let profile = profileCache.current[conv.targetId]
|
||||
if (!profile) {
|
||||
try {
|
||||
const results = await ImSDK.searchUsers(conv.targetId)
|
||||
const match = results.find(u => u.userId === conv.targetId)
|
||||
profile = match ? toDemoUserProfile(match) : { appId, userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' }
|
||||
const results = await demoApi.searchUsers(conv.targetId)
|
||||
profile = results.find(u => u.userId === conv.targetId) ?? { appId, userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' }
|
||||
profileCache.current[conv.targetId] = profile
|
||||
} catch {
|
||||
profile = { appId, userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' }
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import React, { useState, useCallback, useRef } from 'react'
|
||||
import {
|
||||
View, Text, TextInput, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||
ActivityIndicator, Alert, ScrollView,
|
||||
FlatList, ActivityIndicator, Alert, ScrollView,
|
||||
} from 'react-native'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { ImSDK } from '@xuqm/rn-sdk'
|
||||
import type { UserProfile } from '../../api/demo'
|
||||
import { toDemoUserProfiles } from '../../utils/userProfiles'
|
||||
import { demoApi, type UserProfile } from '../../api/demo'
|
||||
|
||||
export default function CreateGroupScreen() {
|
||||
const navigation = useNavigation()
|
||||
@ -25,8 +24,8 @@ export default function CreateGroupScreen() {
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setSearching(true)
|
||||
try {
|
||||
const res = await ImSDK.searchUsers(text.trim())
|
||||
setSearchResults(toDemoUserProfiles(res))
|
||||
const res = await demoApi.searchUsers(text.trim())
|
||||
setSearchResults(res)
|
||||
} catch {
|
||||
setSearchResults([])
|
||||
} 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 {
|
||||
View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||
ActivityIndicator,
|
||||
} from 'react-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 type { ImGroup } from '@xuqm/rn-sdk'
|
||||
import type { RootStackParams } from '../../navigation/types'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||
|
||||
@ -34,34 +35,20 @@ export default function GroupListScreen() {
|
||||
const navigation = useNavigation<Nav>()
|
||||
const [groups, setGroups] = useState<ImGroup[]>([])
|
||||
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)
|
||||
try {
|
||||
const list = text.trim()
|
||||
? await ImSDK.searchGroups(text.trim())
|
||||
: await ImSDK.listGroups()
|
||||
const list = await ImSDK.listGroups()
|
||||
setGroups(list)
|
||||
} catch {
|
||||
/* silently fail */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [keyword])
|
||||
}
|
||||
|
||||
const handleSearch = useCallback((text: string) => {
|
||||
setKeyword(text)
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchGroups(text)
|
||||
}, 300)
|
||||
}, [fetchGroups])
|
||||
|
||||
useFocusEffect(useCallback(() => {
|
||||
fetchGroups()
|
||||
}, [fetchGroups]))
|
||||
useFocusEffect(useCallback(() => { fetchGroups() }, []))
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.root}>
|
||||
@ -71,17 +58,8 @@ export default function GroupListScreen() {
|
||||
<Text style={styles.createBtnText}>+ 创建</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.searchBar}>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="搜索群名称或群号"
|
||||
value={keyword}
|
||||
onChangeText={handleSearch}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
{loading
|
||||
? <ActivityIndicator style={styles.loading} color="#07C160" />
|
||||
? <ActivityIndicator style={{ flex: 1 }} color="#07C160" />
|
||||
: (
|
||||
<FlatList
|
||||
data={groups}
|
||||
@ -107,9 +85,6 @@ const styles = StyleSheet.create({
|
||||
title: { fontSize: 18, fontWeight: '700', color: '#111' },
|
||||
createBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 },
|
||||
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' },
|
||||
avatar: { width: 46, height: 46, borderRadius: 8, backgroundColor: '#5856d6', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
|
||||
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 {
|
||||
View, Text, FlatList, StyleSheet, SafeAreaView, ActivityIndicator, Image,
|
||||
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView, ActivityIndicator, Alert, Image,
|
||||
} 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 { 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 { toDemoUserProfile } from '../../utils/userProfiles'
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParams, 'GroupMembers'>
|
||||
|
||||
export default function GroupMembersScreen({ route }: Props) {
|
||||
const { groupId } = route.params
|
||||
const { groupId, groupName } = route.params
|
||||
const appId = 'ak_demo_chat'
|
||||
const navigation = useNavigation()
|
||||
const [members, setMembers] = useState<UserProfile[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@ -28,9 +28,8 @@ export default function GroupMembersScreen({ route }: Props) {
|
||||
catch { ids = group.memberIds ? group.memberIds.split(',').filter(Boolean) : [] }
|
||||
const profiles = await Promise.all(
|
||||
ids.map(async id => {
|
||||
const res = await ImSDK.searchUsers(id)
|
||||
const match = res.find(u => u.userId === id)
|
||||
return match ? toDemoUserProfile(match, appId) : { appId, userId: id, nickname: id, avatar: '', gender: 'UNKNOWN' as const, status: '' }
|
||||
const res = await demoApi.searchUsers(id)
|
||||
return res.find(u => u.userId === id) ?? { appId, userId: id, nickname: id, avatar: '', gender: 'UNKNOWN' as const, status: '' }
|
||||
})
|
||||
)
|
||||
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 { useNavigation } from '@react-navigation/native'
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||
import { ImSDK } from '@xuqm/rn-sdk'
|
||||
import type { RootStackParams } from '../../navigation/types'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParams, 'GroupSettings'>
|
||||
|
||||
export default function GroupSettingsScreen({ route }: Props) {
|
||||
const { groupId, groupName } = route.params
|
||||
const { groupId, groupName, isAdmin } = route.params
|
||||
const nav = useNavigation()
|
||||
const { userId } = useAuth()
|
||||
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 }) {
|
||||
return (
|
||||
@ -41,7 +25,6 @@ export default function GroupSettingsScreen({ route }: Props) {
|
||||
}
|
||||
|
||||
const openMembers = () => (nav as any).navigate('GroupMembers', { groupId, groupName })
|
||||
const openJoinRequests = () => (nav as any).navigate('GroupJoinRequests', { groupId, groupName })
|
||||
|
||||
const handleLeave = () => {
|
||||
Alert.alert('退出群聊', '退出后将不再收到该群消息,确定吗?', [
|
||||
@ -75,8 +58,6 @@ export default function GroupSettingsScreen({ route }: Props) {
|
||||
<>
|
||||
<View style={styles.sep} />
|
||||
<Row label="添加成员" onPress={() => (nav as any).navigate('UserSearch', { addToGroupId: groupId })} />
|
||||
<View style={styles.sep} />
|
||||
<Row label="入群申请" onPress={openJoinRequests} />
|
||||
</>
|
||||
)}
|
||||
</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",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"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
|
||||
"types": ["jest"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["**/node_modules", "**/Pods"]
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户