feat(chat): 添加聊天界面和文件更新SDK功能

- 实现完整的聊天界面UI组件,支持文本、图片、视频、音频、文件等多种消息类型
- 集成IM消息收发功能,实现消息气泡显示和用户头像占位符
- 添加媒体文件选择和拍摄功能,支持相册图片、视频及相机拍照录像
- 实现语音录制和播放功能,包含按住说话交互和权限处理
- 添加群组提及功能,支持@用户和提及候选列表显示
- 实现消息回复和引用功能,支持消息长按回复操作
- 添加本地消息搜索功能,支持搜索当前会话的历史消息
- 实现文件上传下载功能,集成FileSDK进行文件传输管理
- 添加应用更新检查功能,集成UpdateSDK支持版本更新
- 实现消息状态显示,包括发送、送达、已读等状态标识
- 添加群组已读人数统计,显示消息在群聊中的阅读情况
- 实现草稿保存和恢复功能,支持断点续聊体验
- 添加连接状态横幅,实时显示IM服务连接状态
- 实现滚动加载更多历史消息,优化大量消息的性能表现
- 添加多媒体文件下载保存功能,支持保存到应用专属目录
这个提交包含在:
徐勤民 2026-04-28 20:11:38 +08:00
父节点 f7d68681b5
当前提交 069f3454fe
共有 17 个文件被更改,包括 620 次插入87 次删除

查看文件

@ -27,7 +27,7 @@ function lastMsgPreview(item: ConversationData): string {
case 'VIDEO': return '[视频]' case 'VIDEO': return '[视频]'
case 'AUDIO': return '[语音]' case 'AUDIO': return '[语音]'
case 'FILE': return '[文件]' case 'FILE': return '[文件]'
default: return item.lastMsgContent default: return item.lastMsgContent ?? ''
} }
} }

查看文件

@ -15,6 +15,8 @@ const ALL_MSG_TYPES: MsgType[] = [
'CALL_AUDIO', 'CALL_AUDIO',
'CALL_VIDEO', 'CALL_VIDEO',
'FORWARD', 'FORWARD',
'QUOTE',
'MERGE',
] ]
const TYPE_LABELS: Record<MsgType, string> = { const TYPE_LABELS: Record<MsgType, string> = {
@ -30,6 +32,8 @@ const TYPE_LABELS: Record<MsgType, string> = {
CALL_AUDIO: '语音通话', CALL_AUDIO: '语音通话',
CALL_VIDEO: '视频通话', CALL_VIDEO: '视频通话',
FORWARD: '转发', FORWARD: '转发',
QUOTE: '引用',
MERGE: '合并',
REVOKED: '已撤回', REVOKED: '已撤回',
} }
@ -46,6 +50,8 @@ const TYPE_ICONS: Record<MsgType, string> = {
CALL_AUDIO: '📞', CALL_AUDIO: '📞',
CALL_VIDEO: '📹', CALL_VIDEO: '📹',
FORWARD: '↪️', FORWARD: '↪️',
QUOTE: '💭',
MERGE: '🗂️',
REVOKED: '🚫', REVOKED: '🚫',
} }
@ -110,6 +116,15 @@ export const DEMO_CONTENT: Record<MsgType, string> = {
originalSender: 'demo_alice', originalSender: 'demo_alice',
originalTime: new Date().toISOString(), originalTime: new Date().toISOString(),
}), }),
QUOTE: JSON.stringify({
quotedMsgId: 'msg_quote_001',
quotedContent: '这是被引用的消息',
text: '这是一条引用消息',
}),
MERGE: JSON.stringify({
title: '合并转发预览',
msgList: ['消息 1', '消息 2', '消息 3'],
}),
REVOKED: '', REVOKED: '',
} }

查看文件

@ -16,10 +16,12 @@ import ProfileScreen from '../screens/profile/ProfileScreen'
import SingleChatScreen from '../screens/chat/SingleChatScreen' import SingleChatScreen from '../screens/chat/SingleChatScreen'
import GroupChatScreen from '../screens/chat/GroupChatScreen' import GroupChatScreen from '../screens/chat/GroupChatScreen'
import UserSearchScreen from '../screens/contact/UserSearchScreen' import UserSearchScreen from '../screens/contact/UserSearchScreen'
import FriendRequestsScreen from '../screens/contact/FriendRequestsScreen'
import GroupListScreen from '../screens/group/GroupListScreen' import GroupListScreen from '../screens/group/GroupListScreen'
import CreateGroupScreen from '../screens/group/CreateGroupScreen' import CreateGroupScreen from '../screens/group/CreateGroupScreen'
import GroupMembersScreen from '../screens/group/GroupMembersScreen' import GroupMembersScreen from '../screens/group/GroupMembersScreen'
import GroupSettingsScreen from '../screens/group/GroupSettingsScreen' import GroupSettingsScreen from '../screens/group/GroupSettingsScreen'
import GroupJoinRequestsScreen from '../screens/group/GroupJoinRequestsScreen'
import EditProfileScreen from '../screens/profile/EditProfileScreen' import EditProfileScreen from '../screens/profile/EditProfileScreen'
import MessageSearchScreen from '../screens/chat/MessageSearchScreen' import MessageSearchScreen from '../screens/chat/MessageSearchScreen'
import DisconnectBanner from '../components/DisconnectBanner' import DisconnectBanner from '../components/DisconnectBanner'
@ -62,10 +64,12 @@ function AppStack() {
<Root.Screen name="SingleChat" component={SingleChatScreen} options={({ route }) => ({ title: route.params.targetName })} /> <Root.Screen name="SingleChat" component={SingleChatScreen} options={({ route }) => ({ title: route.params.targetName })} />
<Root.Screen name="GroupChat" component={GroupChatScreen} options={({ route }) => ({ title: route.params.groupName })} /> <Root.Screen name="GroupChat" component={GroupChatScreen} options={({ route }) => ({ title: route.params.groupName })} />
<Root.Screen name="UserSearch" component={UserSearchScreen} options={{ title: '搜索联系人' }} /> <Root.Screen name="UserSearch" component={UserSearchScreen} options={{ title: '搜索联系人' }} />
<Root.Screen name="FriendRequests" component={FriendRequestsScreen} options={{ title: '好友申请' }} />
<Root.Screen name="GroupList" component={GroupListScreen} options={{ title: '群聊' }} /> <Root.Screen name="GroupList" component={GroupListScreen} options={{ title: '群聊' }} />
<Root.Screen name="CreateGroup" component={CreateGroupScreen} options={{ title: '创建群聊' }} /> <Root.Screen name="CreateGroup" component={CreateGroupScreen} options={{ title: '创建群聊' }} />
<Root.Screen name="GroupMembers" component={GroupMembersScreen} options={{ title: '群成员' }} /> <Root.Screen name="GroupMembers" component={GroupMembersScreen} options={{ title: '群成员' }} />
<Root.Screen name="GroupSettings" component={GroupSettingsScreen} options={{ title: '群设置' }} /> <Root.Screen name="GroupSettings" component={GroupSettingsScreen} options={{ title: '群设置' }} />
<Root.Screen name="GroupJoinRequests" component={GroupJoinRequestsScreen} options={{ title: '入群申请' }} />
<Root.Screen name="EditProfile" component={EditProfileScreen} options={{ title: '编辑资料' }} /> <Root.Screen name="EditProfile" component={EditProfileScreen} options={{ title: '编辑资料' }} />
<Root.Screen name="MessageSearch" component={MessageSearchScreen} options={{ title: '搜索消息' }} /> <Root.Screen name="MessageSearch" component={MessageSearchScreen} options={{ title: '搜索消息' }} />
</Root.Navigator> </Root.Navigator>

查看文件

@ -15,10 +15,12 @@ export type RootStackParams = {
SingleChat: { targetId: string; targetName: string; targetAvatar?: string } SingleChat: { targetId: string; targetName: string; targetAvatar?: string }
GroupChat: { groupId: string; groupName: string } GroupChat: { groupId: string; groupName: string }
UserSearch: { addToGroupId?: string } | undefined UserSearch: { addToGroupId?: string } | undefined
FriendRequests: undefined
GroupList: undefined GroupList: undefined
CreateGroup: undefined CreateGroup: undefined
GroupMembers: { groupId: string; groupName: string } GroupMembers: { groupId: string; groupName: string }
GroupSettings: { groupId: string; groupName: string; isAdmin: boolean } GroupSettings: { groupId: string; groupName: string; isAdmin?: boolean }
GroupJoinRequests: { groupId: string; groupName: string }
EditProfile: undefined EditProfile: undefined
MessageSearch: { targetId?: string; chatType?: 'SINGLE' | 'GROUP' } MessageSearch: { targetId?: string; chatType?: 'SINGLE' | 'GROUP' }
} }

查看文件

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { import {
View, FlatList, StyleSheet, Alert, SafeAreaView, FlatList, StyleSheet, Alert, SafeAreaView,
KeyboardAvoidingView, Platform, TouchableOpacity, Text, KeyboardAvoidingView, Platform, TouchableOpacity, Text,
} from 'react-native' } from 'react-native'
import type { NativeStackScreenProps } from '@react-navigation/native-stack' import type { NativeStackScreenProps } from '@react-navigation/native-stack'
@ -13,6 +13,28 @@ import ChatInput from '../../components/ChatInput'
type Props = NativeStackScreenProps<RootStackParams, 'GroupChat'> type Props = NativeStackScreenProps<RootStackParams, 'GroupChat'>
function GroupHeaderButton({
navigation,
groupId,
groupName,
}: Pick<Props, 'navigation'> & { groupId: string; groupName: string }) {
return (
<TouchableOpacity onPress={() => navigation.navigate('GroupSettings', { groupId, groupName, isAdmin: false })}>
<Text style={styles.settingsIcon}></Text>
</TouchableOpacity>
)
}
function upsertMessage(messages: ImMessage[], message: ImMessage): ImMessage[] {
const index = messages.findIndex(item => item.id === message.id)
if (index >= 0) {
const next = [...messages]
next[index] = message
return next
}
return [...messages, message]
}
export default function GroupChatScreen({ route, navigation }: Props) { export default function GroupChatScreen({ route, navigation }: Props) {
const { groupId, groupName } = route.params const { groupId, groupName } = route.params
const { userId } = useAuth() const { userId } = useAuth()
@ -24,7 +46,10 @@ export default function GroupChatScreen({ route, navigation }: Props) {
const loadHistory = useCallback(async (p = 0) => { const loadHistory = useCallback(async (p = 0) => {
try { try {
const history = await ImSDK.fetchGroupHistory(groupId, p, 30) const history = await ImSDK.fetchGroupHistory(groupId, p, 30)
setMessages(prev => 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) setPage(p)
} catch {/* ignore */} } catch {/* ignore */}
}, [groupId]) }, [groupId])
@ -38,8 +63,7 @@ export default function GroupChatScreen({ route, navigation }: Props) {
onGroupMessage(msg) { onGroupMessage(msg) {
if (msg.toId !== groupId) return if (msg.toId !== groupId) return
setMessages(prev => { setMessages(prev => {
if (prev.find(m => m.id === msg.id)) return prev return upsertMessage(prev, msg)
return [...prev, msg]
}) })
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100) setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
}, },
@ -50,18 +74,14 @@ export default function GroupChatScreen({ route, navigation }: Props) {
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( // eslint-disable-next-line react/no-unstable-nested-components
<TouchableOpacity onPress={() => navigation.navigate('GroupSettings', { groupId, groupName, isAdmin: false })}> headerRight: () => <GroupHeaderButton navigation={navigation} groupId={groupId} groupName={groupName} />,
<Text style={styles.settingsIcon}></Text>
</TouchableOpacity>
),
}) })
}, [navigation, groupId, groupName]) }, [navigation, groupId, groupName])
const onSent = (msg: ImMessage) => { const onSent = (msg: ImMessage) => {
setMessages(prev => { setMessages(prev => {
if (prev.find(m => m.id === msg.id)) return prev return upsertMessage(prev, msg)
return [...prev, msg]
}) })
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100) setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
} }

查看文件

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { import {
View, FlatList, StyleSheet, Alert, SafeAreaView, FlatList, StyleSheet, Alert, SafeAreaView,
KeyboardAvoidingView, Platform, KeyboardAvoidingView, Platform,
} from 'react-native' } from 'react-native'
import type { NativeStackScreenProps } from '@react-navigation/native-stack' import type { NativeStackScreenProps } from '@react-navigation/native-stack'
@ -13,9 +13,19 @@ import ChatInput from '../../components/ChatInput'
type Props = NativeStackScreenProps<RootStackParams, 'SingleChat'> type Props = NativeStackScreenProps<RootStackParams, 'SingleChat'>
export default function SingleChatScreen({ route, navigation }: Props) { function upsertMessage(messages: ImMessage[], message: ImMessage): ImMessage[] {
const { targetId, targetName, targetAvatar } = route.params const index = messages.findIndex(item => item.id === message.id)
const { userId, profile } = useAuth() 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 [messages, setMessages] = useState<ImMessage[]>([])
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
@ -24,7 +34,10 @@ export default function SingleChatScreen({ route, navigation }: Props) {
const loadHistory = useCallback(async (p = 0) => { const loadHistory = useCallback(async (p = 0) => {
try { try {
const history = await ImSDK.fetchHistory(targetId, p, 30) const history = await ImSDK.fetchHistory(targetId, p, 30)
setMessages(prev => 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) setPage(p)
} catch {/* ignore */} } catch {/* ignore */}
}, [targetId]) }, [targetId])
@ -37,8 +50,7 @@ export default function SingleChatScreen({ route, navigation }: Props) {
if ((msg.fromUserId === targetId && msg.toId === userId) || if ((msg.fromUserId === targetId && msg.toId === userId) ||
(msg.fromUserId === userId && msg.toId === targetId)) { (msg.fromUserId === userId && msg.toId === targetId)) {
setMessages(prev => { setMessages(prev => {
if (prev.find(m => m.id === msg.id)) return prev return upsertMessage(prev, msg)
return [...prev, msg]
}) })
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100) setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
} }
@ -52,8 +64,7 @@ export default function SingleChatScreen({ route, navigation }: Props) {
const onSent = (msg: ImMessage) => { const onSent = (msg: ImMessage) => {
setMessages(prev => { setMessages(prev => {
if (prev.find(m => m.id === msg.id)) return prev return upsertMessage(prev, msg)
return [...prev, msg]
}) })
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100) setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
} }

查看文件

@ -6,8 +6,9 @@ import {
import { useNavigation, useFocusEffect } from '@react-navigation/native' import { useNavigation, useFocusEffect } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack' import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import type { RootStackParams } from '../../navigation/types' import type { RootStackParams } from '../../navigation/types'
import { ImSDK } from '@xuqm/rn-sdk' import { ImSDK, type FriendRequest, type ImEventListener, type ImMessage } from '@xuqm/rn-sdk'
import { demoApi, type UserProfile } from '../../api/demo' import type { UserProfile } from '../../api/demo'
import { toDemoUserProfile } from '../../utils/userProfiles'
import { load, save, K } from '../../utils/storage' import { load, save, K } from '../../utils/storage'
type Nav = NativeStackNavigationProp<RootStackParams> type Nav = NativeStackNavigationProp<RootStackParams>
@ -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() { export default function ContactsScreen() {
const navigation = useNavigation<Nav>() const navigation = useNavigation<Nav>()
const [contacts, setContacts] = useState<UserProfile[]>([]) const [contacts, setContacts] = useState<UserProfile[]>([])
const [friendRequests, setFriendRequests] = useState<FriendRequest[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const refreshFriendRequests = useCallback(async () => {
try {
const list = await ImSDK.listFriendRequests('incoming')
setFriendRequests(list)
} catch {
setFriendRequests([])
}
}, [])
const fetchContacts = useCallback(async () => { const fetchContacts = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@ -45,9 +72,9 @@ export default function ContactsScreen() {
await Promise.all( await Promise.all(
friendIds.map(async (id) => { friendIds.map(async (id) => {
try { try {
const results = await demoApi.searchUsers(id) const results = await ImSDK.searchUsers(id)
const match = results.find(u => u.userId === id) const match = results.find(u => u.userId === id)
if (match) profiles.push(match) if (match) profiles.push(toDemoUserProfile(match))
} catch {/* skip individual failures */} } 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( useFocusEffect(
useCallback(() => { useCallback(() => {
fetchContacts() fetchContacts()
}, [fetchContacts]), refreshFriendRequests()
}, [fetchContacts, refreshFriendRequests]),
) )
const openChat = (user: UserProfile) => { const openChat = (user: UserProfile) => {
@ -77,6 +123,9 @@ export default function ContactsScreen() {
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}></Text> <Text style={styles.title}></Text>
<View style={styles.headerActions}> <View style={styles.headerActions}>
<TouchableOpacity onPress={() => navigation.navigate('FriendRequests')} style={styles.headerBtn}>
<Text style={styles.headerBtnText}>{friendRequests.length > 0 ? `(${friendRequests.length})` : ''}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('GroupList')} style={styles.headerBtn}> <TouchableOpacity onPress={() => navigation.navigate('GroupList')} style={styles.headerBtn}>
<Text style={styles.headerBtnText}></Text> <Text style={styles.headerBtnText}></Text>
</TouchableOpacity> </TouchableOpacity>
@ -85,22 +134,21 @@ export default function ContactsScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
{friendRequests.length > 0 && (
<View style={styles.requestBanner}>
<Text style={styles.requestText}> {friendRequests.length} </Text>
<TouchableOpacity onPress={() => navigation.navigate('FriendRequests')}>
<Text style={styles.requestLink}></Text>
</TouchableOpacity>
</View>
)}
{loading && <ActivityIndicator style={styles.loadingIndicator} color="#07C160" />} {loading && <ActivityIndicator style={styles.loadingIndicator} color="#07C160" />}
<FlatList <FlatList
data={contacts} data={contacts}
keyExtractor={u => u.userId} keyExtractor={u => u.userId}
renderItem={({ item }) => <ContactRow user={item} onPress={() => openChat(item)} />} renderItem={({ item }) => <ContactRow user={item} onPress={() => openChat(item)} />}
ItemSeparatorComponent={() => <View style={styles.sep} />} ItemSeparatorComponent={ContactsSeparator}
ListEmptyComponent={ ListEmptyComponent={<ContactsEmpty loading={loading} onGoSearch={() => navigation.navigate('UserSearch')} />}
!loading ? (
<View style={styles.empty}>
<Text style={styles.emptyText}></Text>
<TouchableOpacity onPress={() => navigation.navigate('UserSearch')}>
<Text style={styles.emptyLink}></Text>
</TouchableOpacity>
</View>
) : null
}
/> />
</SafeAreaView> </SafeAreaView>
) )
@ -113,6 +161,9 @@ const styles = StyleSheet.create({
headerActions: { flexDirection: 'row', gap: 12 }, headerActions: { flexDirection: 'row', gap: 12 },
headerBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 }, headerBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 },
headerBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' }, headerBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
requestBanner: { marginHorizontal: 12, marginTop: 12, padding: 12, backgroundColor: '#fff7e6', borderRadius: 8, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
requestText: { color: '#8a5a00', fontSize: 13, flex: 1, marginRight: 8 },
requestLink: { color: '#07C160', fontSize: 13, fontWeight: '700' },
loadingIndicator: { marginVertical: 8 }, loadingIndicator: { marginVertical: 8 },
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' }, row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
avatar: { width: 46, height: 46, borderRadius: 8, marginRight: 12 }, avatar: { width: 46, height: 46, borderRadius: 8, marginRight: 12 },

查看文件

@ -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 React, { useState, useCallback, useRef } from 'react'
import { import {
View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity, View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity,
SafeAreaView, ActivityIndicator, Alert, Image, SafeAreaView, ActivityIndicator, Alert, Image, Modal,
} from 'react-native' } from 'react-native'
import { useNavigation, useRoute } from '@react-navigation/native' import { useNavigation, useRoute } from '@react-navigation/native'
import type { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack' import type { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'
import { ImSDK } from '@xuqm/rn-sdk' import { ImSDK } from '@xuqm/rn-sdk'
import { demoApi, type UserProfile } from '../../api/demo' import type { UserProfile } from '../../api/demo'
import { load, save, K } from '../../utils/storage'
import type { RootStackParams } from '../../navigation/types' import type { RootStackParams } from '../../navigation/types'
import { toDemoUserProfiles } from '../../utils/userProfiles'
type Nav = NativeStackNavigationProp<RootStackParams> type Nav = NativeStackNavigationProp<RootStackParams>
type Props = NativeStackScreenProps<RootStackParams, 'UserSearch'> type Props = NativeStackScreenProps<RootStackParams, 'UserSearch'>
@ -20,6 +20,8 @@ export default function UserSearchScreen() {
const [keyword, setKeyword] = useState('') const [keyword, setKeyword] = useState('')
const [results, setResults] = useState<UserProfile[]>([]) const [results, setResults] = useState<UserProfile[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [pendingUser, setPendingUser] = useState<UserProfile | null>(null)
const [friendRemark, setFriendRemark] = useState('')
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const search = useCallback((text: string) => { const search = useCallback((text: string) => {
@ -29,8 +31,8 @@ export default function UserSearchScreen() {
debounceRef.current = setTimeout(async () => { debounceRef.current = setTimeout(async () => {
setLoading(true) setLoading(true)
try { try {
const res = await demoApi.searchUsers(text.trim()) const res = await ImSDK.searchUsers(text.trim())
setResults(res) setResults(toDemoUserProfiles(res))
} catch { } catch {
setResults([]) setResults([])
} finally { } finally {
@ -48,16 +50,20 @@ export default function UserSearchScreen() {
Alert.alert('添加失败', e?.message ?? '请重试') Alert.alert('添加失败', e?.message ?? '请重试')
} }
} else { } else {
setPendingUser(user)
setFriendRemark('')
}
}
const sendFriendRequest = async () => {
if (!pendingUser) return
try { try {
await ImSDK.addFriend(user.userId) await ImSDK.sendFriendRequest(pendingUser.userId, friendRemark.trim() || undefined)
const current = (await load<UserProfile[]>(K.CONTACTS)) ?? [] Alert.alert('好友申请已发送')
if (!current.find(c => c.userId === user.userId)) { setPendingUser(null)
await save(K.CONTACTS, [...current, user]) setFriendRemark('')
} } catch (e: any) {
Alert.alert('已添加为好友') Alert.alert('添加失败', e?.message ?? '请重试')
} catch {
Alert.alert('添加失败')
}
} }
} }
@ -111,6 +117,29 @@ export default function UserSearchScreen() {
: null : null
} }
/> />
<Modal transparent visible={pendingUser !== null} animationType="fade" onRequestClose={() => setPendingUser(null)}>
<View style={styles.modalMask}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalSubTitle}>{pendingUser?.nickname ?? pendingUser?.userId ?? ''}</Text>
<TextInput
style={styles.modalInput}
placeholder="填写申请信息(可选)"
value={friendRemark}
onChangeText={setFriendRemark}
multiline
/>
<View style={styles.modalActions}>
<TouchableOpacity style={[styles.modalBtn, styles.modalCancel]} onPress={() => setPendingUser(null)}>
<Text style={styles.modalCancelText}></Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.modalBtn, styles.modalConfirm]} onPress={sendFriendRequest}>
<Text style={styles.modalConfirmText}></Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</SafeAreaView> </SafeAreaView>
) )
} }
@ -132,4 +161,15 @@ const styles = StyleSheet.create({
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 68 }, sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 68 },
empty: { alignItems: 'center', paddingTop: 60 }, empty: { alignItems: 'center', paddingTop: 60 },
emptyText: { color: '#bbb', fontSize: 15 }, emptyText: { color: '#bbb', fontSize: 15 },
modalMask: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'center', padding: 24 },
modalCard: { width: '100%', backgroundColor: '#fff', borderRadius: 12, padding: 16 },
modalTitle: { fontSize: 17, fontWeight: '700', color: '#111' },
modalSubTitle: { marginTop: 6, color: '#666', fontSize: 13 },
modalInput: { minHeight: 90, borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, marginTop: 12, padding: 10, textAlignVertical: 'top' },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 10, marginTop: 14 },
modalBtn: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 8 },
modalCancel: { backgroundColor: '#f2f2f2' },
modalConfirm: { backgroundColor: '#07C160' },
modalCancelText: { color: '#333', fontWeight: '600' },
modalConfirmText: { color: '#fff', fontWeight: '600' },
}) })

查看文件

@ -8,7 +8,8 @@ import { ImSDK, type ConversationData } from '@xuqm/rn-sdk'
import type { RootStackParams } from '../../navigation/types' import type { RootStackParams } from '../../navigation/types'
import DisconnectBanner from '../../components/DisconnectBanner' import DisconnectBanner from '../../components/DisconnectBanner'
import ConversationItem from '../../components/ConversationItem' import ConversationItem from '../../components/ConversationItem'
import { demoApi, type UserProfile } from '../../api/demo' import type { UserProfile } from '../../api/demo'
import { toDemoUserProfile } from '../../utils/userProfiles'
type Nav = NativeStackNavigationProp<RootStackParams> type Nav = NativeStackNavigationProp<RootStackParams>
@ -27,8 +28,9 @@ export default function ConversationListScreen() {
let profile = profileCache.current[conv.targetId] let profile = profileCache.current[conv.targetId]
if (!profile) { if (!profile) {
try { try {
const results = await demoApi.searchUsers(conv.targetId) const results = await ImSDK.searchUsers(conv.targetId)
profile = results.find(u => u.userId === conv.targetId) ?? { appId, userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' } 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 profileCache.current[conv.targetId] = profile
} catch { } catch {
profile = { appId, userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' } profile = { appId, userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' }

查看文件

@ -1,11 +1,12 @@
import React, { useState, useCallback, useRef } from 'react' import React, { useState, useCallback, useRef } from 'react'
import { import {
View, Text, TextInput, StyleSheet, TouchableOpacity, SafeAreaView, View, Text, TextInput, StyleSheet, TouchableOpacity, SafeAreaView,
FlatList, ActivityIndicator, Alert, ScrollView, ActivityIndicator, Alert, ScrollView,
} from 'react-native' } from 'react-native'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { ImSDK } from '@xuqm/rn-sdk' import { ImSDK } from '@xuqm/rn-sdk'
import { demoApi, type UserProfile } from '../../api/demo' import type { UserProfile } from '../../api/demo'
import { toDemoUserProfiles } from '../../utils/userProfiles'
export default function CreateGroupScreen() { export default function CreateGroupScreen() {
const navigation = useNavigation() const navigation = useNavigation()
@ -24,8 +25,8 @@ export default function CreateGroupScreen() {
debounceRef.current = setTimeout(async () => { debounceRef.current = setTimeout(async () => {
setSearching(true) setSearching(true)
try { try {
const res = await demoApi.searchUsers(text.trim()) const res = await ImSDK.searchUsers(text.trim())
setSearchResults(res) setSearchResults(toDemoUserProfiles(res))
} catch { } catch {
setSearchResults([]) setSearchResults([])
} finally { } 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 { import {
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView, View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
ActivityIndicator, ActivityIndicator,
} from 'react-native' } from 'react-native'
import { useNavigation, useFocusEffect } from '@react-navigation/native' import { useNavigation, useFocusEffect } from '@react-navigation/native'
@ -8,7 +8,6 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { ImSDK } from '@xuqm/rn-sdk' import { ImSDK } from '@xuqm/rn-sdk'
import type { ImGroup } from '@xuqm/rn-sdk' import type { ImGroup } from '@xuqm/rn-sdk'
import type { RootStackParams } from '../../navigation/types' import type { RootStackParams } from '../../navigation/types'
import { useAuth } from '../../context/AuthContext'
type Nav = NativeStackNavigationProp<RootStackParams> type Nav = NativeStackNavigationProp<RootStackParams>
@ -35,20 +34,34 @@ export default function GroupListScreen() {
const navigation = useNavigation<Nav>() const navigation = useNavigation<Nav>()
const [groups, setGroups] = useState<ImGroup[]>([]) const [groups, setGroups] = useState<ImGroup[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fetchGroups = async () => { const fetchGroups = useCallback(async (text = keyword) => {
setLoading(true) setLoading(true)
try { try {
const list = await ImSDK.listGroups() const list = text.trim()
? await ImSDK.searchGroups(text.trim())
: await ImSDK.listGroups()
setGroups(list) setGroups(list)
} catch { } catch {
/* silently fail */ /* silently fail */
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [keyword])
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 ( return (
<SafeAreaView style={styles.root}> <SafeAreaView style={styles.root}>
@ -58,8 +71,17 @@ export default function GroupListScreen() {
<Text style={styles.createBtnText}>+ </Text> <Text style={styles.createBtnText}>+ </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.searchBar}>
<TextInput
style={styles.searchInput}
placeholder="搜索群名称或群号"
value={keyword}
onChangeText={handleSearch}
clearButtonMode="while-editing"
/>
</View>
{loading {loading
? <ActivityIndicator style={{ flex: 1 }} color="#07C160" /> ? <ActivityIndicator style={styles.loading} color="#07C160" />
: ( : (
<FlatList <FlatList
data={groups} data={groups}
@ -85,6 +107,9 @@ const styles = StyleSheet.create({
title: { fontSize: 18, fontWeight: '700', color: '#111' }, title: { fontSize: 18, fontWeight: '700', color: '#111' },
createBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 }, createBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 },
createBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' }, createBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
searchBar: { margin: 12, backgroundColor: '#fff', borderRadius: 8, paddingHorizontal: 12, borderWidth: 1, borderColor: '#e0e0e0' },
searchInput: { fontSize: 15, paddingVertical: 10 },
loading: { flex: 1 },
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' }, row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
avatar: { width: 46, height: 46, borderRadius: 8, backgroundColor: '#5856d6', alignItems: 'center', justifyContent: 'center', marginRight: 12 }, avatar: { width: 46, height: 46, borderRadius: 8, backgroundColor: '#5856d6', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' }, avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' },

查看文件

@ -1,19 +1,19 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useState } from 'react'
import { import {
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView, ActivityIndicator, Alert, Image, View, Text, FlatList, StyleSheet, SafeAreaView, ActivityIndicator, Image,
} from 'react-native' } 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 type { NativeStackScreenProps } from '@react-navigation/native-stack'
import { ImSDK } from '@xuqm/rn-sdk' 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 type { RootStackParams } from '../../navigation/types'
import { toDemoUserProfile } from '../../utils/userProfiles'
type Props = NativeStackScreenProps<RootStackParams, 'GroupMembers'> type Props = NativeStackScreenProps<RootStackParams, 'GroupMembers'>
export default function GroupMembersScreen({ route }: Props) { export default function GroupMembersScreen({ route }: Props) {
const { groupId, groupName } = route.params const { groupId } = route.params
const appId = 'ak_demo_chat' const appId = 'ak_demo_chat'
const navigation = useNavigation()
const [members, setMembers] = useState<UserProfile[]>([]) const [members, setMembers] = useState<UserProfile[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -28,8 +28,9 @@ export default function GroupMembersScreen({ route }: Props) {
catch { ids = group.memberIds ? group.memberIds.split(',').filter(Boolean) : [] } catch { ids = group.memberIds ? group.memberIds.split(',').filter(Boolean) : [] }
const profiles = await Promise.all( const profiles = await Promise.all(
ids.map(async id => { ids.map(async id => {
const res = await demoApi.searchUsers(id) const res = await ImSDK.searchUsers(id)
return res.find(u => u.userId === id) ?? { appId, userId: id, nickname: id, avatar: '', gender: 'UNKNOWN' as const, status: '' } 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) 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 { View, Text, StyleSheet, TouchableOpacity, SafeAreaView, Alert, ActivityIndicator } from 'react-native'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import type { NativeStackScreenProps } from '@react-navigation/native-stack' import type { NativeStackScreenProps } from '@react-navigation/native-stack'
import { ImSDK } from '@xuqm/rn-sdk' import { ImSDK } from '@xuqm/rn-sdk'
import type { RootStackParams } from '../../navigation/types' import type { RootStackParams } from '../../navigation/types'
import { useAuth } from '../../context/AuthContext'
type Props = NativeStackScreenProps<RootStackParams, 'GroupSettings'> type Props = NativeStackScreenProps<RootStackParams, 'GroupSettings'>
export default function GroupSettingsScreen({ route }: Props) { export default function GroupSettingsScreen({ route }: Props) {
const { groupId, groupName, isAdmin } = route.params const { groupId, groupName } = route.params
const nav = useNavigation() const nav = useNavigation()
const { userId } = useAuth()
const [leaving, setLeaving] = useState(false) const [leaving, setLeaving] = useState(false)
const [isAdmin, setIsAdmin] = useState(Boolean(route.params.isAdmin))
useEffect(() => {
let mounted = true
ImSDK.getGroupInfo(groupId)
.then(group => {
if (!mounted) return
const admins = new Set((group.adminIds || '').split(',').map(item => item.trim()).filter(Boolean))
const creator = group.creatorId?.trim()
setIsAdmin(Boolean(userId && (admins.has(userId) || creator === userId)))
})
.catch(() => {})
return () => { mounted = false }
}, [groupId, userId])
function Row({ label, value, onPress }: { label: string; value?: string; onPress?: () => void }) { function Row({ label, value, onPress }: { label: string; value?: string; onPress?: () => void }) {
return ( return (
@ -25,6 +41,7 @@ export default function GroupSettingsScreen({ route }: Props) {
} }
const openMembers = () => (nav as any).navigate('GroupMembers', { groupId, groupName }) const openMembers = () => (nav as any).navigate('GroupMembers', { groupId, groupName })
const openJoinRequests = () => (nav as any).navigate('GroupJoinRequests', { groupId, groupName })
const handleLeave = () => { const handleLeave = () => {
Alert.alert('退出群聊', '退出后将不再收到该群消息,确定吗?', [ Alert.alert('退出群聊', '退出后将不再收到该群消息,确定吗?', [
@ -58,6 +75,8 @@ export default function GroupSettingsScreen({ route }: Props) {
<> <>
<View style={styles.sep} /> <View style={styles.sep} />
<Row label="添加成员" onPress={() => (nav as any).navigate('UserSearch', { addToGroupId: groupId })} /> <Row label="添加成员" onPress={() => (nav as any).navigate('UserSearch', { addToGroupId: groupId })} />
<View style={styles.sep} />
<Row label="入群申请" onPress={openJoinRequests} />
</> </>
)} )}
</View> </View>

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", "extends": "@react-native/typescript-config",
"compilerOptions": { "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"], "include": ["**/*.ts", "**/*.tsx"],
"exclude": ["**/node_modules", "**/Pods"] "exclude": ["**/node_modules", "**/Pods"]