feat(chat): 添加聊天界面和文件更新SDK功能
- 实现完整的聊天界面UI组件,支持文本、图片、视频、音频、文件等多种消息类型 - 集成IM消息收发功能,实现消息气泡显示和用户头像占位符 - 添加媒体文件选择和拍摄功能,支持相册图片、视频及相机拍照录像 - 实现语音录制和播放功能,包含按住说话交互和权限处理 - 添加群组提及功能,支持@用户和提及候选列表显示 - 实现消息回复和引用功能,支持消息长按回复操作 - 添加本地消息搜索功能,支持搜索当前会话的历史消息 - 实现文件上传下载功能,集成FileSDK进行文件传输管理 - 添加应用更新检查功能,集成UpdateSDK支持版本更新 - 实现消息状态显示,包括发送、送达、已读等状态标识 - 添加群组已读人数统计,显示消息在群聊中的阅读情况 - 实现草稿保存和恢复功能,支持断点续聊体验 - 添加连接状态横幅,实时显示IM服务连接状态 - 实现滚动加载更多历史消息,优化大量消息的性能表现 - 添加多媒体文件下载保存功能,支持保存到应用专属目录
这个提交包含在:
父节点
f7d68681b5
当前提交
069f3454fe
@ -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 ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,8 @@ const ALL_MSG_TYPES: MsgType[] = [
|
||||
'CALL_AUDIO',
|
||||
'CALL_VIDEO',
|
||||
'FORWARD',
|
||||
'QUOTE',
|
||||
'MERGE',
|
||||
]
|
||||
|
||||
const TYPE_LABELS: Record<MsgType, string> = {
|
||||
@ -30,6 +32,8 @@ const TYPE_LABELS: Record<MsgType, string> = {
|
||||
CALL_AUDIO: '语音通话',
|
||||
CALL_VIDEO: '视频通话',
|
||||
FORWARD: '转发',
|
||||
QUOTE: '引用',
|
||||
MERGE: '合并',
|
||||
REVOKED: '已撤回',
|
||||
}
|
||||
|
||||
@ -46,6 +50,8 @@ const TYPE_ICONS: Record<MsgType, string> = {
|
||||
CALL_AUDIO: '📞',
|
||||
CALL_VIDEO: '📹',
|
||||
FORWARD: '↪️',
|
||||
QUOTE: '💭',
|
||||
MERGE: '🗂️',
|
||||
REVOKED: '🚫',
|
||||
}
|
||||
|
||||
@ -110,6 +116,15 @@ 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,10 +16,12 @@ import ProfileScreen from '../screens/profile/ProfileScreen'
|
||||
import SingleChatScreen from '../screens/chat/SingleChatScreen'
|
||||
import GroupChatScreen from '../screens/chat/GroupChatScreen'
|
||||
import UserSearchScreen from '../screens/contact/UserSearchScreen'
|
||||
import FriendRequestsScreen from '../screens/contact/FriendRequestsScreen'
|
||||
import GroupListScreen from '../screens/group/GroupListScreen'
|
||||
import CreateGroupScreen from '../screens/group/CreateGroupScreen'
|
||||
import GroupMembersScreen from '../screens/group/GroupMembersScreen'
|
||||
import GroupSettingsScreen from '../screens/group/GroupSettingsScreen'
|
||||
import GroupJoinRequestsScreen from '../screens/group/GroupJoinRequestsScreen'
|
||||
import EditProfileScreen from '../screens/profile/EditProfileScreen'
|
||||
import MessageSearchScreen from '../screens/chat/MessageSearchScreen'
|
||||
import DisconnectBanner from '../components/DisconnectBanner'
|
||||
@ -62,10 +64,12 @@ function AppStack() {
|
||||
<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,10 +15,12 @@ export type RootStackParams = {
|
||||
SingleChat: { targetId: string; targetName: string; targetAvatar?: string }
|
||||
GroupChat: { groupId: string; groupName: string }
|
||||
UserSearch: { addToGroupId?: string } | undefined
|
||||
FriendRequests: undefined
|
||||
GroupList: undefined
|
||||
CreateGroup: undefined
|
||||
GroupMembers: { groupId: string; groupName: string }
|
||||
GroupSettings: { groupId: string; groupName: string; isAdmin: boolean }
|
||||
GroupSettings: { groupId: string; groupName: string; isAdmin?: boolean }
|
||||
GroupJoinRequests: { groupId: string; groupName: string }
|
||||
EditProfile: undefined
|
||||
MessageSearch: { targetId?: string; chatType?: 'SINGLE' | 'GROUP' }
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
View, FlatList, StyleSheet, Alert, SafeAreaView,
|
||||
FlatList, StyleSheet, Alert, SafeAreaView,
|
||||
KeyboardAvoidingView, Platform, TouchableOpacity, Text,
|
||||
} from 'react-native'
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||
@ -13,6 +13,28 @@ import ChatInput from '../../components/ChatInput'
|
||||
|
||||
type Props = NativeStackScreenProps<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()
|
||||
@ -24,7 +46,10 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
||||
const loadHistory = useCallback(async (p = 0) => {
|
||||
try {
|
||||
const history = await ImSDK.fetchGroupHistory(groupId, p, 30)
|
||||
setMessages(prev => p === 0 ? history : [...history, ...prev])
|
||||
setMessages(prev => {
|
||||
const merged = p === 0 ? history : [...history, ...prev]
|
||||
return merged.reduce<ImMessage[]>((acc, message) => upsertMessage(acc, message), [])
|
||||
})
|
||||
setPage(p)
|
||||
} catch {/* ignore */}
|
||||
}, [groupId])
|
||||
@ -38,8 +63,7 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
||||
onGroupMessage(msg) {
|
||||
if (msg.toId !== groupId) return
|
||||
setMessages(prev => {
|
||||
if (prev.find(m => m.id === msg.id)) return prev
|
||||
return [...prev, msg]
|
||||
return upsertMessage(prev, msg)
|
||||
})
|
||||
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
||||
},
|
||||
@ -50,18 +74,14 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => navigation.navigate('GroupSettings', { groupId, groupName, isAdmin: false })}>
|
||||
<Text style={styles.settingsIcon}>⚙️</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
headerRight: () => <GroupHeaderButton navigation={navigation} groupId={groupId} groupName={groupName} />,
|
||||
})
|
||||
}, [navigation, groupId, groupName])
|
||||
|
||||
const onSent = (msg: ImMessage) => {
|
||||
setMessages(prev => {
|
||||
if (prev.find(m => m.id === msg.id)) return prev
|
||||
return [...prev, msg]
|
||||
return upsertMessage(prev, msg)
|
||||
})
|
||||
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
View, FlatList, StyleSheet, Alert, SafeAreaView,
|
||||
FlatList, StyleSheet, Alert, SafeAreaView,
|
||||
KeyboardAvoidingView, Platform,
|
||||
} from 'react-native'
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||
@ -13,9 +13,19 @@ import ChatInput from '../../components/ChatInput'
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParams, 'SingleChat'>
|
||||
|
||||
export default function SingleChatScreen({ route, navigation }: Props) {
|
||||
const { targetId, targetName, targetAvatar } = route.params
|
||||
const { userId, profile } = useAuth()
|
||||
function upsertMessage(messages: ImMessage[], message: ImMessage): ImMessage[] {
|
||||
const index = messages.findIndex(item => item.id === message.id)
|
||||
if (index >= 0) {
|
||||
const next = [...messages]
|
||||
next[index] = message
|
||||
return next
|
||||
}
|
||||
return [...messages, message]
|
||||
}
|
||||
|
||||
export default function SingleChatScreen({ route }: Props) {
|
||||
const { targetId, targetName } = route.params
|
||||
const { userId } = useAuth()
|
||||
const [messages, setMessages] = useState<ImMessage[]>([])
|
||||
const [page, setPage] = useState(0)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
@ -24,7 +34,10 @@ export default function SingleChatScreen({ route, navigation }: Props) {
|
||||
const loadHistory = useCallback(async (p = 0) => {
|
||||
try {
|
||||
const history = await ImSDK.fetchHistory(targetId, p, 30)
|
||||
setMessages(prev => p === 0 ? history : [...history, ...prev])
|
||||
setMessages(prev => {
|
||||
const merged = p === 0 ? history : [...history, ...prev]
|
||||
return merged.reduce<ImMessage[]>((acc, message) => upsertMessage(acc, message), [])
|
||||
})
|
||||
setPage(p)
|
||||
} catch {/* ignore */}
|
||||
}, [targetId])
|
||||
@ -37,8 +50,7 @@ export default function SingleChatScreen({ route, navigation }: Props) {
|
||||
if ((msg.fromUserId === targetId && msg.toId === userId) ||
|
||||
(msg.fromUserId === userId && msg.toId === targetId)) {
|
||||
setMessages(prev => {
|
||||
if (prev.find(m => m.id === msg.id)) return prev
|
||||
return [...prev, msg]
|
||||
return upsertMessage(prev, msg)
|
||||
})
|
||||
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
||||
}
|
||||
@ -52,8 +64,7 @@ export default function SingleChatScreen({ route, navigation }: Props) {
|
||||
|
||||
const onSent = (msg: ImMessage) => {
|
||||
setMessages(prev => {
|
||||
if (prev.find(m => m.id === msg.id)) return prev
|
||||
return [...prev, msg]
|
||||
return upsertMessage(prev, msg)
|
||||
})
|
||||
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
||||
}
|
||||
|
||||
@ -6,8 +6,9 @@ import {
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
import type { RootStackParams } from '../../navigation/types'
|
||||
import { ImSDK } from '@xuqm/rn-sdk'
|
||||
import { demoApi, type UserProfile } from '../../api/demo'
|
||||
import { ImSDK, type FriendRequest, type ImEventListener, type ImMessage } from '@xuqm/rn-sdk'
|
||||
import type { UserProfile } from '../../api/demo'
|
||||
import { toDemoUserProfile } from '../../utils/userProfiles'
|
||||
import { load, save, K } from '../../utils/storage'
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||
@ -32,11 +33,37 @@ function ContactRow({ user, onPress }: { user: UserProfile; onPress(): void }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ContactsEmpty({ loading, onGoSearch }: { loading: boolean; onGoSearch(): void }) {
|
||||
if (loading) return null
|
||||
return (
|
||||
<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 {
|
||||
@ -45,9 +72,9 @@ export default function ContactsScreen() {
|
||||
await Promise.all(
|
||||
friendIds.map(async (id) => {
|
||||
try {
|
||||
const results = await demoApi.searchUsers(id)
|
||||
const results = await ImSDK.searchUsers(id)
|
||||
const match = results.find(u => u.userId === id)
|
||||
if (match) profiles.push(match)
|
||||
if (match) profiles.push(toDemoUserProfile(match))
|
||||
} catch {/* skip individual failures */}
|
||||
}),
|
||||
)
|
||||
@ -62,10 +89,29 @@ 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()
|
||||
}, [fetchContacts]),
|
||||
refreshFriendRequests()
|
||||
}, [fetchContacts, refreshFriendRequests]),
|
||||
)
|
||||
|
||||
const openChat = (user: UserProfile) => {
|
||||
@ -77,6 +123,9 @@ 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>
|
||||
@ -85,22 +134,21 @@ 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={() => <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
|
||||
}
|
||||
ItemSeparatorComponent={ContactsSeparator}
|
||||
ListEmptyComponent={<ContactsEmpty loading={loading} onGoSearch={() => navigation.navigate('UserSearch')} />}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
@ -113,6 +161,9 @@ 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 },
|
||||
|
||||
@ -0,0 +1,134 @@
|
||||
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,
|
||||
SafeAreaView, ActivityIndicator, Alert, Image, Modal,
|
||||
} 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 { demoApi, type UserProfile } from '../../api/demo'
|
||||
import { load, save, K } from '../../utils/storage'
|
||||
import type { UserProfile } from '../../api/demo'
|
||||
import type { RootStackParams } from '../../navigation/types'
|
||||
import { toDemoUserProfiles } from '../../utils/userProfiles'
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||
type Props = NativeStackScreenProps<RootStackParams, 'UserSearch'>
|
||||
@ -20,6 +20,8 @@ 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) => {
|
||||
@ -29,8 +31,8 @@ export default function UserSearchScreen() {
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await demoApi.searchUsers(text.trim())
|
||||
setResults(res)
|
||||
const res = await ImSDK.searchUsers(text.trim())
|
||||
setResults(toDemoUserProfiles(res))
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
@ -48,16 +50,20 @@ export default function UserSearchScreen() {
|
||||
Alert.alert('添加失败', e?.message ?? '请重试')
|
||||
}
|
||||
} else {
|
||||
setPendingUser(user)
|
||||
setFriendRemark('')
|
||||
}
|
||||
}
|
||||
|
||||
const sendFriendRequest = async () => {
|
||||
if (!pendingUser) return
|
||||
try {
|
||||
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('添加失败')
|
||||
}
|
||||
await ImSDK.sendFriendRequest(pendingUser.userId, friendRemark.trim() || undefined)
|
||||
Alert.alert('好友申请已发送')
|
||||
setPendingUser(null)
|
||||
setFriendRemark('')
|
||||
} catch (e: any) {
|
||||
Alert.alert('添加失败', e?.message ?? '请重试')
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,6 +117,29 @@ 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>
|
||||
)
|
||||
}
|
||||
@ -132,4 +161,15 @@ 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,7 +8,8 @@ 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 { demoApi, type UserProfile } from '../../api/demo'
|
||||
import type { UserProfile } from '../../api/demo'
|
||||
import { toDemoUserProfile } from '../../utils/userProfiles'
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||
|
||||
@ -27,8 +28,9 @@ export default function ConversationListScreen() {
|
||||
let profile = profileCache.current[conv.targetId]
|
||||
if (!profile) {
|
||||
try {
|
||||
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: '' }
|
||||
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: '' }
|
||||
profileCache.current[conv.targetId] = profile
|
||||
} catch {
|
||||
profile = { appId, userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' }
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import React, { useState, useCallback, useRef } from 'react'
|
||||
import {
|
||||
View, Text, TextInput, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||
FlatList, ActivityIndicator, Alert, ScrollView,
|
||||
ActivityIndicator, Alert, ScrollView,
|
||||
} from 'react-native'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { ImSDK } from '@xuqm/rn-sdk'
|
||||
import { demoApi, type UserProfile } from '../../api/demo'
|
||||
import type { UserProfile } from '../../api/demo'
|
||||
import { toDemoUserProfiles } from '../../utils/userProfiles'
|
||||
|
||||
export default function CreateGroupScreen() {
|
||||
const navigation = useNavigation()
|
||||
@ -24,8 +25,8 @@ export default function CreateGroupScreen() {
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setSearching(true)
|
||||
try {
|
||||
const res = await demoApi.searchUsers(text.trim())
|
||||
setSearchResults(res)
|
||||
const res = await ImSDK.searchUsers(text.trim())
|
||||
setSearchResults(toDemoUserProfiles(res))
|
||||
} catch {
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
|
||||
@ -0,0 +1,170 @@
|
||||
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, useEffect, useState } from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||
View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||
ActivityIndicator,
|
||||
} from 'react-native'
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
||||
@ -8,7 +8,6 @@ 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>
|
||||
|
||||
@ -35,20 +34,34 @@ 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 = async () => {
|
||||
const fetchGroups = useCallback(async (text = keyword) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const list = await ImSDK.listGroups()
|
||||
const list = text.trim()
|
||||
? await ImSDK.searchGroups(text.trim())
|
||||
: await ImSDK.listGroups()
|
||||
setGroups(list)
|
||||
} catch {
|
||||
/* silently fail */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [keyword])
|
||||
|
||||
useFocusEffect(useCallback(() => { fetchGroups() }, []))
|
||||
const handleSearch = useCallback((text: string) => {
|
||||
setKeyword(text)
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchGroups(text)
|
||||
}, 300)
|
||||
}, [fetchGroups])
|
||||
|
||||
useFocusEffect(useCallback(() => {
|
||||
fetchGroups()
|
||||
}, [fetchGroups]))
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.root}>
|
||||
@ -58,8 +71,17 @@ 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={{ flex: 1 }} color="#07C160" />
|
||||
? <ActivityIndicator style={styles.loading} color="#07C160" />
|
||||
: (
|
||||
<FlatList
|
||||
data={groups}
|
||||
@ -85,6 +107,9 @@ 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, useEffect, useState } from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import {
|
||||
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView, ActivityIndicator, Alert, Image,
|
||||
View, Text, FlatList, StyleSheet, SafeAreaView, ActivityIndicator, Image,
|
||||
} from 'react-native'
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
||||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||
import { ImSDK } from '@xuqm/rn-sdk'
|
||||
import { demoApi, type UserProfile } from '../../api/demo'
|
||||
import 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, groupName } = route.params
|
||||
const { groupId } = route.params
|
||||
const appId = 'ak_demo_chat'
|
||||
const navigation = useNavigation()
|
||||
const [members, setMembers] = useState<UserProfile[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@ -28,8 +28,9 @@ 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 demoApi.searchUsers(id)
|
||||
return res.find(u => u.userId === id) ?? { appId, userId: id, nickname: id, avatar: '', gender: 'UNKNOWN' as const, status: '' }
|
||||
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: '' }
|
||||
})
|
||||
)
|
||||
setMembers(profiles)
|
||||
|
||||
@ -1,16 +1,32 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useEffect, 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, isAdmin } = route.params
|
||||
const { groupId, groupName } = 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 (
|
||||
@ -25,6 +41,7 @@ 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('退出群聊', '退出后将不再收到该群消息,确定吗?', [
|
||||
@ -58,6 +75,8 @@ 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>
|
||||
|
||||
25
src/utils/userProfiles.ts
普通文件
25
src/utils/userProfiles.ts
普通文件
@ -0,0 +1,25 @@
|
||||
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,7 +1,20 @@
|
||||
{
|
||||
"extends": "@react-native/typescript-config",
|
||||
"compilerOptions": {
|
||||
"types": ["jest"]
|
||||
"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
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["**/node_modules", "**/Pods"]
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户