feat(chat): 添加聊天界面和文件更新SDK功能
- 实现完整的聊天界面UI组件,支持文本、图片、视频、音频、文件等多种消息类型 - 集成IM消息收发功能,实现消息气泡显示和用户头像占位符 - 添加媒体文件选择和拍摄功能,支持相册图片、视频及相机拍照录像 - 实现语音录制和播放功能,包含按住说话交互和权限处理 - 添加群组提及功能,支持@用户和提及候选列表显示 - 实现消息回复和引用功能,支持消息长按回复操作 - 添加本地消息搜索功能,支持搜索当前会话的历史消息 - 实现文件上传下载功能,集成FileSDK进行文件传输管理 - 添加应用更新检查功能,集成UpdateSDK支持版本更新 - 实现消息状态显示,包括发送、送达、已读等状态标识 - 添加群组已读人数统计,显示消息在群聊中的阅读情况 - 实现草稿保存和恢复功能,支持断点续聊体验 - 添加连接状态横幅,实时显示IM服务连接状态 - 实现滚动加载更多历史消息,优化大量消息的性能表现 - 添加多媒体文件下载保存功能,支持保存到应用专属目录
这个提交包含在:
父节点
f7d68681b5
当前提交
069f3454fe
@ -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 {
|
||||||
try {
|
setPendingUser(user)
|
||||||
await ImSDK.addFriend(user.userId)
|
setFriendRemark('')
|
||||||
const current = (await load<UserProfile[]>(K.CONTACTS)) ?? []
|
}
|
||||||
if (!current.find(c => c.userId === user.userId)) {
|
}
|
||||||
await save(K.CONTACTS, [...current, user])
|
|
||||||
}
|
const sendFriendRequest = async () => {
|
||||||
Alert.alert('已添加为好友')
|
if (!pendingUser) return
|
||||||
} catch {
|
try {
|
||||||
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
|
: 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('退出群聊', '退出后将不再收到该群消息,确定吗?', [
|
||||||
@ -53,14 +70,16 @@ export default function GroupSettingsScreen({ route }: Props) {
|
|||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Row label="群名称" value={groupName} />
|
<Row label="群名称" value={groupName} />
|
||||||
<View style={styles.sep} />
|
<View style={styles.sep} />
|
||||||
<Row label="群成员" onPress={openMembers} />
|
<Row label="群成员" onPress={openMembers} />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<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>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.leaveBtn, leaving && styles.leaveBtnDisabled]}
|
style={[styles.leaveBtn, leaving && styles.leaveBtnDisabled]}
|
||||||
|
|||||||
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",
|
"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"]
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户