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>
这个提交包含在:
徐勤民 2026-04-25 17:27:22 +08:00
父节点 e5adc00b44
当前提交 536759c14a
共有 6 个文件被更改,包括 70 次插入18 次删除

查看文件

@ -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={
!loading ? (
<View style={styles.empty}> <View style={styles.empty}>
<Text style={styles.emptyText}></Text> <Text style={styles.emptyText}></Text>
<TouchableOpacity onPress={() => navigation.navigate('UserSearch')}> <TouchableOpacity onPress={() => navigation.navigate('UserSearch')}>
<Text style={styles.emptyLink}></Text> <Text style={styles.emptyLink}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </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('添加失败')
} }
} }