refactor(App, navigation): migrate to Expo Router and clean up navigation structure
- Updated App entry point to utilize Expo Router, simplifying the navigation setup. - Removed legacy navigation components and services to streamline the codebase. - Adjusted package.json to reflect the new entry point and updated dependencies for compatibility. - Enhanced overall application structure by consolidating navigation logic and improving maintainability.
This commit is contained in:
@@ -11,19 +11,15 @@ import {
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
|
||||
type ForgotPasswordNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||
|
||||
export const ForgotPasswordScreen: React.FC = () => {
|
||||
const navigation = useNavigation<ForgotPasswordNavigationProp>();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
@@ -95,7 +91,7 @@ export const ForgotPasswordScreen: React.FC = () => {
|
||||
});
|
||||
if (ok) {
|
||||
showPrompt({ title: '重置成功', message: '密码已重置,请重新登录', type: 'success' });
|
||||
navigation.goBack();
|
||||
router.back();
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '重置失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' });
|
||||
@@ -192,7 +188,7 @@ export const ForgotPasswordScreen: React.FC = () => {
|
||||
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.submitButtonText}>重置密码</Text>}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<Text style={styles.backButtonText}>返回登录</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -22,24 +22,22 @@ import {
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
|
||||
type LoginNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||
|
||||
// 分栏布局断点
|
||||
const SPLIT_BREAKPOINT = 768;
|
||||
|
||||
export const LoginScreen: React.FC = () => {
|
||||
const navigation = useNavigation<LoginNavigationProp>();
|
||||
const router = useRouter();
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const storeError = useAuthStore((state) => state.error);
|
||||
const setStoreError = useAuthStore((state) => state.setError);
|
||||
|
||||
@@ -131,6 +129,12 @@ export const LoginScreen: React.FC = () => {
|
||||
}
|
||||
}, [storeError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace(hrefs.hrefHome());
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
const clearError = () => {
|
||||
setErrorMsg(null);
|
||||
setStoreError(null);
|
||||
@@ -158,7 +162,7 @@ export const LoginScreen: React.FC = () => {
|
||||
|
||||
// 跳转到注册页
|
||||
const handleGoToRegister = () => {
|
||||
navigation.navigate('Register' as any);
|
||||
router.push(hrefs.hrefAuthRegister());
|
||||
};
|
||||
|
||||
// 渲染左侧面板(Logo和品牌信息)- 优化大屏布局
|
||||
@@ -297,7 +301,10 @@ export const LoginScreen: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* 忘记密码 */}
|
||||
<TouchableOpacity style={styles.forgotPassword} onPress={() => navigation.navigate('ForgotPassword' as any)}>
|
||||
<TouchableOpacity
|
||||
style={styles.forgotPassword}
|
||||
onPress={() => router.push(hrefs.hrefAuthForgot())}
|
||||
>
|
||||
<Text style={styles.forgotPasswordText}>忘记密码?</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
||||
@@ -10,28 +10,24 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { qrcodeApi } from '../../services/authService';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
|
||||
type QRCodeConfirmRouteProp = RouteProp<RootStackParamList, 'QRCodeConfirm'>;
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
import { AppBackButton } from '../../components/common';
|
||||
|
||||
export const QRCodeConfirmScreen: React.FC = () => {
|
||||
const route = useRoute<QRCodeConfirmRouteProp>();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const { sessionId } = route.params;
|
||||
const router = useRouter();
|
||||
const { sessionId: sessionIdParam } = useLocalSearchParams<{ sessionId?: string }>();
|
||||
const sessionId = sessionIdParam || '';
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [userInfo, setUserInfo] = useState<{ id: string; nickname: string; avatar: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 扫码
|
||||
handleScan();
|
||||
}, []);
|
||||
if (!sessionId) return;
|
||||
void handleScan();
|
||||
}, [sessionId]);
|
||||
|
||||
const handleScan = async () => {
|
||||
try {
|
||||
@@ -42,7 +38,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
|
||||
const errorMsg = err.response?.data?.message || '扫码失败';
|
||||
setError(errorMsg);
|
||||
Alert.alert('扫码失败', errorMsg, [
|
||||
{ text: '确定', onPress: () => navigation.goBack() }
|
||||
{ text: '确定', onPress: () => router.back() }
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -54,7 +50,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
|
||||
setLoading(true);
|
||||
await qrcodeApi.confirm(sessionId);
|
||||
Alert.alert('登录成功', '网页端已登录', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() }
|
||||
{ text: '确定', onPress: () => router.back() }
|
||||
]);
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.message || '确认登录失败';
|
||||
@@ -70,7 +66,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
|
||||
} catch (err) {
|
||||
// 忽略取消错误
|
||||
}
|
||||
navigation.goBack();
|
||||
router.back();
|
||||
};
|
||||
|
||||
if (loading && !userInfo) {
|
||||
@@ -90,7 +86,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialCommunityIcons name="alert-circle" size={64} color={colors.error.main} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<Text style={styles.backButtonText}>返回</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -101,9 +97,7 @@ export const QRCodeConfirmScreen: React.FC = () => {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={handleCancel} style={styles.closeButton}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
<AppBackButton onPress={handleCancel} icon="close" style={styles.closeButton} iconColor="#666" />
|
||||
<Text style={styles.headerTitle}>确认登录</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
@@ -22,25 +22,23 @@ import {
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
|
||||
type RegisterNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||
|
||||
// 分栏布局断点
|
||||
const SPLIT_BREAKPOINT = 768;
|
||||
|
||||
export const RegisterScreen: React.FC = () => {
|
||||
const navigation = useNavigation<RegisterNavigationProp>();
|
||||
const router = useRouter();
|
||||
const register = useAuthStore((state) => state.register);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
|
||||
// 响应式布局
|
||||
const { isLandscape, width } = useResponsive();
|
||||
@@ -48,6 +46,12 @@ export const RegisterScreen: React.FC = () => {
|
||||
// 是否使用分栏布局
|
||||
const isSplitLayout = width >= SPLIT_BREAKPOINT;
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace(hrefs.hrefHome());
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -238,7 +242,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
|
||||
// 跳转到登录页
|
||||
const handleGoToLogin = () => {
|
||||
navigation.navigate('Login' as any);
|
||||
router.push(hrefs.hrefAuthLogin());
|
||||
};
|
||||
|
||||
// 渲染左侧面板(Logo和品牌信息)- 优化大屏布局
|
||||
|
||||
@@ -21,17 +21,17 @@ import {
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
import { Text, Button, ResponsiveContainer } from '../../components/common';
|
||||
import { Text, Button, ResponsiveContainer, AppBackButton } from '../../components/common';
|
||||
import { postService, showPrompt, voteService } from '../../services';
|
||||
import { ApiError } from '../../services/api';
|
||||
import { uploadService } from '../../services/uploadService';
|
||||
import VoteEditor from '../../components/business/VoteEditor';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
|
||||
// Props 接口
|
||||
interface CreatePostScreenProps {
|
||||
@@ -80,10 +80,11 @@ const getPublishErrorMessage = (error: unknown): string => {
|
||||
|
||||
export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
const { onClose } = props;
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation();
|
||||
const route = useRoute<RouteProp<RootStackParamList, 'CreatePost'>>();
|
||||
const isEditMode = route.params?.mode === 'edit' && !!route.params?.postId;
|
||||
const editPostID = route.params?.postId || '';
|
||||
const { mode, postId: postIdParam } = useLocalSearchParams<{ mode?: string; postId?: string }>();
|
||||
const isEditMode = mode === 'edit' && !!postIdParam;
|
||||
const editPostID = postIdParam || '';
|
||||
|
||||
// 响应式布局
|
||||
const { isWideScreen, width } = useResponsive();
|
||||
@@ -135,12 +136,11 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerShown: true,
|
||||
title: isEditMode ? '编辑帖子' : '发布帖子',
|
||||
// 当作为 Modal 使用时,显示关闭按钮
|
||||
headerLeft: onClose ? () => (
|
||||
<TouchableOpacity onPress={onClose} style={{ padding: 8 }}>
|
||||
<MaterialCommunityIcons name="close" size={24} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
<AppBackButton onPress={onClose} icon="close" />
|
||||
) : undefined,
|
||||
});
|
||||
}, [navigation, isEditMode, onClose]);
|
||||
@@ -156,7 +156,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
const existingPost = await postService.getPost(editPostID);
|
||||
if (!existingPost) {
|
||||
Alert.alert('提示', '帖子不存在或已被删除');
|
||||
navigation.goBack();
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
setTitle(existingPost.title || '');
|
||||
@@ -165,14 +165,14 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
} catch (error) {
|
||||
console.error('加载待编辑帖子失败:', error);
|
||||
Alert.alert('错误', '加载帖子失败,请稍后重试');
|
||||
navigation.goBack();
|
||||
router.back();
|
||||
} finally {
|
||||
setLoadingPost(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPostForEdit();
|
||||
}, [isEditMode, editPostID, navigation]);
|
||||
}, [isEditMode, editPostID, router]);
|
||||
|
||||
// 选择图片
|
||||
const handlePickImage = async () => {
|
||||
@@ -381,10 +381,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
message: '投票帖已提交,内容审核中,稍后展示',
|
||||
duration: 2600,
|
||||
});
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: 'Main' }],
|
||||
});
|
||||
router.replace(hrefs.hrefHome());
|
||||
} else {
|
||||
if (isEditMode && editPostID) {
|
||||
const updated = await postService.updatePost(editPostID, {
|
||||
@@ -401,10 +398,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
message: '帖子内容已更新',
|
||||
duration: 2200,
|
||||
});
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: 'Main' }],
|
||||
});
|
||||
router.replace(hrefs.hrefHome());
|
||||
} else {
|
||||
// 创建普通帖子
|
||||
await postService.createPost({
|
||||
@@ -418,10 +412,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
message: '帖子已提交,内容审核中,稍后展示',
|
||||
duration: 2600,
|
||||
});
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [{ name: 'Main' }],
|
||||
});
|
||||
router.replace(hrefs.hrefHome());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,8 +19,7 @@ import {
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
@@ -31,15 +30,12 @@ import { postService } from '../../services';
|
||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||
import type { PostCardAction } from '../../components/business/PostCard';
|
||||
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
|
||||
import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
|
||||
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||
import { useDifferentialPosts } from '../../hooks/useDifferentialPosts';
|
||||
import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
|
||||
import { SearchScreen } from './SearchScreen';
|
||||
import { CreatePostScreen } from '../create/CreatePostScreen';
|
||||
import { navigationService } from '../../infrastructure/navigation/navigationService';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Home'> & NativeStackNavigationProp<RootStackParamList>;
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
|
||||
const TABS = ['最新', '关注', '热门'];
|
||||
const TAB_ICONS = ['clock-outline', 'account-heart-outline', 'fire'];
|
||||
@@ -54,7 +50,7 @@ type ViewMode = 'list' | 'grid';
|
||||
type PostType = 'follow' | 'hot' | 'latest';
|
||||
|
||||
export const HomeScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { posts: storePosts } = useUserStore();
|
||||
const currentUser = useCurrentUser();
|
||||
@@ -302,12 +298,12 @@ export const HomeScreen: React.FC = () => {
|
||||
|
||||
// 跳转到帖子详情
|
||||
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
|
||||
navigationService.navigate('PostDetail', { postId, scrollToComments });
|
||||
router.push(hrefs.hrefPostDetail(postId, scrollToComments));
|
||||
};
|
||||
|
||||
// 跳转到用户主页
|
||||
const handleUserPress = (userId: string) => {
|
||||
navigationService.navigate('UserProfile', { userId });
|
||||
router.push(hrefs.hrefUserProfile(userId));
|
||||
};
|
||||
|
||||
// 点赞帖子
|
||||
@@ -563,10 +559,12 @@ export const HomeScreen: React.FC = () => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
|
||||
{/* 加载更多指示器 */}
|
||||
{isLoading && displayPosts.length > 0 && (
|
||||
<View style={styles.loadingMore}>
|
||||
<View style={styles.waterfallColumnsRow}>
|
||||
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
|
||||
</View>
|
||||
{/* 独立底部加载区:避免参与横向列布局导致列宽抖动 */}
|
||||
{isLoadingMore && displayPosts.length > 0 && (
|
||||
<View style={styles.loadingMoreFooter}>
|
||||
<Loading size="sm" />
|
||||
</View>
|
||||
)}
|
||||
@@ -808,8 +806,12 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
waterfallContainer: {
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
},
|
||||
waterfallColumnsRow: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
waterfallColumn: {
|
||||
@@ -844,7 +846,7 @@ const styles = StyleSheet.create({
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
},
|
||||
loadingMore: {
|
||||
loadingMoreFooter: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -23,8 +23,7 @@ import {
|
||||
Clipboard,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
@@ -35,19 +34,20 @@ import { postService, commentService, uploadService, authService, showPrompt, vo
|
||||
import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
|
||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
import { CommentItem, VoteCard } from '../../components/business';
|
||||
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
|
||||
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'PostDetail'>;
|
||||
type PostDetailRouteProp = RouteProp<RootStackParamList, 'PostDetail'>;
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
|
||||
export const PostDetailScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<PostDetailRouteProp>();
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const postId = route.params?.postId || '';
|
||||
const shouldScrollToComments = route.params?.scrollToComments || false;
|
||||
const { postId: postIdParam, scrollToComments: scrollParam } = useLocalSearchParams<{
|
||||
postId?: string;
|
||||
scrollToComments?: string;
|
||||
}>();
|
||||
const postId = postIdParam || '';
|
||||
const shouldScrollToComments = scrollParam === '1' || scrollParam === 'true';
|
||||
|
||||
// 使用响应式 hook
|
||||
const {
|
||||
@@ -282,23 +282,18 @@ export const PostDetailScreen: React.FC = () => {
|
||||
|
||||
const author = post.author;
|
||||
const handleBackPress = () => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack();
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
navigation.navigate('Main', {
|
||||
screen: 'HomeTab',
|
||||
params: { screen: 'Home', params: undefined },
|
||||
});
|
||||
router.replace(hrefs.hrefHome());
|
||||
};
|
||||
|
||||
navigation.setOptions({
|
||||
headerBackVisible: false,
|
||||
headerTitleAlign: 'left',
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={handleBackPress} style={styles.headerBackButton} hitSlop={8}>
|
||||
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
<AppBackButton onPress={handleBackPress} style={styles.headerBackButton} />
|
||||
),
|
||||
headerTitle: () => (
|
||||
<View style={styles.headerContainer}>
|
||||
@@ -641,7 +636,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
Alert.alert('删除成功', '帖子已删除', [
|
||||
{
|
||||
text: '确定',
|
||||
onPress: () => navigation.goBack(),
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
@@ -654,15 +649,12 @@ export const PostDetailScreen: React.FC = () => {
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [post, isDeleting, currentUser?.id, navigation]);
|
||||
}, [post, isDeleting, currentUser?.id, router]);
|
||||
|
||||
const handleEditPost = useCallback(() => {
|
||||
if (!post) return;
|
||||
navigation.navigate('CreatePost', {
|
||||
mode: 'edit',
|
||||
postId: post.id,
|
||||
});
|
||||
}, [navigation, post]);
|
||||
router.push(hrefs.hrefCreatePost('edit', post.id));
|
||||
}, [router, post]);
|
||||
|
||||
// 点击图片查看大图
|
||||
const handleImagePress = useCallback((images: ImageGridItem[], index: number) => {
|
||||
@@ -999,7 +991,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
|
||||
// 跳转到用户主页
|
||||
const handleUserPress = (userId: string) => {
|
||||
navigation.navigate('UserProfile', { userId });
|
||||
router.push(hrefs.hrefUserProfile(userId));
|
||||
};
|
||||
|
||||
// 渲染身份标识
|
||||
@@ -1294,27 +1286,22 @@ export const PostDetailScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 评论标题 - 现代化设计 */}
|
||||
<View style={[styles.commentTitle, { paddingVertical: responsiveGap }]}>
|
||||
<View style={styles.commentTitleLeft}>
|
||||
<View style={[styles.commentTitleIconContainer, { width: isDesktop ? 32 : 28, height: isDesktop ? 32 : 28 }]}>
|
||||
<MaterialCommunityIcons
|
||||
name="chat-processing-outline"
|
||||
size={isDesktop ? 18 : 16}
|
||||
color={colors.primary.contrast}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
variant="body"
|
||||
style={[
|
||||
styles.commentTitleText,
|
||||
{ fontSize: isDesktop ? fontSizes.xl : fontSizes.lg }
|
||||
]}
|
||||
>
|
||||
评论
|
||||
</Text>
|
||||
<View style={styles.commentCountBadge}>
|
||||
<Text variant="caption" style={styles.commentCountText}>{post.comments_count}</Text>
|
||||
{/* 评论标题 - 现代化简洁分区头 */}
|
||||
<View style={[styles.commentSectionHeader, { marginTop: responsiveGap, marginBottom: responsiveGap }]}>
|
||||
<View style={styles.commentSectionTitleBlock}>
|
||||
<View style={styles.commentSectionTitleRow}>
|
||||
<Text
|
||||
variant="body"
|
||||
style={[
|
||||
styles.commentSectionTitle,
|
||||
{ fontSize: isDesktop ? fontSizes.xl : fontSizes.lg }
|
||||
]}
|
||||
>
|
||||
评论区
|
||||
</Text>
|
||||
<View style={styles.commentCountBadge}>
|
||||
<Text variant="caption" style={styles.commentCountText}>{post.comments_count}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -1396,9 +1383,9 @@ export const PostDetailScreen: React.FC = () => {
|
||||
<View style={styles.emptyCommentsContainer}>
|
||||
<View style={styles.emptyCommentsIconWrapper}>
|
||||
<MaterialCommunityIcons
|
||||
name="message-text-outline"
|
||||
size={isDesktop ? 48 : 40}
|
||||
color={colors.primary.main}
|
||||
name="message-reply-text-outline"
|
||||
size={isDesktop ? 24 : 22}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
<Text variant="body" style={styles.emptyCommentsTitle}>
|
||||
@@ -1831,7 +1818,7 @@ const styles = StyleSheet.create({
|
||||
postMetaInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
justifyContent: 'flex-start',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
metaInfoMain: {
|
||||
@@ -1928,38 +1915,42 @@ const styles = StyleSheet.create({
|
||||
marginLeft: 6,
|
||||
fontWeight: '500',
|
||||
},
|
||||
commentTitle: {
|
||||
commentSectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
paddingTop: spacing.lg,
|
||||
},
|
||||
commentSectionTitleBlock: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
commentSectionTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
commentTitleLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
commentTitleIconContainer: {
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.main,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
commentTitleText: {
|
||||
fontWeight: '700',
|
||||
commentSectionTitle: {
|
||||
fontWeight: '800',
|
||||
color: colors.text.primary,
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
commentCountBadge: {
|
||||
backgroundColor: colors.primary.light + '25',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 999,
|
||||
marginLeft: spacing.sm,
|
||||
minWidth: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
commentCountText: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '700',
|
||||
color: colors.primary.main,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
inputContainer: {
|
||||
backgroundColor: colors.background.paper,
|
||||
@@ -2067,17 +2058,26 @@ const styles = StyleSheet.create({
|
||||
emptyCommentsContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.xl * 2,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
paddingVertical: spacing.xl + spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
emptyCommentsIconWrapper: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: colors.primary.light + '18',
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.background.default,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
emptyCommentsTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
|
||||
@@ -14,8 +14,7 @@ import {
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { Post, User } from '../../types';
|
||||
@@ -24,12 +23,10 @@ import { postService, authService } from '../../services';
|
||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||
import type { PostCardAction } from '../../components/business/PostCard';
|
||||
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
||||
import { HomeStackParamList } from '../../navigation/types';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive';
|
||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Search'>;
|
||||
|
||||
const TABS = ['帖子', '用户'];
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
@@ -37,12 +34,10 @@ type SearchType = 'posts' | 'users';
|
||||
|
||||
interface SearchScreenProps {
|
||||
onBack?: () => void;
|
||||
navigation?: NavigationProp;
|
||||
}
|
||||
|
||||
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation: propNavigation }) => {
|
||||
// 如果传入了 navigation 则使用传入的,否则使用 hook 获取的
|
||||
const navigation = propNavigation || useNavigation<NavigationProp>();
|
||||
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore();
|
||||
|
||||
@@ -168,12 +163,12 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
||||
|
||||
// 跳转到帖子详情
|
||||
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
|
||||
navigation.navigate('PostDetail', { postId, scrollToComments });
|
||||
router.push(hrefs.hrefPostDetail(postId, scrollToComments));
|
||||
};
|
||||
|
||||
// 跳转到用户主页
|
||||
const handleUserPress = (userId: string) => {
|
||||
navigation.navigate('UserProfile', { userId });
|
||||
router.push(hrefs.hrefUserProfile(userId));
|
||||
};
|
||||
|
||||
// 统一处理 PostCard 的操作(搜索页不支持删除)
|
||||
@@ -515,7 +510,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelButton, { marginLeft: responsiveGap }]}
|
||||
onPress={() => onBack ? onBack() : navigation.goBack()}
|
||||
onPress={() => (onBack ? onBack() : router.back())}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text
|
||||
|
||||
@@ -27,11 +27,12 @@ import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useNavigation, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
|
||||
import { colors } from '../../theme';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { messageManager } from '../../stores';
|
||||
import { useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import {
|
||||
@@ -49,7 +50,8 @@ import {
|
||||
} from './components/ChatScreen';
|
||||
|
||||
export const ChatScreen: React.FC = () => {
|
||||
const navigation = useNavigation<any>();
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// 响应式布局
|
||||
@@ -59,21 +61,9 @@ export const ChatScreen: React.FC = () => {
|
||||
// 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页
|
||||
useEffect(() => {
|
||||
if (isWideScreen) {
|
||||
// 导航到大屏幕模式的主界面首页
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: 'Main',
|
||||
params: {
|
||||
screen: 'HomeTab',
|
||||
params: { screen: 'Home' }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
router.replace(hrefs.hrefHome());
|
||||
}
|
||||
}, [isWideScreen, navigation]);
|
||||
}, [isWideScreen, router]);
|
||||
|
||||
// 输入框区域高度(用于定位浮动 mention 面板)
|
||||
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
||||
@@ -228,16 +218,6 @@ export const ChatScreen: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const highlightMessageTemporarily = useCallback((messageId: string, duration = 1500) => {
|
||||
if (replyHighlightTimerRef.current) {
|
||||
clearTimeout(replyHighlightTimerRef.current);
|
||||
}
|
||||
setSelectedMessageId(messageId);
|
||||
replyHighlightTimerRef.current = setTimeout(() => {
|
||||
setSelectedMessageId(prev => (prev === messageId ? null : prev));
|
||||
}, duration);
|
||||
}, [setSelectedMessageId]);
|
||||
|
||||
const handleReplyPreviewPress = useCallback((messageId: string) => {
|
||||
const targetId = String(messageId);
|
||||
const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId);
|
||||
@@ -245,14 +225,13 @@ export const ChatScreen: React.FC = () => {
|
||||
|
||||
replyTargetMessageIdRef.current = targetId;
|
||||
setBrowsingHistory(true);
|
||||
highlightMessageTemporarily(targetId);
|
||||
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: targetIndex,
|
||||
animated: true,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, [displayMessages, flatListRef, highlightMessageTemporarily, setBrowsingHistory]);
|
||||
}, [displayMessages, flatListRef, setBrowsingHistory]);
|
||||
|
||||
// 监听返回事件,刷新会话列表
|
||||
useEffect(() => {
|
||||
@@ -399,7 +378,7 @@ export const ChatScreen: React.FC = () => {
|
||||
otherUser={otherUser}
|
||||
routeGroupName={routeGroupName}
|
||||
typingHint={typingHint}
|
||||
onBack={() => navigation.goBack()}
|
||||
onBack={() => router.back()}
|
||||
onTitlePress={navigateToInfo}
|
||||
onMorePress={navigateToChatSettings}
|
||||
onGroupInfoPress={handleGroupInfoPress}
|
||||
@@ -431,8 +410,8 @@ export const ChatScreen: React.FC = () => {
|
||||
initialNumToRender={14}
|
||||
maxToRenderPerBatch={10}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={9}
|
||||
removeClippedSubviews={Platform.OS !== 'web'}
|
||||
windowSize={15}
|
||||
removeClippedSubviews={false}
|
||||
onScroll={handleMessageListScroll}
|
||||
onScrollBeginDrag={() => {
|
||||
isUserDraggingRef.current = true;
|
||||
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
@@ -25,13 +24,10 @@ import { groupService } from '../../services/groupService';
|
||||
import { uploadService } from '../../services/uploadService';
|
||||
import { Avatar, Text, Button, Loading } from '../../components/common';
|
||||
import { User } from '../../types';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import MutualFollowSelectorModal from './components/MutualFollowSelectorModal';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const CreateGroupScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const router = useRouter();
|
||||
|
||||
// 表单状态
|
||||
const [groupName, setGroupName] = useState('');
|
||||
@@ -138,7 +134,7 @@ const CreateGroupScreen: React.FC = () => {
|
||||
Alert.alert('成功', '群组创建成功', [
|
||||
{
|
||||
text: '确定',
|
||||
onPress: () => navigation.goBack(),
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
@@ -39,16 +39,14 @@ import {
|
||||
JoinType,
|
||||
} from '../../types/dto';
|
||||
import { User } from '../../types';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { firstRouteParam } from '../../navigation/paramUtils';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { messageManager } from '../../stores/messageManager';
|
||||
import { groupManager } from '../../stores/groupManager';
|
||||
import MutualFollowSelectorModal from './components/MutualFollowSelectorModal';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
type GroupInfoRouteProp = RouteProp<RootStackParamList, 'GroupInfo'>;
|
||||
|
||||
// 加群方式选项
|
||||
const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: string }[] = [
|
||||
{ value: 0, label: '允许任何人加入', icon: 'earth', desc: '任何人都可以直接加入群聊' },
|
||||
@@ -57,9 +55,13 @@ const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: s
|
||||
];
|
||||
|
||||
const GroupInfoScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<GroupInfoRouteProp>();
|
||||
const { groupId, conversationId } = route.params;
|
||||
const router = useRouter();
|
||||
const { groupId: groupIdParam, conversationId: conversationIdParam } = useLocalSearchParams<{
|
||||
groupId?: string | string[];
|
||||
conversationId?: string | string[];
|
||||
}>();
|
||||
const groupId = firstRouteParam(groupIdParam) ?? '';
|
||||
const conversationId = firstRouteParam(conversationIdParam);
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
// 响应式布局
|
||||
@@ -112,6 +114,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
|
||||
// 加载群组信息
|
||||
const loadGroupInfo = useCallback(async () => {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
@@ -138,7 +141,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
} catch (error) {
|
||||
console.error('加载群组信息失败:', error);
|
||||
Alert.alert('错误', '加载群组信息失败', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() },
|
||||
{ text: '确定', onPress: () => router.back() },
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -394,7 +397,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
try {
|
||||
await groupService.dissolveGroup(groupId);
|
||||
Alert.alert('成功', '群组已解散', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() },
|
||||
{ text: '确定', onPress: () => router.back() },
|
||||
]);
|
||||
} catch (error: any) {
|
||||
console.error('解散群组失败:', error);
|
||||
@@ -420,7 +423,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
try {
|
||||
await groupService.leaveGroup(groupId);
|
||||
Alert.alert('成功', '已退出群聊', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() },
|
||||
{ text: '确定', onPress: () => router.back() },
|
||||
]);
|
||||
} catch (error: any) {
|
||||
console.error('退出群聊失败:', error);
|
||||
@@ -464,7 +467,7 @@ const GroupInfoScreen: React.FC = () => {
|
||||
|
||||
// 跳转到成员管理
|
||||
const goToMembers = () => {
|
||||
navigation.navigate('GroupMembers' as any, { groupId });
|
||||
router.push(hrefs.hrefGroupMembers(groupId));
|
||||
};
|
||||
|
||||
// 获取加群方式文本
|
||||
@@ -485,8 +488,8 @@ const GroupInfoScreen: React.FC = () => {
|
||||
Alert.alert('已复制', '群号已复制到剪贴板');
|
||||
};
|
||||
|
||||
const formatGroupNo = (id: string | number) => {
|
||||
const raw = String(id);
|
||||
const formatGroupNo = (id: string) => {
|
||||
const raw = id;
|
||||
if (raw.length <= 12) return raw;
|
||||
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
||||
};
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { View, StyleSheet, ActivityIndicator, Alert, ScrollView } from 'react-native';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { View, StyleSheet, ActivityIndicator, Alert, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { Avatar, Text } from '../../components/common';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { routePayloadCache } from '../../stores/routePayloadCache';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { groupManager } from '../../stores/groupManager';
|
||||
import { GroupMemberResponse } from '../../types/dto';
|
||||
import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared';
|
||||
|
||||
type Route = RouteProp<RootStackParamList, 'GroupInviteDetail'>;
|
||||
type Navigation = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const GroupInviteDetailScreen: React.FC = () => {
|
||||
const route = useRoute<Route>();
|
||||
const navigation = useNavigation<Navigation>();
|
||||
const { message } = route.params;
|
||||
const router = useRouter();
|
||||
const { messageId } = useLocalSearchParams<{ messageId?: string }>();
|
||||
const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined;
|
||||
|
||||
if (!message) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.emptyWrap}>
|
||||
<Text variant="body" color="secondary">
|
||||
无法加载该消息,请从通知列表重新打开。
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Text variant="body">返回</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const { extra_data } = message;
|
||||
|
||||
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||
@@ -35,7 +46,7 @@ const GroupInviteDetailScreen: React.FC = () => {
|
||||
if (!extra_data?.group_id) return;
|
||||
setLoadingGroup(true);
|
||||
try {
|
||||
const group = await groupManager.getGroup(extra_data.group_id);
|
||||
const group = await groupManager.getGroup(String(extra_data.group_id));
|
||||
setMemberCount(group.member_count ?? null);
|
||||
} catch {
|
||||
setMemberCount(null);
|
||||
@@ -51,7 +62,7 @@ const GroupInviteDetailScreen: React.FC = () => {
|
||||
if (!extra_data?.group_id) return;
|
||||
setLoadingMembers(true);
|
||||
try {
|
||||
const res = await groupManager.getMembers(extra_data.group_id, 1, 100);
|
||||
const res = await groupManager.getMembers(String(extra_data.group_id), 1, 100);
|
||||
const admins = (res.list || []).filter(m => m.role === 'owner' || m.role === 'admin');
|
||||
setMembers(admins);
|
||||
} catch {
|
||||
@@ -76,9 +87,9 @@ const GroupInviteDetailScreen: React.FC = () => {
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await groupService.respondInvite(groupId, { flag, approve });
|
||||
await groupService.respondInvite(String(groupId), { flag, approve });
|
||||
Alert.alert('成功', approve ? '已同意加入群聊' : '已拒绝邀请', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() },
|
||||
{ text: '确定', onPress: () => router.back() },
|
||||
]);
|
||||
} catch {
|
||||
Alert.alert('操作失败', '请稍后重试');
|
||||
@@ -87,7 +98,7 @@ const GroupInviteDetailScreen: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const groupNo = useMemo(() => extra_data?.group_id || '-', [extra_data?.group_id]);
|
||||
const groupNo = useMemo(() => String(extra_data?.group_id ?? '-'), [extra_data?.group_id]);
|
||||
const formatGroupNo = (id: string) => {
|
||||
if (id.length <= 12) return id;
|
||||
return `${id.slice(0, 6)}...${id.slice(-4)}`;
|
||||
@@ -168,6 +179,16 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
emptyWrap: {
|
||||
flex: 1,
|
||||
padding: spacing.lg,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
backBtn: {
|
||||
padding: spacing.sm,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -19,8 +19,7 @@ import {
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
@@ -38,8 +37,7 @@ import {
|
||||
GroupMemberResponse,
|
||||
GroupRole,
|
||||
} from '../../types/dto';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
|
||||
import { firstRouteParam } from '../../navigation/paramUtils';
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
const GROUP_MEMBER_REMOTE_LIST_KIND: GroupMemberListSourceKind = 'cursor';
|
||||
|
||||
@@ -50,9 +48,6 @@ const GRID_CONFIG = {
|
||||
desktop: { columns: 3, itemWidth: '31%' },
|
||||
};
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
type GroupMembersRouteProp = RouteProp<RootStackParamList, 'GroupMembers'>;
|
||||
|
||||
// 成员分组
|
||||
interface MemberGroup {
|
||||
title: string;
|
||||
@@ -60,9 +55,8 @@ interface MemberGroup {
|
||||
}
|
||||
|
||||
const GroupMembersScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<GroupMembersRouteProp>();
|
||||
const { groupId } = route.params;
|
||||
const { groupId: groupIdParam } = useLocalSearchParams<{ groupId?: string | string[] }>();
|
||||
const groupId = firstRouteParam(groupIdParam) ?? '';
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
// 响应式布局
|
||||
@@ -155,8 +149,9 @@ const GroupMembersScreen: React.FC = () => {
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
refresh();
|
||||
}, [groupId]);
|
||||
}, [groupId, refresh]);
|
||||
|
||||
// 按角色分组
|
||||
const groupMembers = useCallback((): MemberGroup[] => {
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { Avatar, Text } from '../../components/common';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { routePayloadCache } from '../../stores/routePayloadCache';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { groupManager } from '../../stores/groupManager';
|
||||
import { userManager } from '../../stores/userManager';
|
||||
import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared';
|
||||
|
||||
type Route = RouteProp<RootStackParamList, 'GroupRequestDetail'>;
|
||||
type Navigation = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const GroupRequestDetailScreen: React.FC = () => {
|
||||
const route = useRoute<Route>();
|
||||
const navigation = useNavigation<Navigation>();
|
||||
const { message } = route.params;
|
||||
const router = useRouter();
|
||||
const { messageId } = useLocalSearchParams<{ messageId?: string }>();
|
||||
const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined;
|
||||
|
||||
if (!message) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.emptyWrap}>
|
||||
<Text variant="body" color="secondary">
|
||||
无法加载该消息,请从通知列表重新打开。
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Text variant="body">返回</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const { extra_data } = message;
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [memberCount, setMemberCount] = useState<number | null>(null);
|
||||
@@ -64,7 +77,7 @@ const GroupRequestDetailScreen: React.FC = () => {
|
||||
if (!extra_data?.group_id) return;
|
||||
setLoadingGroup(true);
|
||||
try {
|
||||
const group = await groupManager.getGroup(extra_data.group_id);
|
||||
const group = await groupManager.getGroup(String(extra_data.group_id));
|
||||
setMemberCount(group.member_count ?? null);
|
||||
} catch {
|
||||
setMemberCount(null);
|
||||
@@ -90,18 +103,18 @@ const GroupRequestDetailScreen: React.FC = () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (message.system_type === 'group_invite') {
|
||||
await groupService.respondInvite(groupId, {
|
||||
await groupService.respondInvite(String(groupId), {
|
||||
flag,
|
||||
approve,
|
||||
});
|
||||
} else {
|
||||
await groupService.reviewJoinRequest(groupId, {
|
||||
await groupService.reviewJoinRequest(String(groupId), {
|
||||
flag,
|
||||
approve,
|
||||
});
|
||||
}
|
||||
Alert.alert('成功', approve ? '已同意申请' : '已拒绝申请', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() },
|
||||
{ text: '确定', onPress: () => router.back() },
|
||||
]);
|
||||
} catch (error) {
|
||||
Alert.alert('操作失败', '请稍后重试');
|
||||
@@ -114,7 +127,7 @@ const GroupRequestDetailScreen: React.FC = () => {
|
||||
if (!applicantId) return;
|
||||
const user = await userManager.getUserById(applicantId);
|
||||
if (user?.id) {
|
||||
navigation.navigate('UserProfile', { userId: String(user.id) });
|
||||
router.push(hrefs.hrefUserProfile(String(user.id)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,7 +137,7 @@ const GroupRequestDetailScreen: React.FC = () => {
|
||||
<GroupInfoSummaryCard
|
||||
groupName={extra_data?.group_name}
|
||||
groupAvatar={extra_data?.group_avatar}
|
||||
groupNo={extra_data?.group_id}
|
||||
groupNo={String(extra_data?.group_id ?? '')}
|
||||
groupDescription={extra_data?.group_description}
|
||||
memberCountText={loadingGroup ? '人数加载中...' : `${memberCount ?? '-'} 人`}
|
||||
/>
|
||||
@@ -166,6 +179,16 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
emptyWrap: {
|
||||
flex: 1,
|
||||
padding: spacing.lg,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
backBtn: {
|
||||
padding: spacing.sm,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { colors, spacing, borderRadius } from '../../theme';
|
||||
@@ -19,15 +18,12 @@ import Avatar from '../../components/common/Avatar';
|
||||
import Text from '../../components/common/Text';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { groupManager } from '../../stores/groupManager';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { GroupResponse, JoinType } from '../../types/dto';
|
||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
import { EmptyState } from '../../components/common';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const JoinGroupScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const router = useRouter();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [joiningGroupId, setJoiningGroupId] = useState<string | null>(null);
|
||||
@@ -92,7 +88,7 @@ const JoinGroupScreen: React.FC = () => {
|
||||
Alert.alert('成功', '操作已提交', [
|
||||
{
|
||||
text: '确定',
|
||||
onPress: () => navigation.goBack(),
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
@@ -106,13 +102,13 @@ const JoinGroupScreen: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyGroupId = (groupId: string | number) => {
|
||||
Clipboard.setString(String(groupId));
|
||||
const handleCopyGroupId = (groupId: string) => {
|
||||
Clipboard.setString(groupId);
|
||||
Alert.alert('已复制', '群号已复制到剪贴板');
|
||||
};
|
||||
|
||||
const formatGroupNo = (id: string | number) => {
|
||||
const raw = String(id);
|
||||
const formatGroupNo = (id: string) => {
|
||||
const raw = id;
|
||||
if (raw.length <= 12) return raw;
|
||||
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
||||
};
|
||||
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useIsFocused } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme';
|
||||
@@ -43,9 +43,9 @@ import {
|
||||
useMarkAsRead,
|
||||
useMessageManagerConversations,
|
||||
} from '../../stores';
|
||||
import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||
import { Avatar, Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common';
|
||||
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
// 导入 EmbeddedChat 组件用于桌面端双栏布局
|
||||
import { EmbeddedChat } from './components/EmbeddedChat';
|
||||
import { ConversationListRow } from './components/ConversationListRow';
|
||||
@@ -54,9 +54,6 @@ import { NotificationsScreen } from './NotificationsScreen';
|
||||
// 导入扫码组件
|
||||
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
type MessageNavProp = NativeStackNavigationProp<MessageStackParamList>;
|
||||
|
||||
// 系统通知会话特殊ID
|
||||
const SYSTEM_MESSAGE_CHANNEL_ID = '-1';
|
||||
|
||||
@@ -76,7 +73,7 @@ interface SearchResultItem {
|
||||
* 支持响应式双栏布局
|
||||
*/
|
||||
export const MessageListScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp & MessageNavProp>();
|
||||
const router = useRouter();
|
||||
const isFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
// 在大屏幕模式下不使用 Bottom Tab Navigator,所以不需要 tabBarHeight
|
||||
@@ -286,22 +283,25 @@ export const MessageListScreen: React.FC = () => {
|
||||
setSelectedConversation(conversation);
|
||||
} else if (conversation.type === 'group' && conversation.group) {
|
||||
// 群聊 - 窄屏下导航
|
||||
(navigation as any).navigate('Chat', {
|
||||
conversationId: String(conversation.id),
|
||||
groupId: String(conversation.group.id),
|
||||
groupName: conversation.group.name,
|
||||
isGroupChat: true,
|
||||
});
|
||||
router.push(
|
||||
hrefs.hrefChat({
|
||||
conversationId: String(conversation.id),
|
||||
groupId: String(conversation.group.id),
|
||||
groupName: conversation.group.name,
|
||||
isGroupChat: true,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// 私聊 - 窄屏下导航
|
||||
const currentUserId = useAuthStore.getState().currentUser?.id;
|
||||
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
|
||||
(navigation as any).navigate('Chat', {
|
||||
conversationId: String(conversation.id),
|
||||
userId: otherUser ? String(otherUser.id) : undefined,
|
||||
userName: otherUser?.nickname || otherUser?.username,
|
||||
isGroupChat: false,
|
||||
});
|
||||
router.push(
|
||||
hrefs.hrefChat({
|
||||
conversationId: String(conversation.id),
|
||||
userId: otherUser ? String(otherUser.id) : undefined,
|
||||
isGroupChat: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -311,12 +311,12 @@ export const MessageListScreen: React.FC = () => {
|
||||
|
||||
// 创建群聊
|
||||
const handleCreateGroup = () => {
|
||||
(navigation as any).navigate('CreateGroup');
|
||||
router.push(hrefs.hrefGroupCreate());
|
||||
};
|
||||
|
||||
// 主动加群
|
||||
const handleJoinGroup = () => {
|
||||
(navigation as any).navigate('JoinGroup');
|
||||
router.push(hrefs.hrefGroupJoin());
|
||||
};
|
||||
|
||||
const handleOpenActionMenu = () => {
|
||||
@@ -581,12 +581,13 @@ export const MessageListScreen: React.FC = () => {
|
||||
const conversation = await createConversation(String(user.id));
|
||||
|
||||
if (conversation) {
|
||||
(navigation as any).navigate('Chat', {
|
||||
conversationId: String(conversation.id),
|
||||
userId: String(user.id),
|
||||
userName: user.nickname || user.username,
|
||||
isGroupChat: false,
|
||||
});
|
||||
router.push(
|
||||
hrefs.hrefChat({
|
||||
conversationId: String(conversation.id),
|
||||
userId: String(user.id),
|
||||
isGroupChat: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建会话失败:', error);
|
||||
@@ -660,9 +661,7 @@ export const MessageListScreen: React.FC = () => {
|
||||
const renderSearchMode = () => (
|
||||
<View style={styles.searchModeContainer}>
|
||||
<View style={styles.searchInputContainer}>
|
||||
<TouchableOpacity onPress={handleCloseSearch}>
|
||||
<MaterialCommunityIcons name="arrow-left" size={22} color="#666" />
|
||||
</TouchableOpacity>
|
||||
<AppBackButton onPress={handleCloseSearch} iconColor="#666" />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
|
||||
|
||||
@@ -18,17 +18,16 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { SystemMessageResponse } from '../../types/dto';
|
||||
import { messageService } from '../../services/messageService';
|
||||
import { commentService } from '../../services/commentService';
|
||||
import { SystemMessageItem } from '../../components/business';
|
||||
import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||
import { Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common';
|
||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { useMessageManagerSystemUnreadCount } from '../../stores';
|
||||
import { messageManager } from '../../stores/messageManager';
|
||||
|
||||
@@ -48,7 +47,7 @@ const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_j
|
||||
export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => {
|
||||
const isFocused = useIsFocused();
|
||||
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const router = useRouter();
|
||||
|
||||
// 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题
|
||||
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });
|
||||
@@ -291,17 +290,17 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
) {
|
||||
const postId = await resolvePostId(message);
|
||||
if (postId) {
|
||||
navigation.navigate('PostDetail', { postId });
|
||||
router.push(hrefs.hrefPostDetail(postId));
|
||||
}
|
||||
} else if (system_type === 'follow') {
|
||||
// 关注 - 跳转到用户主页
|
||||
if (extra_data?.actor_id_str) {
|
||||
navigation.navigate('UserProfile', { userId: extra_data.actor_id_str });
|
||||
router.push(hrefs.hrefUserProfile(extra_data.actor_id_str));
|
||||
}
|
||||
} else if (system_type === 'group_join_apply') {
|
||||
navigation.navigate('GroupRequestDetail', { message });
|
||||
router.push(hrefs.hrefGroupRequestDetail(message));
|
||||
} else if (system_type === 'group_invite') {
|
||||
navigation.navigate('GroupInviteDetail', { message });
|
||||
router.push(hrefs.hrefGroupInviteDetail(message));
|
||||
}
|
||||
// 其他类型暂不处理跳转
|
||||
} catch (error) {
|
||||
@@ -316,7 +315,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
const actorId = extra_data?.actor_id_str || (extra_data?.actor_id ? String(extra_data.actor_id) : null);
|
||||
|
||||
if (actorId) {
|
||||
navigation.navigate('UserProfile', { userId: actorId });
|
||||
router.push(hrefs.hrefUserProfile(actorId));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -350,13 +349,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
return (
|
||||
<View style={[styles.header, isWideScreen ? styles.headerWide : null]}>
|
||||
{shouldShowBackButton ? (
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={onBack}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
<AppBackButton style={styles.backButton} onPress={onBack} />
|
||||
) : (
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
)}
|
||||
|
||||
@@ -14,8 +14,7 @@ import {
|
||||
Switch,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
@@ -30,16 +29,20 @@ import { messageManager } from '../../stores/messageManager';
|
||||
import { userManager } from '../../stores/userManager';
|
||||
import { Avatar, Text, Button, Loading, Divider } from '../../components/common';
|
||||
import { User } from '../../types';
|
||||
import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
|
||||
import { navigationService } from '../../infrastructure/navigation/navigationService';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<MessageStackParamList>;
|
||||
type PrivateChatInfoRouteProp = RouteProp<MessageStackParamList, 'PrivateChatInfo'>;
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
|
||||
const PrivateChatInfoScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<PrivateChatInfoRouteProp>();
|
||||
const { conversationId, userId, userName, userAvatar } = route.params;
|
||||
const router = useRouter();
|
||||
const raw = useLocalSearchParams<{
|
||||
conversationId?: string;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
userAvatar?: string;
|
||||
}>();
|
||||
const conversationId = raw.conversationId ?? '';
|
||||
const userId = raw.userId ?? '';
|
||||
const userName = raw.userName;
|
||||
const userAvatar = raw.userAvatar;
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
// 用户信息状态
|
||||
@@ -150,8 +153,7 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
|
||||
// 查看用户资料
|
||||
const handleViewProfile = () => {
|
||||
// 使用导航服务跳转到用户资料页面
|
||||
navigationService.navigate('UserProfile', { userId });
|
||||
router.push(hrefs.hrefUserProfile(userId));
|
||||
};
|
||||
|
||||
// 举报/投诉
|
||||
@@ -219,7 +221,7 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
}
|
||||
setIsBlocked(true);
|
||||
messageManager.removeConversation(conversationId);
|
||||
navigation.navigate('MessageList' as any);
|
||||
router.replace(hrefs.hrefMessages());
|
||||
Alert.alert('已拉黑', '你已成功拉黑该用户');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '拉黑失败,请稍后重试');
|
||||
@@ -245,7 +247,7 @@ const PrivateChatInfoScreen: React.FC = () => {
|
||||
await messageService.deleteConversationForSelf(conversationId);
|
||||
messageManager.removeConversation(conversationId);
|
||||
// 返回消息列表
|
||||
navigation.navigate('MessageList' as any);
|
||||
router.replace(hrefs.hrefMessages());
|
||||
} catch (error) {
|
||||
const msg = error instanceof ApiError ? error.message : '删除聊天失败';
|
||||
Alert.alert('错误', msg);
|
||||
|
||||
@@ -7,10 +7,10 @@ import React, { useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Avatar, Text } from '../../../../components/common';
|
||||
import { Avatar, Text, AppBackButton } from '../../../../components/common';
|
||||
import { colors, spacing } from '../../../../theme';
|
||||
import { chatScreenStyles as baseStyles } from './styles';
|
||||
import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive';
|
||||
import { useBreakpointGTE } from '../../../../hooks/useResponsive';
|
||||
import { ChatHeaderProps } from './types';
|
||||
|
||||
export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
@@ -26,7 +26,6 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
isWideScreen: propIsWideScreen,
|
||||
}) => {
|
||||
// 响应式布局
|
||||
const { width } = useResponsive();
|
||||
// 使用 768px 作为大屏幕和小屏幕的分界线
|
||||
const isWideScreenHook = useBreakpointGTE('lg');
|
||||
// 优先使用 props 传入的 isWideScreen,否则使用 hook
|
||||
@@ -74,12 +73,7 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
<View style={styles.header}>
|
||||
{/* 大屏幕(>= 768px)时隐藏返回按钮 */}
|
||||
{!isWideScreen ? (
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={onBack}
|
||||
>
|
||||
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.primary.dark} />
|
||||
</TouchableOpacity>
|
||||
<AppBackButton style={styles.backButton} onPress={onBack} />
|
||||
) : (
|
||||
<View style={styles.backButton} />
|
||||
)}
|
||||
@@ -131,14 +125,14 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
style={styles.moreButton}
|
||||
onPress={onGroupInfoPress}
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.moreButton}
|
||||
onPress={onMorePress}
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -120,7 +120,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
const data = s.data as ImageSegmentData;
|
||||
return {
|
||||
id: `img-${message.id}-${idx}`,
|
||||
url: data.url,
|
||||
url: data.url || data.thumbnail_url || '',
|
||||
thumbnail_url: data.thumbnail_url,
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
@@ -128,7 +128,6 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
});
|
||||
|
||||
// 检查当前消息是否被选中
|
||||
const isSelected = selectedMessageId === String(message.id);
|
||||
|
||||
// 获取发送者信息(群聊)- 供子组件使用
|
||||
const getSenderInfo = React.useCallback((senderId: string): SenderInfo => {
|
||||
@@ -307,7 +306,6 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
isMe ? styles.myBubble : styles.theirBubble,
|
||||
hasReply && segmentStyles.replyBubble,
|
||||
isPureImageMessage && segmentStyles.pureImageBubble,
|
||||
isSelected && (isMe ? styles.mySelectedBubble : styles.theirSelectedBubble),
|
||||
]}>
|
||||
<MessageSegmentsRenderer
|
||||
segments={segments}
|
||||
@@ -320,7 +318,9 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
onReplyPress={onReplyPress}
|
||||
onImagePress={(url) => {
|
||||
// 查找点击的图片索引
|
||||
const clickIndex = imageSegments.findIndex(img => img.url === url);
|
||||
const clickIndex = imageSegments.findIndex(
|
||||
img => img.url === url || img.thumbnail_url === url
|
||||
);
|
||||
if (clickIndex !== -1 && onImagePress) {
|
||||
onImagePress(imageSegments, clickIndex);
|
||||
}
|
||||
@@ -419,7 +419,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<View style={styles.messageContent}>
|
||||
<View style={[styles.messageContent, isMe ? styles.myMessageContentPanel : null]}>
|
||||
{/* 群聊模式:显示发送者昵称 */}
|
||||
{isGroupChat && !isMe && senderInfo && (
|
||||
<Text style={styles.senderName}>{senderInfo.nickname}</Text>
|
||||
@@ -483,16 +483,4 @@ const segmentStyles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
// 使用 React.memo 优化渲染性能
|
||||
export default React.memo(MessageBubble, (prevProps, nextProps) => {
|
||||
// 自定义比较逻辑:只比较关键属性
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.status === nextProps.message.status &&
|
||||
prevProps.selectedMessageId === nextProps.selectedMessageId &&
|
||||
prevProps.currentUserId === nextProps.currentUserId &&
|
||||
prevProps.isGroupChat === nextProps.isGroupChat &&
|
||||
prevProps.otherUserLastReadSeq === nextProps.otherUserLastReadSeq &&
|
||||
prevProps.index === nextProps.index
|
||||
);
|
||||
});
|
||||
export default MessageBubble;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 用于渲染消息链中的各种 Segment 类型
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -131,11 +131,21 @@ const ImageSegment: React.FC<{
|
||||
const pressPositionRef = useRef({ x: 0, y: 0 });
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const imageUrl = data.thumbnail_url || data.url;
|
||||
const [currentImageUri, setCurrentImageUri] = useState(data.thumbnail_url || data.url || '');
|
||||
const imageUrl = currentImageUri;
|
||||
const pressUrl = data.url || data.thumbnail_url || '';
|
||||
const stableImageKey = `${data.url || ''}|${data.thumbnail_url || ''}|${data.width || 0}x${data.height || 0}`;
|
||||
|
||||
// 如果没有有效的图片URL,显示图片占位符
|
||||
const hasValidUrl = !!imageUrl && imageUrl.trim() !== '';
|
||||
|
||||
useEffect(() => {
|
||||
// 切换消息图片时重置状态,避免列表复用导致旧状态污染
|
||||
setCurrentImageUri(data.thumbnail_url || data.url || '');
|
||||
setDimensions(null);
|
||||
setLoadError(false);
|
||||
}, [data.thumbnail_url, data.url]);
|
||||
|
||||
useEffect(() => {
|
||||
// 重置状态
|
||||
setLoadError(false);
|
||||
@@ -169,9 +179,7 @@ const ImageSegment: React.FC<{
|
||||
|
||||
// 添加超时处理,防止高分辨率图片加载卡住
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!dimensions) {
|
||||
setDimensions(IMAGE_FALLBACK_SIZE);
|
||||
}
|
||||
setDimensions(prev => prev || IMAGE_FALLBACK_SIZE);
|
||||
}, 3000);
|
||||
|
||||
// 否则从图片获取实际尺寸
|
||||
@@ -196,7 +204,7 @@ const ImageSegment: React.FC<{
|
||||
|
||||
setDimensions({ width: Math.round(newWidth), height: Math.round(newHeight) });
|
||||
},
|
||||
(error) => {
|
||||
() => {
|
||||
clearTimeout(timeoutId);
|
||||
// 获取失败时使用默认 4:3 比例
|
||||
setDimensions(IMAGE_FALLBACK_SIZE);
|
||||
@@ -221,10 +229,9 @@ const ImageSegment: React.FC<{
|
||||
if (!hasValidUrl || loadError) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`image-${data.url || Math.random()}`}
|
||||
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={() => hasValidUrl && onImagePress?.(data.url)}
|
||||
onPress={() => hasValidUrl && onImagePress?.(pressUrl)}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={500}
|
||||
activeOpacity={0.9}
|
||||
@@ -250,10 +257,9 @@ const ImageSegment: React.FC<{
|
||||
if (!dimensions) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`image-${data.url}`}
|
||||
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={() => onImagePress?.(data.url)}
|
||||
onPress={() => onImagePress?.(pressUrl)}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={500}
|
||||
activeOpacity={0.9}
|
||||
@@ -272,10 +278,9 @@ const ImageSegment: React.FC<{
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`image-${data.url}`}
|
||||
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={() => onImagePress?.(data.url)}
|
||||
onPress={() => onImagePress?.(pressUrl)}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={500}
|
||||
activeOpacity={0.9}
|
||||
@@ -287,11 +292,20 @@ const ImageSegment: React.FC<{
|
||||
height: dimensions.height,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
recyclingKey={stableImageKey}
|
||||
contentFit="cover"
|
||||
cachePolicy="disk"
|
||||
cachePolicy="memory-disk"
|
||||
priority="normal"
|
||||
transition={200}
|
||||
onLoadStart={() => {
|
||||
setLoadError(false);
|
||||
}}
|
||||
onError={() => {
|
||||
// 缩略图失败时自动回退原图,降低跳转定位后的白块/丢图概率
|
||||
if (data.thumbnail_url && data.url && imageUrl === data.thumbnail_url) {
|
||||
setCurrentImageUri(data.url);
|
||||
return;
|
||||
}
|
||||
setLoadError(true);
|
||||
}}
|
||||
/>
|
||||
@@ -680,7 +694,9 @@ export const MessageSegmentsRenderer: React.FC<{
|
||||
{/* 其他 Segment 内容 */}
|
||||
<View style={styles.segmentsContent}>
|
||||
{otherSegments.map((segment, index) => (
|
||||
<React.Fragment key={`segment-${index}-${segment.type}`}>
|
||||
<React.Fragment
|
||||
key={`segment-${segment.type}-${(segment as any)?.data?.url || (segment as any)?.data?.id || (segment as any)?.data?.user_id || index}`}
|
||||
>
|
||||
{renderSegment(segment, renderProps)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
@@ -738,17 +754,17 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
atTextMe: {
|
||||
color: '#1976D2', // 蓝色
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.12)',
|
||||
color: '#4A88C7',
|
||||
backgroundColor: 'rgba(74, 136, 199, 0.12)',
|
||||
borderRadius: 4,
|
||||
},
|
||||
atTextOther: {
|
||||
color: '#1976D2', // 蓝色
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.12)',
|
||||
color: '#4A88C7',
|
||||
backgroundColor: 'rgba(74, 136, 199, 0.12)',
|
||||
borderRadius: 4,
|
||||
},
|
||||
atHighlight: {
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.2)',
|
||||
backgroundColor: 'rgba(74, 136, 199, 0.2)',
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
@@ -966,7 +982,7 @@ const styles = StyleSheet.create({
|
||||
elevation: 0,
|
||||
},
|
||||
replyMe: {
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.1)',
|
||||
backgroundColor: 'rgba(74, 136, 199, 0.12)',
|
||||
},
|
||||
replyOther: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.03)',
|
||||
@@ -984,7 +1000,7 @@ const styles = StyleSheet.create({
|
||||
replySender: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#1976D2',
|
||||
color: '#4A88C7',
|
||||
marginRight: 6,
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
@@ -23,8 +23,8 @@ export const bubbleColors = {
|
||||
},
|
||||
// 被回复的消息高亮
|
||||
replyHighlight: {
|
||||
background: 'rgba(255, 107, 53, 0.08)',
|
||||
borderLeft: '#FF6B35',
|
||||
background: 'rgba(74, 136, 199, 0.1)',
|
||||
borderLeft: '#4A88C7',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
},
|
||||
senderName: {
|
||||
fontSize: 13,
|
||||
color: '#1976D2',
|
||||
color: '#4A88C7',
|
||||
marginBottom: 4,
|
||||
marginLeft: 4,
|
||||
fontWeight: '600',
|
||||
@@ -215,7 +215,7 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
minWidth: 60,
|
||||
},
|
||||
myBubble: {
|
||||
backgroundColor: '#E3F2FD', // Telegram风格的浅蓝色
|
||||
backgroundColor: '#DFF2FF', // Telegram风格的浅蓝色
|
||||
borderBottomRightRadius: 4, // 右下角尖,箭头在右下
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
@@ -255,17 +255,23 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
// 选中状态
|
||||
selectedBubble: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#007AFF',
|
||||
borderColor: '#7FB6E6',
|
||||
},
|
||||
myMessageContentPanel: {
|
||||
backgroundColor: '#DFF2FF',
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 4,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
mySelectedBubble: {
|
||||
backgroundColor: '#0051D5',
|
||||
backgroundColor: '#CFEAFF',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255,255,255,0.5)',
|
||||
borderColor: '#7FB6E6',
|
||||
},
|
||||
theirSelectedBubble: {
|
||||
backgroundColor: '#E8F1FF',
|
||||
backgroundColor: '#EEF5FC',
|
||||
borderWidth: 2,
|
||||
borderColor: '#007AFF',
|
||||
borderColor: '#7FB6E6',
|
||||
},
|
||||
selectedText: {
|
||||
// 文本选中时的样式
|
||||
@@ -539,7 +545,7 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
panelTabActive: {
|
||||
backgroundColor: '#E3F2FD',
|
||||
backgroundColor: '#DFF2FF',
|
||||
},
|
||||
panelTabEmoji: {
|
||||
fontSize: 24,
|
||||
@@ -1025,11 +1031,11 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
// 表情包选择状态
|
||||
stickerItemSelected: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#007AFF',
|
||||
borderColor: '#7FB6E6',
|
||||
},
|
||||
stickerCheckOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 122, 255, 0.1)',
|
||||
backgroundColor: 'rgba(127, 182, 230, 0.14)',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'flex-start',
|
||||
padding: 4,
|
||||
@@ -1045,8 +1051,8 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
stickerCheckBoxSelected: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderColor: '#007AFF',
|
||||
backgroundColor: '#7FB6E6',
|
||||
borderColor: '#7FB6E6',
|
||||
},
|
||||
|
||||
// 表情管理模态框
|
||||
@@ -1077,12 +1083,12 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
},
|
||||
manageDoneText: {
|
||||
fontSize: 16,
|
||||
color: '#007AFF',
|
||||
color: '#4A88C7',
|
||||
fontWeight: '500',
|
||||
},
|
||||
manageSelectText: {
|
||||
fontSize: 16,
|
||||
color: '#007AFF',
|
||||
color: '#4A88C7',
|
||||
fontWeight: '500',
|
||||
},
|
||||
manageInfoBar: {
|
||||
@@ -1120,7 +1126,7 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
},
|
||||
manageSelectAllText: {
|
||||
fontSize: 15,
|
||||
color: '#007AFF',
|
||||
color: '#4A88C7',
|
||||
fontWeight: '500',
|
||||
},
|
||||
manageDeleteButton: {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
KeyboardEvent,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useRoute, RouteProp, useNavigation } from '@react-navigation/native';
|
||||
import { useLocalSearchParams, router } from 'expo-router';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
@@ -30,8 +30,8 @@ import { useChat, useGroupTyping, useGroupMuted, messageManager } from '../../..
|
||||
import { groupService } from '../../../../services/groupService';
|
||||
import { userManager } from '../../../../stores/userManager';
|
||||
import { groupManager } from '../../../../stores/groupManager';
|
||||
import { RootStackParamList } from '../../../../navigation/types';
|
||||
import { navigationService } from '../../../../infrastructure/navigation/navigationService';
|
||||
import * as hrefs from '../../../../navigation/hrefs';
|
||||
import { firstRouteParam } from '../../../../navigation/paramUtils';
|
||||
import {
|
||||
GroupMessage,
|
||||
PanelType,
|
||||
@@ -46,8 +46,6 @@ import {
|
||||
clearConversationMessages,
|
||||
} from '../../../../services/database';
|
||||
|
||||
type ChatRouteProp = RouteProp<RootStackParamList, 'Chat'>;
|
||||
|
||||
export const useChatScreen = () => {
|
||||
const getSendErrorMessage = useCallback((error: unknown, fallback: string) => {
|
||||
if (error instanceof ApiError && error.message) {
|
||||
@@ -56,17 +54,30 @@ export const useChatScreen = () => {
|
||||
return fallback;
|
||||
}, []);
|
||||
|
||||
const route = useRoute<ChatRouteProp>();
|
||||
const navigation = useNavigation();
|
||||
|
||||
// 路由参数
|
||||
const routeParams = useMemo(() => ({
|
||||
conversationId: route.params?.conversationId || null,
|
||||
userId: route.params?.userId || null,
|
||||
isGroupChat: route.params?.isGroupChat || false,
|
||||
groupId: route.params?.groupId,
|
||||
groupName: route.params?.groupName,
|
||||
}), [route.params?.conversationId, route.params?.userId, route.params?.isGroupChat, route.params?.groupId, route.params?.groupName]);
|
||||
const rawParams = useLocalSearchParams<{
|
||||
conversationId?: string | string[];
|
||||
userId?: string | string[];
|
||||
isGroupChat?: string | string[];
|
||||
groupId?: string | string[];
|
||||
groupName?: string | string[];
|
||||
}>();
|
||||
// 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失
|
||||
const routeParams = useMemo(() => {
|
||||
const isGroupFlag = firstRouteParam(rawParams.isGroupChat);
|
||||
return {
|
||||
conversationId: firstRouteParam(rawParams.conversationId) ?? null,
|
||||
userId: firstRouteParam(rawParams.userId) ?? null,
|
||||
isGroupChat: isGroupFlag === '1' || isGroupFlag === 'true',
|
||||
groupId: firstRouteParam(rawParams.groupId),
|
||||
groupName: firstRouteParam(rawParams.groupName),
|
||||
};
|
||||
}, [
|
||||
rawParams.conversationId,
|
||||
rawParams.userId,
|
||||
rawParams.isGroupChat,
|
||||
rawParams.groupId,
|
||||
rawParams.groupName,
|
||||
]);
|
||||
|
||||
const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName } = routeParams;
|
||||
|
||||
@@ -399,8 +410,30 @@ export const useChatScreen = () => {
|
||||
console.error('获取成员信息失败:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
const isGroupNotFound =
|
||||
error instanceof ApiError &&
|
||||
(error.code === 404 ||
|
||||
error.message === '群组不存在' ||
|
||||
error.message.includes('群组不存在'));
|
||||
|
||||
if (isGroupNotFound) {
|
||||
Alert.alert('提示', '该群组不存在或已解散', [
|
||||
{
|
||||
text: '确定',
|
||||
onPress: () => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace(hrefs.hrefMessages());
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('获取群组信息失败:', error);
|
||||
Alert.alert('错误', '无法获取群组信息');
|
||||
Alert.alert('错误', getSendErrorMessage(error, '无法获取群组信息'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1129,7 +1162,7 @@ export const useChatScreen = () => {
|
||||
// 点击头像跳转到用户主页
|
||||
const handleAvatarPress = useCallback((userId: string) => {
|
||||
if (userId && userId !== currentUserId) {
|
||||
navigationService.navigate('UserProfile', { userId: String(userId) });
|
||||
router.push(hrefs.hrefUserProfile(String(userId)));
|
||||
}
|
||||
}, [currentUserId]);
|
||||
|
||||
@@ -1198,31 +1231,27 @@ export const useChatScreen = () => {
|
||||
// 导航到群组信息或用户资料
|
||||
const navigateToInfo = useCallback(() => {
|
||||
if (isGroupChat && routeGroupId) {
|
||||
navigation.navigate('GroupInfo' as any, {
|
||||
groupId: routeGroupId,
|
||||
conversationId: conversationId || undefined,
|
||||
});
|
||||
router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined));
|
||||
} else if (otherUser?.id) {
|
||||
navigationService.navigate('UserProfile', { userId: String(otherUser.id) });
|
||||
router.push(hrefs.hrefUserProfile(String(otherUser.id)));
|
||||
}
|
||||
}, [navigation, isGroupChat, routeGroupId, otherUser]);
|
||||
}, [isGroupChat, routeGroupId, otherUser, conversationId]);
|
||||
|
||||
// 导航到聊天管理页面
|
||||
const navigateToChatSettings = useCallback(() => {
|
||||
if (isGroupChat && routeGroupId) {
|
||||
navigation.navigate('GroupInfo' as any, {
|
||||
groupId: routeGroupId,
|
||||
conversationId: conversationId || undefined,
|
||||
});
|
||||
router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined));
|
||||
} else if (otherUser?.id && conversationId) {
|
||||
navigation.navigate('PrivateChatInfo' as any, {
|
||||
conversationId: conversationId,
|
||||
userId: String(otherUser.id),
|
||||
userName: otherUser.nickname,
|
||||
userAvatar: otherUser.avatar,
|
||||
});
|
||||
router.push(
|
||||
hrefs.hrefPrivateChatInfo({
|
||||
conversationId,
|
||||
userId: String(otherUser.id),
|
||||
userName: otherUser.nickname,
|
||||
userAvatar: otherUser.avatar,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [navigation, isGroupChat, routeGroupId, otherUser, conversationId]);
|
||||
}, [isGroupChat, routeGroupId, otherUser, conversationId]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
|
||||
@@ -16,17 +16,18 @@ import {
|
||||
Dimensions,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { colors, spacing, fontSizes, shadows } from '../../../theme';
|
||||
import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto';
|
||||
import { Avatar, Text, ImageGallery } from '../../../components/common';
|
||||
import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common';
|
||||
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
|
||||
import { extractTextFromSegments } from '../../../types/dto';
|
||||
import { useBreakpointGTE } from '../../../hooks/useResponsive';
|
||||
import { useMarkAsRead } from '../../../stores/messageManagerHooks';
|
||||
import { messageService } from '../../../services';
|
||||
import * as hrefs from '../../../navigation/hrefs';
|
||||
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
|
||||
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
|
||||
|
||||
@@ -39,7 +40,7 @@ interface EmbeddedChatProps {
|
||||
}
|
||||
|
||||
export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack }) => {
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const currentUser = useAuthStore(state => state.currentUser);
|
||||
|
||||
// 响应式布局 - 使用 768px 作为大屏幕和小屏幕的分界线
|
||||
@@ -190,21 +191,24 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
// 导航到完整聊天页面
|
||||
const handleNavigateToFullChat = () => {
|
||||
if (isGroupChat && conversation.group) {
|
||||
(navigation as any).navigate('Chat', {
|
||||
conversationId: String(conversation.id),
|
||||
groupId: String(conversation.group.id),
|
||||
groupName: conversation.group.name,
|
||||
isGroupChat: true,
|
||||
});
|
||||
router.push(
|
||||
hrefs.hrefChat({
|
||||
conversationId: String(conversation.id),
|
||||
groupId: String(conversation.group.id),
|
||||
groupName: conversation.group.name,
|
||||
isGroupChat: true,
|
||||
}) as any
|
||||
);
|
||||
} else {
|
||||
const currentUserId = currentUser?.id;
|
||||
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
|
||||
(navigation as any).navigate('Chat', {
|
||||
conversationId: String(conversation.id),
|
||||
userId: otherUser ? String(otherUser.id) : undefined,
|
||||
userName: otherUser?.nickname || otherUser?.username,
|
||||
isGroupChat: false,
|
||||
});
|
||||
router.push(
|
||||
hrefs.hrefChat({
|
||||
conversationId: String(conversation.id),
|
||||
userId: otherUser ? String(otherUser.id) : undefined,
|
||||
isGroupChat: false,
|
||||
}) as any
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -407,9 +411,7 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
<View style={styles.header}>
|
||||
{/* 大屏幕(>= 768px)时隐藏返回按钮 */}
|
||||
{!isWideScreen ? (
|
||||
<TouchableOpacity onPress={onBack} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="arrow-left" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
<AppBackButton onPress={onBack} style={styles.headerButton} iconColor="#666" />
|
||||
) : (
|
||||
<View style={styles.headerButton} />
|
||||
)}
|
||||
|
||||
@@ -9,14 +9,16 @@ import {
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { navigationService } from '../../infrastructure/navigation/navigationService';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common';
|
||||
import { authService } from '../../services';
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { User } from '../../types';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
|
||||
export const BlockedUsersScreen: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { isMobile } = useResponsive();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
@@ -78,7 +80,7 @@ export const BlockedUsersScreen: React.FC = () => {
|
||||
<TouchableOpacity
|
||||
style={styles.item}
|
||||
activeOpacity={0.75}
|
||||
onPress={() => navigationService.navigate('UserProfile', { userId: item.id })}
|
||||
onPress={() => router.push(hrefs.hrefUserProfile(item.id))}
|
||||
>
|
||||
<Avatar source={item.avatar} size={46} name={item.nickname} />
|
||||
<View style={styles.content}>
|
||||
|
||||
@@ -16,23 +16,19 @@ import {
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { User } from '../../types';
|
||||
import { useAuthStore, useUserStore } from '../../stores';
|
||||
import { authService } from '../../services';
|
||||
import { Avatar, Text, Button, Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||
import { HomeStackParamList } from '../../navigation/types';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { useResponsive, useColumnCount } from '../../hooks';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'FollowList'>;
|
||||
type FollowListRouteProp = RouteProp<HomeStackParamList, 'FollowList'>;
|
||||
|
||||
const FollowListScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<FollowListRouteProp>();
|
||||
const { userId, type } = route.params;
|
||||
const router = useRouter();
|
||||
const { userId = '', type: typeParam } = useLocalSearchParams<{ userId?: string; type?: string }>();
|
||||
const type = typeParam === 'followers' ? 'followers' : 'following';
|
||||
|
||||
const { currentUser } = useAuthStore();
|
||||
const { followUser, unfollowUser } = useUserStore();
|
||||
@@ -135,7 +131,7 @@ const FollowListScreen: React.FC = () => {
|
||||
// 跳转到用户主页
|
||||
const handleUserPress = (targetUserId: string) => {
|
||||
if (targetUserId !== currentUser?.id) {
|
||||
navigation.push('UserProfile', { userId: targetUserId });
|
||||
router.push(hrefs.hrefUserProfile(targetUserId));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,11 +178,11 @@ const FollowListScreen: React.FC = () => {
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||
@{item.username}
|
||||
</Text>
|
||||
{item.bio && (
|
||||
{item.bio?.trim() ? (
|
||||
<Text variant="caption" color={colors.text.hint} numberOfLines={1} style={styles.bio}>
|
||||
{item.bio}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
{!isSelf && (
|
||||
<Button
|
||||
@@ -226,11 +222,11 @@ const FollowListScreen: React.FC = () => {
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||
@{item.username}
|
||||
</Text>
|
||||
{item.bio && (
|
||||
{item.bio?.trim() ? (
|
||||
<Text variant="caption" color={colors.text.hint} numberOfLines={2} style={styles.userCardBio}>
|
||||
{item.bio}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
{!isSelf && (
|
||||
<View style={styles.userCardFooter}>
|
||||
|
||||
@@ -5,17 +5,10 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import UserProfileScreen from './UserProfileScreen';
|
||||
import { ProfileStackParamList } from '../../navigation/types';
|
||||
|
||||
type ProfileNavigationProp = NativeStackNavigationProp<ProfileStackParamList, 'Profile'>;
|
||||
|
||||
export const ProfileScreen: React.FC = () => {
|
||||
const profileNavigation = useNavigation<ProfileNavigationProp>();
|
||||
|
||||
return <UserProfileScreen mode="self" profileNavigation={profileNavigation} />;
|
||||
return <UserProfileScreen mode="self" />;
|
||||
};
|
||||
|
||||
export default ProfileScreen;
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import Constants from 'expo-constants';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
@@ -22,6 +22,8 @@ import { Text, ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
|
||||
interface SettingsItem {
|
||||
key: string;
|
||||
title: string;
|
||||
@@ -65,7 +67,7 @@ const SETTINGS_GROUPS = [
|
||||
];
|
||||
|
||||
export const SettingsScreen: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const { logout } = useAuthStore();
|
||||
const { isWideScreen } = useResponsive();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -78,16 +80,16 @@ export const SettingsScreen: React.FC = () => {
|
||||
const handleItemPress = (key: string) => {
|
||||
switch (key) {
|
||||
case 'edit_profile':
|
||||
navigation.navigate('EditProfile' as never);
|
||||
router.push(hrefs.hrefProfileEdit());
|
||||
break;
|
||||
case 'notification_settings':
|
||||
navigation.navigate('NotificationSettings' as never);
|
||||
router.push(hrefs.hrefProfileNotifications());
|
||||
break;
|
||||
case 'blocked_users':
|
||||
navigation.navigate('BlockedUsers' as never);
|
||||
router.push(hrefs.hrefProfileBlocked());
|
||||
break;
|
||||
case 'security':
|
||||
navigation.navigate('AccountSecurity' as never);
|
||||
router.push(hrefs.hrefProfileSecurity());
|
||||
break;
|
||||
case 'logout':
|
||||
Alert.alert(
|
||||
@@ -98,8 +100,9 @@ export const SettingsScreen: React.FC = () => {
|
||||
{
|
||||
text: '确定',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
logout();
|
||||
onPress: async () => {
|
||||
await logout();
|
||||
router.replace(hrefs.hrefAuthLogin());
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
@@ -12,23 +12,19 @@ import {
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { colors } from '../../theme';
|
||||
import { Post } from '../../types';
|
||||
import { PostCard, TabBar, UserProfileHeader } from '../../components/business';
|
||||
import { Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import { ProfileStackParamList } from '../../navigation/types';
|
||||
import { useUserProfile, ProfileMode, TABS, TAB_ICONS, sharedStyles } from './useUserProfile';
|
||||
|
||||
interface UserProfileScreenProps {
|
||||
mode: ProfileMode;
|
||||
userId?: string; // 仅 other 模式需要
|
||||
/** 使用 NavigationProp 避免与具体 screen 的 NativeStackNavigationProp 在 setParams 上不兼容 */
|
||||
profileNavigation?: NavigationProp<ProfileStackParamList>; // 仅 self 模式需要
|
||||
}
|
||||
|
||||
export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, userId, profileNavigation }) => {
|
||||
export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, userId }) => {
|
||||
const { isDesktop, isTablet } = useResponsive();
|
||||
|
||||
const {
|
||||
@@ -51,7 +47,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
||||
handleEditProfile,
|
||||
isCurrentUser,
|
||||
currentUser,
|
||||
} = useUserProfile({ mode, userId, isDesktop, isTablet, profileNavigation });
|
||||
} = useUserProfile({ mode, userId, isDesktop, isTablet });
|
||||
|
||||
// 渲染帖子列表
|
||||
const renderPostList = useCallback((postList: Post[], emptyTitle: string, emptyDesc: string) => {
|
||||
|
||||
@@ -5,16 +5,13 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import UserProfileScreen from './UserProfileScreen';
|
||||
import { HomeStackParamList } from '../../navigation/types';
|
||||
import { useCurrentUser } from '../../stores/authStore';
|
||||
|
||||
type UserRouteProp = RouteProp<HomeStackParamList, 'UserProfile'>;
|
||||
|
||||
export const UserScreen: React.FC = () => {
|
||||
const route = useRoute<UserRouteProp>();
|
||||
const userId = route.params?.userId || '';
|
||||
const { userId: userIdParam } = useLocalSearchParams<{ userId?: string }>();
|
||||
const userId = userIdParam || '';
|
||||
const currentUser = useCurrentUser();
|
||||
const isSelfProfile = !!currentUser?.id && currentUser.id === userId;
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigation, NavigationProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { colors, spacing } from '../../theme';
|
||||
import { Post, User } from '../../types';
|
||||
@@ -13,7 +12,7 @@ import { useUserStore } from '../../stores';
|
||||
import { useCurrentUser } from '../../stores/authStore';
|
||||
import { postService, authService, messageService } from '../../services';
|
||||
import { userManager } from '../../stores/userManager';
|
||||
import { HomeStackParamList, RootStackParamList, ProfileStackParamList } from '../../navigation/types';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { PostCardAction } from '../../components/business/PostCard';
|
||||
|
||||
export type ProfileMode = 'self' | 'other';
|
||||
@@ -23,7 +22,6 @@ export interface UseUserProfileOptions {
|
||||
userId?: string; // 仅 other 模式需要
|
||||
isDesktop?: boolean;
|
||||
isTablet?: boolean;
|
||||
profileNavigation?: NavigationProp<ProfileStackParamList>; // 仅 self 模式需要
|
||||
}
|
||||
|
||||
export interface UseUserProfileReturn {
|
||||
@@ -66,11 +64,10 @@ export interface UseUserProfileReturn {
|
||||
}
|
||||
|
||||
export const useUserProfile = (options: UseUserProfileOptions): UseUserProfileReturn => {
|
||||
const { mode, userId, isDesktop = false, profileNavigation } = options;
|
||||
|
||||
const { mode, userId, isDesktop = false } = options;
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
const homeNavigation = useNavigation<NativeStackNavigationProp<HomeStackParamList>>();
|
||||
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const router = useRouter();
|
||||
|
||||
const { followUser, unfollowUser, posts: storePosts } = useUserStore();
|
||||
const currentUser = useCurrentUser();
|
||||
@@ -252,32 +249,38 @@ export const useUserProfile = (options: UseUserProfileOptions): UseUserProfileRe
|
||||
const handleFollowingPress = useCallback(() => {
|
||||
const targetUserId = mode === 'self' ? currentUser?.id : userId;
|
||||
if (targetUserId) {
|
||||
(rootNavigation as any).navigate('FollowList', { userId: targetUserId, type: 'following' });
|
||||
router.push(hrefs.hrefFollowList(targetUserId, 'following'));
|
||||
}
|
||||
}, [mode, currentUser?.id, userId, rootNavigation]);
|
||||
|
||||
}, [mode, currentUser?.id, userId, router]);
|
||||
|
||||
// 跳转到粉丝列表
|
||||
const handleFollowersPress = useCallback(() => {
|
||||
const targetUserId = mode === 'self' ? currentUser?.id : userId;
|
||||
if (targetUserId) {
|
||||
(rootNavigation as any).navigate('FollowList', { userId: targetUserId, type: 'followers' });
|
||||
router.push(hrefs.hrefFollowList(targetUserId, 'followers'));
|
||||
}
|
||||
}, [mode, currentUser?.id, userId, rootNavigation]);
|
||||
|
||||
}, [mode, currentUser?.id, userId, router]);
|
||||
|
||||
// 跳转到帖子详情
|
||||
const handlePostPress = useCallback((postId: string, scrollToComments: boolean = false) => {
|
||||
homeNavigation.navigate('PostDetail', { postId, scrollToComments });
|
||||
}, [homeNavigation]);
|
||||
|
||||
const handlePostPress = useCallback(
|
||||
(postId: string, scrollToComments: boolean = false) => {
|
||||
router.push(hrefs.hrefPostDetail(postId, scrollToComments));
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 跳转到用户主页
|
||||
const handleUserPress = useCallback((postUserId: string) => {
|
||||
if (mode === 'self') {
|
||||
return;
|
||||
}
|
||||
if (postUserId !== userId) {
|
||||
homeNavigation.push('UserProfile', { userId: postUserId });
|
||||
}
|
||||
}, [mode, userId, homeNavigation]);
|
||||
const handleUserPress = useCallback(
|
||||
(postUserId: string) => {
|
||||
if (mode === 'self') {
|
||||
return;
|
||||
}
|
||||
if (postUserId !== userId) {
|
||||
router.push(hrefs.hrefUserProfile(postUserId));
|
||||
}
|
||||
},
|
||||
[mode, userId, router]
|
||||
);
|
||||
|
||||
// 删除帖子
|
||||
const handleDeletePost = useCallback(async (postId: string) => {
|
||||
@@ -332,15 +335,17 @@ export const useUserProfile = (options: UseUserProfileOptions): UseUserProfileRe
|
||||
try {
|
||||
const conversation = await messageService.createConversation(user.id);
|
||||
if (conversation) {
|
||||
(rootNavigation as any).navigate('Chat', {
|
||||
conversationId: conversation.id.toString(),
|
||||
userId: user.id
|
||||
});
|
||||
router.push(
|
||||
hrefs.hrefChat({
|
||||
conversationId: conversation.id.toString(),
|
||||
userId: user.id,
|
||||
}) as any
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建会话失败:', error);
|
||||
}
|
||||
}, [user, rootNavigation]);
|
||||
}, [user, router]);
|
||||
|
||||
// 更多操作(仅 other 模式)
|
||||
const handleMore = useCallback(() => {
|
||||
@@ -393,17 +398,13 @@ export const useUserProfile = (options: UseUserProfileOptions): UseUserProfileRe
|
||||
|
||||
// 设置页跳转(仅 self 模式)
|
||||
const handleSettings = useCallback(() => {
|
||||
if (profileNavigation) {
|
||||
profileNavigation.navigate('Settings');
|
||||
}
|
||||
}, [profileNavigation]);
|
||||
|
||||
router.push(hrefs.hrefProfileSettings());
|
||||
}, [router]);
|
||||
|
||||
// 编辑资料页跳转(仅 self 模式)
|
||||
const handleEditProfile = useCallback(() => {
|
||||
if (profileNavigation) {
|
||||
profileNavigation.navigate('EditProfile');
|
||||
}
|
||||
}, [profileNavigation]);
|
||||
router.push(hrefs.hrefProfileEdit());
|
||||
}, [router]);
|
||||
|
||||
// 计算滚动底部间距
|
||||
const scrollBottomInset = useMemo(() => {
|
||||
@@ -435,8 +436,8 @@ export const useUserProfile = (options: UseUserProfileOptions): UseUserProfileRe
|
||||
handlePostAction,
|
||||
handleMessage: mode === 'other' ? handleMessage : undefined,
|
||||
handleMore: mode === 'other' ? handleMore : undefined,
|
||||
handleSettings: mode === 'self' && profileNavigation ? handleSettings : undefined,
|
||||
handleEditProfile: mode === 'self' && profileNavigation ? handleEditProfile : undefined,
|
||||
handleSettings: mode === 'self' ? handleSettings : undefined,
|
||||
handleEditProfile: mode === 'self' ? handleEditProfile : undefined,
|
||||
isCurrentUser: mode === 'self',
|
||||
currentUser,
|
||||
};
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Pressable } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import type { ScheduleStackParamList } from '../../navigation/types';
|
||||
import { colors, spacing, borderRadius, fontSizes, shadows } from '../../theme';
|
||||
import { routePayloadCache } from '../../stores/routePayloadCache';
|
||||
import { scheduleService } from '../../services/scheduleService';
|
||||
|
||||
type CourseDetailRouteProp = RouteProp<ScheduleStackParamList, 'CourseDetail'>;
|
||||
|
||||
const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
const getMergedSectionIndex = (section: number) => Math.ceil(section / 2);
|
||||
|
||||
@@ -36,13 +33,17 @@ const formatWeekRanges = (weeks: number[]) => {
|
||||
};
|
||||
|
||||
const CourseDetailScreen: React.FC = () => {
|
||||
const route = useRoute<CourseDetailRouteProp>();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>();
|
||||
const { course, relatedCourses } = route.params;
|
||||
const router = useRouter();
|
||||
const { courseId } = useLocalSearchParams<{ courseId?: string }>();
|
||||
const cached = courseId ? routePayloadCache.getCourseDetail(courseId) : undefined;
|
||||
const course = cached?.course;
|
||||
const relatedCourses = cached?.relatedCourses ?? [];
|
||||
|
||||
const timeLines = relatedCourses.length > 0 ? relatedCourses : [course];
|
||||
const timeLines =
|
||||
relatedCourses.length > 0 ? relatedCourses : course ? [course] : [];
|
||||
|
||||
const handleDeleteCourse = () => {
|
||||
if (!course) return;
|
||||
Alert.alert('删除课程', '确认删除这条课程安排吗?', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
@@ -52,7 +53,7 @@ const CourseDetailScreen: React.FC = () => {
|
||||
try {
|
||||
await scheduleService.deleteCourse(course.id);
|
||||
Alert.alert('删除成功', '课程已删除');
|
||||
navigation.goBack();
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error('删除课程失败:', error);
|
||||
Alert.alert('删除失败', '删除课程失败,请稍后重试');
|
||||
@@ -62,10 +63,20 @@ const CourseDetailScreen: React.FC = () => {
|
||||
]);
|
||||
};
|
||||
|
||||
if (!course) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.mask}>
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => router.back()} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.mask}>
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => navigation.goBack()} />
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => router.back()} />
|
||||
<View style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
@@ -74,7 +85,7 @@ const CourseDetailScreen: React.FC = () => {
|
||||
</View>
|
||||
<Text style={styles.title}>课程详情</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.closeButton} hitSlop={10}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.closeButton} hitSlop={10}>
|
||||
<MaterialCommunityIcons name="close" size={18} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -21,8 +21,8 @@ import { PanGestureHandler, State } from 'react-native-gesture-handler';
|
||||
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { colors, fontSizes, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import {
|
||||
@@ -32,7 +32,8 @@ import {
|
||||
getCourseColor,
|
||||
hasCourseInWeek,
|
||||
} from '../../types/schedule';
|
||||
import type { ScheduleStackParamList } from '../../navigation/types';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { routePayloadCache } from '../../stores/routePayloadCache';
|
||||
import { scheduleService } from '../../services/scheduleService';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
@@ -147,7 +148,7 @@ const getWeekDates = (weekOffset: number = 0) => {
|
||||
const INITIAL_WEEK = getCurrentWeekNumber();
|
||||
|
||||
export const ScheduleScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>();
|
||||
const router = useRouter();
|
||||
// 使用响应式 hook 检测屏幕尺寸
|
||||
const { width: screenWidth, height: screenHeight, isMobile, platform } = useResponsive();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -616,12 +617,11 @@ export const ScheduleScreen: React.FC = () => {
|
||||
width: cardWidth,
|
||||
},
|
||||
]}
|
||||
onPress={() =>
|
||||
navigation.navigate('CourseDetail', {
|
||||
course,
|
||||
relatedCourses: getRelatedCourses(course),
|
||||
})
|
||||
}
|
||||
onPress={() => {
|
||||
const related = getRelatedCourses(course);
|
||||
routePayloadCache.stashCourseDetail(course, related);
|
||||
router.push(hrefs.hrefScheduleCourse(String(course.id)));
|
||||
}}
|
||||
onLongPress={() => handleCourseLongPress(course)}
|
||||
delayLongPress={500}
|
||||
activeOpacity={0.82}
|
||||
|
||||
Reference in New Issue
Block a user