feat(app): update check flow, friend-based contacts, global disconnect banner
- AuthContext: post-login checkAppUpdate + silent RN update - ContactsScreen: ImSDK.listFriends + demoApi profiles, useFocusEffect - UserSearchScreen: addFriend via ImSDK - DisconnectBanner moved to AppStack level (global, not per-screen) - Remove DisconnectBanner from SingleChatScreen and GroupChatScreen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
e5adc00b44
当前提交
536759c14a
@ -1,7 +1,9 @@
|
|||||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||||
import { XuqmSDK, ImSDK } from '@xuqm/rn-sdk'
|
import { Alert, Linking } from 'react-native'
|
||||||
|
import { XuqmSDK, ImSDK, UpdateSDK } from '@xuqm/rn-sdk'
|
||||||
import { demoApi, type UserProfile } from '../api/demo'
|
import { demoApi, type UserProfile } from '../api/demo'
|
||||||
import { save, load, clearSession, K } from '../utils/storage'
|
import { save, load, clearSession, K } from '../utils/storage'
|
||||||
|
import pluginMeta from '../../plugin.json'
|
||||||
|
|
||||||
const APP_ID = 'ak_demo_chat'
|
const APP_ID = 'ak_demo_chat'
|
||||||
const SERVER_URL = 'https://sentry.xuqinmin.com'
|
const SERVER_URL = 'https://sentry.xuqinmin.com'
|
||||||
@ -29,6 +31,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await XuqmSDK.initialize({ appId: APP_ID, serverUrl: SERVER_URL })
|
await XuqmSDK.initialize({ appId: APP_ID, serverUrl: SERVER_URL })
|
||||||
await ImSDK.loginWithToken(userId, imToken, 'xuqm_im')
|
await ImSDK.loginWithToken(userId, imToken, 'xuqm_im')
|
||||||
setState({ ready: true, userId, profile })
|
setState({ ready: true, userId, profile })
|
||||||
|
|
||||||
|
UpdateSDK.checkAppUpdate()
|
||||||
|
.then(update => {
|
||||||
|
if (update) {
|
||||||
|
Alert.alert(
|
||||||
|
'发现新版本',
|
||||||
|
`${update.versionName} 是否立即更新?`,
|
||||||
|
[
|
||||||
|
{ text: '稍后', style: 'cancel' },
|
||||||
|
{ text: '立即更新', onPress: () => Linking.openURL(update.downloadUrl) },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
UpdateSDK.checkRnUpdate(pluginMeta.moduleId).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import GroupMembersScreen from '../screens/group/GroupMembersScreen'
|
|||||||
import GroupSettingsScreen from '../screens/group/GroupSettingsScreen'
|
import GroupSettingsScreen from '../screens/group/GroupSettingsScreen'
|
||||||
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'
|
||||||
|
|
||||||
const AuthStack = createNativeStackNavigator<AuthStackParams>()
|
const AuthStack = createNativeStackNavigator<AuthStackParams>()
|
||||||
const Tab = createBottomTabNavigator<MainTabParams>()
|
const Tab = createBottomTabNavigator<MainTabParams>()
|
||||||
@ -55,6 +56,7 @@ function MainTabs() {
|
|||||||
function AppStack() {
|
function AppStack() {
|
||||||
return (
|
return (
|
||||||
<IMProvider>
|
<IMProvider>
|
||||||
|
<DisconnectBanner />
|
||||||
<Root.Navigator screenOptions={{ headerStyle: { backgroundColor: '#fff' }, headerTintColor: '#333' }}>
|
<Root.Navigator screenOptions={{ headerStyle: { backgroundColor: '#fff' }, headerTintColor: '#333' }}>
|
||||||
<Root.Screen name="Main" component={MainTabs} options={{ headerShown: false }} />
|
<Root.Screen name="Main" component={MainTabs} options={{ headerShown: false }} />
|
||||||
<Root.Screen name="SingleChat" component={SingleChatScreen} options={({ route }) => ({ title: route.params.targetName })} />
|
<Root.Screen name="SingleChat" component={SingleChatScreen} options={({ route }) => ({ title: route.params.targetName })} />
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import { useAuth } from '../../context/AuthContext'
|
|||||||
import type { RootStackParams } from '../../navigation/types'
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
import { MessageBubble } from '../../components/MessageBubble'
|
import { MessageBubble } from '../../components/MessageBubble'
|
||||||
import ChatInput from '../../components/ChatInput'
|
import ChatInput from '../../components/ChatInput'
|
||||||
import DisconnectBanner from '../../components/DisconnectBanner'
|
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParams, 'GroupChat'>
|
type Props = NativeStackScreenProps<RootStackParams, 'GroupChat'>
|
||||||
|
|
||||||
@ -85,7 +84,6 @@ export default function GroupChatScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.root}>
|
<SafeAreaView style={styles.root}>
|
||||||
<DisconnectBanner />
|
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.flex}
|
style={styles.flex}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import { useAuth } from '../../context/AuthContext'
|
|||||||
import type { RootStackParams } from '../../navigation/types'
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
import { MessageBubble } from '../../components/MessageBubble'
|
import { MessageBubble } from '../../components/MessageBubble'
|
||||||
import ChatInput from '../../components/ChatInput'
|
import ChatInput from '../../components/ChatInput'
|
||||||
import DisconnectBanner from '../../components/DisconnectBanner'
|
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParams, 'SingleChat'>
|
type Props = NativeStackScreenProps<RootStackParams, 'SingleChat'>
|
||||||
|
|
||||||
@ -86,7 +85,6 @@ export default function SingleChatScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.root}>
|
<SafeAreaView style={styles.root}>
|
||||||
<DisconnectBanner />
|
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.flex}
|
style={styles.flex}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||||
TextInput, Alert,
|
ActivityIndicator,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { useNavigation } 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 { demoApi, type UserProfile } from '../../api/demo'
|
import { demoApi, type UserProfile } from '../../api/demo'
|
||||||
import { load, save, K } from '../../utils/storage'
|
import { load, save, K } from '../../utils/storage'
|
||||||
|
|
||||||
@ -28,11 +29,39 @@ function ContactRow({ user, onPress }: { user: UserProfile; onPress(): void }) {
|
|||||||
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 [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchContacts = useCallback(async () => {
|
||||||
load<UserProfile[]>(K.CONTACTS).then(c => { if (c) setContacts(c) })
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const friendIds = await ImSDK.listFriends()
|
||||||
|
const profiles: UserProfile[] = []
|
||||||
|
await Promise.all(
|
||||||
|
friendIds.map(async (id) => {
|
||||||
|
try {
|
||||||
|
const results = await demoApi.searchUsers(id)
|
||||||
|
const match = results.find(u => u.userId === id)
|
||||||
|
if (match) profiles.push(match)
|
||||||
|
} catch {/* skip individual failures */}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setContacts(profiles)
|
||||||
|
await save(K.CONTACTS, profiles)
|
||||||
|
} catch {
|
||||||
|
// network failed — load from local cache
|
||||||
|
const cached = await load<UserProfile[]>(K.CONTACTS)
|
||||||
|
if (cached) setContacts(cached)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchContacts()
|
||||||
|
}, [fetchContacts]),
|
||||||
|
)
|
||||||
|
|
||||||
const openChat = (user: UserProfile) => {
|
const openChat = (user: UserProfile) => {
|
||||||
navigation.navigate('SingleChat', { targetId: user.userId, targetName: user.nickname, targetAvatar: user.avatar })
|
navigation.navigate('SingleChat', { targetId: user.userId, targetName: user.nickname, targetAvatar: user.avatar })
|
||||||
}
|
}
|
||||||
@ -50,18 +79,21 @@ export default function ContactsScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
{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={() => <View style={styles.sep} />}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={styles.empty}>
|
!loading ? (
|
||||||
<Text style={styles.emptyText}>还没有联系人</Text>
|
<View style={styles.empty}>
|
||||||
<TouchableOpacity onPress={() => navigation.navigate('UserSearch')}>
|
<Text style={styles.emptyText}>还没有联系人</Text>
|
||||||
<Text style={styles.emptyLink}>搜索添加</Text>
|
<TouchableOpacity onPress={() => navigation.navigate('UserSearch')}>
|
||||||
</TouchableOpacity>
|
<Text style={styles.emptyLink}>搜索添加</Text>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
@ -75,6 +107,7 @@ 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' },
|
||||||
|
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, backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
|
avatar: { width: 46, height: 46, borderRadius: 8, backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
|
||||||
avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' },
|
avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' },
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { useNavigation } from '@react-navigation/native'
|
import { useNavigation } from '@react-navigation/native'
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||||
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
import { demoApi, type UserProfile } from '../../api/demo'
|
import { demoApi, type UserProfile } from '../../api/demo'
|
||||||
import { load, save, K } from '../../utils/storage'
|
import { load, save, K } from '../../utils/storage'
|
||||||
import type { RootStackParams } from '../../navigation/types'
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
@ -37,13 +38,14 @@ export default function UserSearchScreen() {
|
|||||||
|
|
||||||
const addContact = async (user: UserProfile) => {
|
const addContact = async (user: UserProfile) => {
|
||||||
try {
|
try {
|
||||||
|
await ImSDK.addFriend(user.userId)
|
||||||
const current = (await load<UserProfile[]>(K.CONTACTS)) ?? []
|
const current = (await load<UserProfile[]>(K.CONTACTS)) ?? []
|
||||||
if (!current.find(c => c.userId === user.userId)) {
|
if (!current.find(c => c.userId === user.userId)) {
|
||||||
await save(K.CONTACTS, [...current, user])
|
await save(K.CONTACTS, [...current, user])
|
||||||
}
|
}
|
||||||
Alert.alert('已添加', `${user.nickname} 已添加到通讯录`)
|
Alert.alert('已添加为好友')
|
||||||
} catch {
|
} catch {
|
||||||
Alert.alert('失败', '添加失败,请重试')
|
Alert.alert('添加失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户