From 357c1d4995b315db2e19916b488096dd17c5ec1c Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 24 Mar 2026 04:23:13 +0800 Subject: [PATCH] refactor(PostCard, PostCard.legacy): remove legacy PostCard component and update exports - Deleted the legacy PostCard component to streamline the codebase and improve maintainability. - Updated exports in PostCard and index files to remove references to the legacy component. - Adjusted PostCardProps to eliminate legacy properties, ensuring only the new API is supported. - Enhanced the PostCard component to focus on modern implementation and features. --- src/components/business/PostCard.legacy.tsx | 996 ------------------ src/components/business/PostCard/PostCard.tsx | 554 +++++++++- src/components/business/PostCard/index.ts | 30 +- src/components/business/PostCard/types.ts | 23 - src/components/business/index.ts | 3 - src/components/common/ImageGallery.tsx | 36 +- src/core/entities/Post.ts | 4 + src/data/repositories/PostRepository.ts | 28 +- src/hooks/usePrefetch.ts | 5 +- src/screens/home/HomeScreen.tsx | 20 +- src/screens/home/PostDetailScreen.tsx | 29 +- src/screens/home/SearchScreen.tsx | 56 +- src/screens/message/ChatScreen.tsx | 62 +- .../components/ChatScreen/MessageBubble.tsx | 4 +- .../message/components/ChatScreen/types.ts | 2 + .../components/ChatScreen/useChatScreen.ts | 31 +- src/screens/profile/ProfileScreen.tsx | 520 +-------- src/screens/profile/UserProfileScreen.tsx | 235 +++++ src/screens/profile/UserScreen.tsx | 571 +--------- src/screens/profile/index.ts | 8 + src/screens/profile/useUserProfile.ts | 504 +++++++++ src/services/sseService.ts | 71 +- src/stores/messageManager.ts | 98 +- src/stores/messageManagerHooks.ts | 15 +- 24 files changed, 1662 insertions(+), 2243 deletions(-) delete mode 100644 src/components/business/PostCard.legacy.tsx create mode 100644 src/screens/profile/UserProfileScreen.tsx create mode 100644 src/screens/profile/useUserProfile.ts diff --git a/src/components/business/PostCard.legacy.tsx b/src/components/business/PostCard.legacy.tsx deleted file mode 100644 index 7f1e39c..0000000 --- a/src/components/business/PostCard.legacy.tsx +++ /dev/null @@ -1,996 +0,0 @@ -/** - * PostCard 帖子卡片组件 - QQ频道风格(响应式版本) - * 简洁的帖子展示,无卡片边框 - * 支持响应式布局,宽屏下优化显示 - */ - -import React, { useState, useCallback, useMemo } from 'react'; -import { - View, - TouchableOpacity, - StyleSheet, - Alert, - useWindowDimensions, -} from 'react-native'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; -import { Post } from '../../types'; -import Text from '../common/Text'; -import Avatar from '../common/Avatar'; -import { ImageGrid, ImageGridItem, SmartImage } from '../common'; -import VotePreview from './VotePreview'; -import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive'; -import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper'; - -interface PostCardProps { - post: Post; - onPress: () => void; - onUserPress: () => void; - onLike: () => void; - onComment: () => void; - onBookmark: () => void; - onShare: () => void; - onImagePress?: (images: ImageGridItem[], index: number) => void; - onDelete?: () => void; - compact?: boolean; - index?: number; - isAuthor?: boolean; - isPostAuthor?: boolean; // 当前用户是否为帖子作者 - variant?: 'default' | 'grid'; -} - -const PostCard: React.FC = ({ - post, - onPress, - onUserPress, - onLike, - onComment, - onBookmark, - onShare, - onImagePress, - onDelete, - compact = false, - index, - isAuthor = false, - isPostAuthor = false, - variant = 'default', -}) => { - const [isExpanded, setIsExpanded] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - // 使用响应式 hook - const { - width, - isMobile, - isTablet, - isDesktop, - isWideScreen, - isLandscape, - orientation - } = useResponsive(); - - const { width: windowWidth } = useWindowDimensions(); - - // 响应式字体大小 - const responsiveFontSize = useResponsiveValue({ - xs: fontSizes.md, - sm: fontSizes.md, - md: fontSizes.lg, - lg: isLandscape ? fontSizes.lg : fontSizes.xl, - xl: fontSizes.xl, - '2xl': fontSizes.xl, - '3xl': fontSizes['2xl'], - '4xl': fontSizes['2xl'], - }); - - // 响应式标题字体大小 - const responsiveTitleFontSize = useResponsiveValue({ - xs: fontSizes.lg, - sm: fontSizes.lg, - md: fontSizes.xl, - lg: isLandscape ? fontSizes.xl : fontSizes['2xl'], - xl: fontSizes['2xl'], - '2xl': fontSizes['2xl'], - '3xl': fontSizes['3xl'], - '4xl': fontSizes['3xl'], - }); - - // 响应式头像大小 - const avatarSize = useResponsiveValue({ - xs: 36, - sm: 38, - md: 40, - lg: 44, - xl: 48, - '2xl': 48, - '3xl': 52, - '4xl': 56, - }); - - // 响应式内边距 - 宽屏下增加更多内边距 - const responsivePadding = useResponsiveValue({ - xs: spacing.md, - sm: spacing.md, - md: spacing.lg, - lg: spacing.xl, - xl: spacing.xl * 1.2, - '2xl': spacing.xl * 1.5, - '3xl': spacing.xl * 2, - '4xl': spacing.xl * 2.5, - }); - - // 宽屏下额外的卡片内边距 - const cardPadding = useMemo(() => { - if (isWideScreen) return spacing.xl; - if (isDesktop) return spacing.lg; - if (isTablet) return spacing.md; - return 0; // 移动端无额外内边距 - }, [isWideScreen, isDesktop, isTablet]); - - const formatDateTime = (dateString?: string | null): string => { - if (!dateString) return ''; - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) return ''; - const pad = (num: number) => String(num).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; - }; - - /** 返回应展示的「最后编辑」时间字符串;优先 content_edited_at(与点赞/评论等统计更新解耦) */ - const getPostEditedDisplayAt = ( - createdAt?: string | null, - contentEditedAt?: string | null, - updatedAt?: string | null, - ): string | null => { - if (contentEditedAt) { - const c = new Date(createdAt || '').getTime(); - const e = new Date(contentEditedAt).getTime(); - if (!Number.isNaN(c) && !Number.isNaN(e) && e - c > 1000) return contentEditedAt; - return null; - } - if (createdAt && updatedAt) { - const c = new Date(createdAt).getTime(); - const u = new Date(updatedAt).getTime(); - if (!Number.isNaN(c) && !Number.isNaN(u) && u - c > 1000) return updatedAt; - } - return null; - }; - - const getTruncatedContent = (content: string | undefined | null, maxLength: number = 100): string => { - if (!content) return ''; - if (content.length <= maxLength || isExpanded) return content; - return content.substring(0, maxLength) + '...'; - }; - - // 宽屏下显示更多内容 - const getResponsiveMaxLength = () => { - if (isWideScreen) return 300; - if (isDesktop) return 250; - if (isTablet) return 200; - return 100; - }; - - const formatNumber = (num: number): string => { - if (num >= 10000) { - return (num / 10000).toFixed(1) + 'w'; - } - if (num >= 1000) { - return (num / 1000).toFixed(1) + 'k'; - } - return num.toString(); - }; - - // 处理删除帖子 - const handleDelete = () => { - if (!onDelete || isDeleting) return; - - Alert.alert( - '删除帖子', - '确定要删除这篇帖子吗?删除后将无法恢复。', - [ - { - text: '取消', - style: 'cancel', - }, - { - text: '删除', - style: 'destructive', - onPress: async () => { - setIsDeleting(true); - try { - await onDelete(); - } catch (error) { - console.error('删除帖子失败:', error); - Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试'); - } finally { - setIsDeleting(false); - } - }, - }, - ], - ); - }; - - // 渲染图片 - 使用新的 ImageGrid 组件 - const renderImages = () => { - if (!post.images || !Array.isArray(post.images) || post.images.length === 0) return null; - - // 转换图片数据格式 - const gridImages: ImageGridItem[] = post.images.map(img => ({ - id: img.id, - url: img.url, - thumbnail_url: img.thumbnail_url, - preview_url: img.preview_url, - preview_url_large: img.preview_url_large, - width: img.width, - height: img.height, - })); - - // 宽屏下显示更大的图片 - const imageGap = isDesktop ? 12 : isTablet ? 8 : 4; - const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.md; - - return ( - - - - ); - }; - - const renderBadges = () => { - const badges = []; - - if (isAuthor) { - badges.push( - - 楼主 - - ); - } - - if (post.author?.id === '1') { - badges.push( - - 管理员 - - ); - } - - return badges; - }; - - const renderTopComment = () => { - if (!post.top_comment) return null; - - const { top_comment } = post; - - const topCommentContainerStyles = [ - styles.topCommentContainer, - ...(isDesktop ? [styles.topCommentContainerWide] : []) - ]; - - const topCommentAuthorStyles = [ - styles.topCommentAuthor, - ...(isDesktop ? [styles.topCommentAuthorWide] : []) - ]; - - const topCommentTextStyles = [ - styles.topCommentText, - ...(isDesktop ? [styles.topCommentTextWide] : []) - ]; - - const moreCommentsStyles = [ - styles.moreComments, - ...(isDesktop ? [styles.moreCommentsWide] : []) - ]; - - return ( - - - - {top_comment.author?.nickname || '匿名用户'}: - - - {top_comment.content} - - - {post.comments_count > 1 && ( - - 查看全部{formatNumber(post.comments_count)}条评论 - - )} - - ); - }; - - // 渲染投票预览 - const renderVotePreview = () => { - if (!post.is_vote) return null; - - return ( - - ); - }; - - // 渲染小红书风格的两栏卡片 - const renderGridVariant = () => { - // 获取封面图(第一张图) - const coverImage = post.images && Array.isArray(post.images) && post.images.length > 0 ? post.images[0] : null; - const hasImage = !!coverImage; - - // 防御性检查:确保 author 存在 - const author = post.author || { id: '', nickname: '匿名用户', avatar: null, username: '' }; - - // 根据图片实际宽高比或使用随机值创建错落感 - // 使用帖子 ID 生成一个伪随机值,确保每次渲染一致但不同帖子有差异 - const postId = post.id || ''; - const hash = postId.split('').reduce((a, b) => a + b.charCodeAt(0), 0); - const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2]; - const aspectRatio = aspectRatios[hash % aspectRatios.length]; - - // 获取封面图预览 URL - const coverUrl = hasImage - ? getPreviewImageUrl(coverImage, 'grid') - : ''; - - // 获取正文预览(无图时显示) - const getContentPreview = (content: string | undefined | null): string => { - if (!content) return ''; - // 移除 Markdown 标记和多余空白 - return content - .replace(/[#*_~`\[\]()]/g, '') - .replace(/\n+/g, ' ') - .trim(); - }; - - const contentPreview = getContentPreview(post.content); - - // 响应式字体大小(网格模式) - const gridTitleFontSize = isDesktop ? 16 : isTablet ? 15 : 14; - const gridUsernameFontSize = isDesktop ? 14 : 12; - const gridLikeFontSize = isDesktop ? 14 : 12; - - const handleContainerPress = () => { - onPress(); - }; - - const handleImagePress = (e: any) => { - e.stopPropagation(); - onImagePress?.(post.images || [], 0); - }; - - const handleUserPress = (e: any) => { - e.stopPropagation(); - onUserPress(); - }; - - return ( - - {/* 封面图 - 只有有图片时才渲染,无图时不显示占位区域 */} - {hasImage && ( - - - - )} - - {/* 无图时的正文预览区域 */} - {!hasImage && contentPreview && ( - - - {contentPreview} - - - )} - - {/* 投票标识 */} - {post.is_vote && ( - - - 投票 - - )} - - {/* 标题 - 无图时显示更多行 */} - {post.title && ( - - {post.title} - - )} - - {/* 底部信息 */} - - - - - {author.nickname} - - - - - - - {formatNumber(post.likes_count)} - - - - - ); - }; - - // 根据 variant 渲染不同样式 - if (variant === 'grid') { - return renderGridVariant(); - } - - // 防御性检查:确保 author 存在 - const author = post.author || { id: '', nickname: '匿名用户', avatar: null, username: '' }; - - // 计算响应式内容最大行数 - const contentNumberOfLines = useMemo(() => { - if (isWideScreen) return 8; - if (isDesktop) return 6; - if (isTablet) return 5; - return 3; - }, [isWideScreen, isDesktop, isTablet]); - - const handleContainerPress = () => { - onPress(); - }; - - const handleUserPress = (e: any) => { - e.stopPropagation(); - onUserPress(); - }; - - const handleLikePress = (e: any) => { - e.stopPropagation(); - onLike(); - }; - - const handleCommentPress = (e: any) => { - e.stopPropagation(); - onComment(); - }; - - const handleSharePress = (e: any) => { - e.stopPropagation(); - onShare(); - }; - - const handleBookmarkPress = (e: any) => { - e.stopPropagation(); - onBookmark(); - }; - - const handleDeletePress = (e: any) => { - e.stopPropagation(); - handleDelete(); - }; - - return ( - - {/* 用户信息 */} - - - - - - - - - {author.nickname} - - - {renderBadges()} - - - - 发布 {formatDateTime(post.created_at)} - - {(() => { - const editedAt = getPostEditedDisplayAt( - post.created_at, - post.content_edited_at, - post.updated_at, - ); - return editedAt ? ( - - {' · 修改 '}{formatDateTime(editedAt)} - - ) : null; - })()} - - - {post.is_pinned && ( - - - 置顶 - - )} - {/* 删除按钮 - 只对帖子作者显示 */} - {isPostAuthor && onDelete && ( - - - - )} - - - {/* 标题 */} - {post.title && ( - - {post.title} - - )} - - {/* 内容 */} - {!compact && ( - <> - - {getTruncatedContent(post.content, getResponsiveMaxLength())} - - {post.content && post.content.length > getResponsiveMaxLength() && ( - setIsExpanded(!isExpanded)} - style={styles.expandButton} - > - - {isExpanded ? '收起' : '展开全文'} - - - )} - - )} - - {/* 图片 */} - {renderImages()} - - {/* 投票预览 */} - {renderVotePreview()} - - {/* 热门评论预览 */} - {renderTopComment()} - - {/* 交互按钮 */} - - - - - {formatNumber(post.views_count || 0)} - - - - - - - - {post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'} - - - - - - - {post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'} - - - - - - - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - backgroundColor: colors.background.paper, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.divider, - }, - // 宽屏卡片样式 - wideCard: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.08, - shadowRadius: 8, - elevation: 3, - }, - userSection: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.sm, - }, - userInfo: { - flex: 1, - marginLeft: spacing.sm, - }, - userNameRow: { - flexDirection: 'row', - alignItems: 'center', - flexWrap: 'wrap', - }, - username: { - fontWeight: '600', - color: colors.text.primary, - }, - badge: { - paddingHorizontal: 4, - paddingVertical: 1, - borderRadius: 2, - marginLeft: spacing.xs, - }, - authorBadge: { - backgroundColor: colors.primary.main, - }, - adminBadge: { - backgroundColor: colors.error.main, - }, - badgeText: { - color: colors.text.inverse, - fontSize: fontSizes.xs, - fontWeight: '600', - }, - postMeta: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 2, - }, - timeText: { - fontSize: fontSizes.sm, - color: colors.text.hint, - }, - pinnedTag: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.warning.light + '20', - paddingHorizontal: spacing.xs, - paddingVertical: 2, - borderRadius: borderRadius.sm, - }, - pinnedText: { - marginLeft: 2, - fontSize: fontSizes.xs, - fontWeight: '500', - }, - deleteButton: { - padding: spacing.xs, - marginLeft: spacing.sm, - }, - title: { - marginBottom: spacing.xs, - fontWeight: '600', - color: colors.text.primary, - }, - content: { - marginBottom: spacing.xs, - color: colors.text.secondary, - }, - expandButton: { - marginBottom: spacing.sm, - }, - imagesContainer: { - marginBottom: spacing.md, - }, - topCommentContainer: { - backgroundColor: colors.background.default, - borderRadius: borderRadius.sm, - padding: spacing.sm, - marginBottom: spacing.sm, - }, - topCommentContainerWide: { - padding: spacing.md, - borderRadius: borderRadius.md, - }, - topCommentContent: { - flexDirection: 'row', - alignItems: 'center', - }, - topCommentAuthor: { - fontWeight: '600', - marginRight: spacing.xs, - }, - topCommentAuthorWide: { - fontSize: fontSizes.sm, - }, - topCommentText: { - flex: 1, - }, - topCommentTextWide: { - fontSize: fontSizes.sm, - }, - moreComments: { - marginTop: spacing.xs, - fontWeight: '500', - }, - moreCommentsWide: { - fontSize: fontSizes.sm, - marginTop: spacing.sm, - }, - actionBar: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - actionBarWide: { - marginTop: spacing.sm, - paddingTop: spacing.sm, - }, - viewCount: { - flexDirection: 'row', - alignItems: 'center', - }, - actionButtons: { - flexDirection: 'row', - alignItems: 'center', - }, - actionButton: { - flexDirection: 'row', - alignItems: 'center', - marginLeft: spacing.md, - paddingVertical: spacing.xs, - }, - actionButtonWide: { - marginLeft: spacing.lg, - paddingVertical: spacing.sm, - }, - actionText: { - marginLeft: 6, - }, - actionTextWide: { - fontSize: fontSizes.md, - }, - - // ========== 小红书风格两栏样式 ========== - gridContainer: { - backgroundColor: '#FFF', - overflow: 'hidden', - borderRadius: 8, - }, - gridContainerDesktop: { - borderRadius: 12, - }, - gridContainerNoImage: { - minHeight: 200, - justifyContent: 'space-between', - }, - gridCoverImage: { - width: '100%', - aspectRatio: 0.7, - backgroundColor: '#F5F5F5', - }, - gridCoverPlaceholder: { - width: '100%', - aspectRatio: 0.7, - backgroundColor: '#F5F5F5', - alignItems: 'center', - justifyContent: 'center', - }, - // 无图时的正文预览区域 - gridContentPreview: { - backgroundColor: '#F8F8F8', - margin: 4, - padding: 8, - borderRadius: 8, - minHeight: 100, - }, - gridContentPreviewDesktop: { - margin: 8, - padding: 12, - borderRadius: 12, - minHeight: 150, - }, - gridContentText: { - fontSize: 13, - color: '#666', - lineHeight: 20, - }, - gridContentTextDesktop: { - fontSize: 15, - lineHeight: 24, - }, - gridTitle: { - color: '#333', - lineHeight: 20, - paddingHorizontal: 4, - paddingTop: 8, - minHeight: 44, - }, - gridTitleNoImage: { - minHeight: 0, - paddingTop: 4, - }, - gridTitleDesktop: { - paddingHorizontal: 8, - paddingTop: 12, - lineHeight: 24, - minHeight: 56, - }, - gridFooter: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 4, - paddingVertical: 8, - }, - gridFooterDesktop: { - paddingHorizontal: 8, - paddingVertical: 12, - }, - gridUserInfo: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - }, - gridUsername: { - color: '#666', - marginLeft: 6, - flex: 1, - }, - gridLikeInfo: { - flexDirection: 'row', - alignItems: 'center', - }, - gridLikeCount: { - color: '#666', - marginLeft: 4, - }, - gridVoteBadge: { - position: 'absolute', - top: 8, - right: 8, - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.primary.main + 'CC', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: borderRadius.full, - gap: 4, - }, - gridVoteBadgeText: { - fontSize: 10, - color: colors.primary.contrast, - fontWeight: '600', - }, -}); - -export default PostCard; diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index 6cc61f9..47254be 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -1,6 +1,6 @@ /** * PostCard 主组件 - * 帖子卡片组件 - 支持列表模式和网格模式 + * 帖子卡片组件(新实现) * * @example * // 新 API 使用方式 @@ -12,63 +12,543 @@ * /> */ -import React from 'react'; -import { PostCardProps, LegacyPostCardProps } from './types'; -import PostCardList from './PostCardList'; -import PostCardGrid from './PostCardGrid'; -import { usePostCardFeatures } from './hooks/usePostCardFeatures'; -import { createLegacyAdapter, isLegacyProps } from './legacyAdapter'; +import React, { useMemo, useState } from 'react'; +import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { PostCardProps, PostCardAction } from './types'; +import { ImageGridItem, SmartImage } from '../../common'; +import Text from '../../common/Text'; +import Avatar from '../../common/Avatar'; +import { colors, spacing, borderRadius, fontSizes } from '../../../theme'; +import { getPreviewImageUrl } from '../../../utils/imageHelper'; +import PostImages from './components/PostImages'; +import { useResponsive } from '../../../hooks/useResponsive'; /** * PostCard 主组件 - * 支持新旧两种 API + * 仅支持新 API */ -const PostCard: React.FC = (props) => { - // 检测是否使用旧 API - const isLegacy = isLegacyProps(props as LegacyPostCardProps); - - // 兼容层:转换旧 Props 到新 Props - const normalizedProps: PostCardProps = isLegacy - ? createLegacyAdapter(props as LegacyPostCardProps) - : (props as PostCardProps); - +const PostCard: React.FC = (normalizedProps) => { const { post, onAction, variant = 'list', - features: featuresConfig, + features, isPostAuthor = false, isAuthor = false, index, style, } = normalizedProps; - // 解析 Features 配置 - const resolvedFeatures = usePostCardFeatures(featuresConfig); + const [isExpanded, setIsExpanded] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const isCompact = + features === 'compact' || + (typeof features === 'object' && features?.showContent === false); + + const emit = (action: PostCardAction) => { + onAction(action); + }; + + const handleImagePress = (images: ImageGridItem[], imageIndex: number) => { + emit({ type: 'imagePress', payload: { images, imageIndex } }); + }; + + const showGrid = variant === 'grid'; + const author = post.author; + const rawContent = post.content ?? ''; + const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive(); + + const handleCardPress = () => emit({ type: 'press' }); + const handleUserPress = () => emit({ type: 'userPress' }); + const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' }); + const handleComment = () => emit({ type: 'comment' }); + const handleBookmark = () => emit({ type: post.is_favorited ? 'unbookmark' : 'bookmark' }); + const handleShare = () => emit({ type: 'share' }); + const handleDelete = () => { + if (isDeleting) return; + Alert.alert( + '删除帖子', + '确定要删除这篇帖子吗?删除后将无法恢复。', + [ + { text: '取消', style: 'cancel' }, + { + text: '删除', + style: 'destructive', + onPress: async () => { + setIsDeleting(true); + try { + emit({ type: 'delete' }); + } finally { + setIsDeleting(false); + } + }, + }, + ], + ); + }; + + const images: ImageGridItem[] = Array.isArray(post.images) + ? post.images.map((img) => ({ + id: img.id, + url: img.url, + thumbnail_url: img.thumbnail_url, + preview_url: img.preview_url, + preview_url_large: img.preview_url_large, + width: img.width, + height: img.height, + })) + : []; + + const formatNumber = (num: number): string => { + if (num >= 10000) return `${(num / 10000).toFixed(1)}w`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}k`; + return String(num); + }; + + const getResponsiveMaxLength = () => { + if (isWideScreen) return 300; + if (isDesktop) return 250; + if (isTablet) return 200; + return 100; + }; + + const contentNumberOfLines = useMemo(() => { + if (isWideScreen) return 8; + if (isDesktop) return 6; + if (isTablet) return 5; + return 3; + }, [isWideScreen, isDesktop, isTablet]); + + const getTruncatedContent = (value: string): string => { + const maxLength = getResponsiveMaxLength(); + if (value.length <= maxLength || isExpanded) return value; + return `${value.substring(0, maxLength)}...`; + }; + + const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength(); + const content = useMemo( + () => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)), + [rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet] + ); + + const renderImages = () => { + if (images.length === 0) return null; + + if (showGrid) { + const cover = images[0]; + const coverUrl = getPreviewImageUrl(cover as any, 'grid'); + return ( + handleImagePress(images, 0)}> + + + ); + } - // 根据 variant 选择渲染模式 - if (variant === 'grid') { return ( - ); - } + }; + + const renderTopComment = () => { + if (showGrid || !post.top_comment) return null; + return ( + + + {post.top_comment.author?.nickname || '匿名用户'}: + + + {post.top_comment.content} + + + ); + }; + + const renderGridCard = () => { + const cover = images[0]; + const hasImage = !!cover; + const coverUrl = hasImage ? getPreviewImageUrl(cover as any, 'grid') : ''; + const contentPreview = rawContent + .replace(/[#*_~`\[\]()]/g, '') + .replace(/\n+/g, ' ') + .trim(); + + return ( + + {hasImage ? ( + handleImagePress(images, 0)}> + + + ) : ( + + + {contentPreview} + + + )} + + {post.is_vote && ( + + + 投票 + + )} + + {!!post.title && ( + + {post.title} + + )} + + + + + {author?.nickname || '匿名用户'} + + + + {formatNumber(post.likes_count || 0)} + + + + ); + }; return ( - + showGrid ? renderGridCard() : ( + + + + + + {author?.nickname || '匿名用户'} + {!showGrid && ( + + + {post.created_at ? new Date(post.created_at).toLocaleDateString() : ''} + + {post.is_pinned && ( + + + 置顶 + + )} + {isAuthor && 楼主} + + )} + + + {isPostAuthor && ( + + + + )} + + + {!!post.title && ( + + {post.title} + + )} + + {!isCompact && !!content && ( + <> + + {content} + + {shouldTruncate && ( + setIsExpanded((prev) => !prev)} style={styles.expandBtn}> + {isExpanded ? '收起' : '展开全文'} + + )} + + )} + + {renderImages()} + {renderTopComment()} + + + + + {post.views_count || 0} + + + + + + {post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'} + + + + + {post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'} + + + + + + + + + + + ) ); }; +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + padding: spacing.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm, + }, + authorWrap: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + minWidth: 0, + }, + authorMeta: { + marginLeft: spacing.sm, + flex: 1, + minWidth: 0, + }, + authorName: { + color: colors.text.primary, + fontWeight: '600', + fontSize: fontSizes.md, + }, + timeText: { + color: colors.text.hint, + fontSize: fontSizes.xs, + marginTop: 2, + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginTop: 2, + }, + pinnedTag: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: `${colors.warning.light}22`, + borderRadius: borderRadius.sm, + paddingHorizontal: 4, + paddingVertical: 1, + gap: 2, + }, + pinnedText: { + color: colors.warning.main, + fontSize: fontSizes.xs, + }, + badgeText: { + color: colors.primary.main, + fontSize: fontSizes.xs, + fontWeight: '600', + }, + deleteButton: { + padding: spacing.xs, + marginLeft: spacing.sm, + }, + title: { + color: colors.text.primary, + fontSize: fontSizes.lg, + fontWeight: '600', + marginBottom: spacing.xs, + }, + content: { + color: colors.text.secondary, + fontSize: fontSizes.md, + marginBottom: spacing.xs, + lineHeight: fontSizes.md * 1.45, + }, + expandBtn: { + marginBottom: spacing.sm, + }, + expandText: { + color: colors.primary.main, + fontSize: fontSizes.sm, + }, + topCommentBox: { + backgroundColor: colors.background.default, + borderRadius: borderRadius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + marginBottom: spacing.sm, + flexDirection: 'row', + alignItems: 'center', + }, + topCommentAuthor: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + fontWeight: '600', + marginRight: 4, + }, + topCommentText: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + flex: 1, + }, + gridCover: { + width: '100%', + aspectRatio: 0.78, + backgroundColor: colors.background.default, + }, + actions: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + viewsWrap: { + flexDirection: 'row', + alignItems: 'center', + }, + viewsText: { + color: colors.text.hint, + fontSize: fontSizes.sm, + marginLeft: 4, + }, + actionButtons: { + flexDirection: 'row', + alignItems: 'center', + }, + actionBtn: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: spacing.md, + }, + actionText: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + marginLeft: 4, + }, + activeActionText: { + color: colors.error.main, + }, + activeActionTextMerged: { + color: colors.error.main, + fontSize: fontSizes.sm, + marginLeft: 4, + }, + gridRootCard: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + overflow: 'hidden', + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, + }, + gridNoImagePreview: { + backgroundColor: colors.background.default, + margin: 6, + borderRadius: borderRadius.md, + minHeight: 120, + padding: 8, + }, + gridNoImageText: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + lineHeight: 20, + }, + gridVoteTag: { + position: 'absolute', + top: 8, + right: 8, + backgroundColor: `${colors.primary.main}CC`, + borderRadius: borderRadius.full, + paddingHorizontal: 8, + paddingVertical: 4, + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + gridVoteTagText: { + color: colors.primary.contrast, + fontSize: 10, + fontWeight: '600', + }, + gridTitleMain: { + color: colors.text.primary, + fontSize: fontSizes.md, + lineHeight: 20, + paddingHorizontal: 8, + paddingTop: 8, + minHeight: 44, + }, + gridFooter: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 8, + paddingVertical: 10, + }, + gridUserArea: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + minWidth: 0, + }, + gridUsername: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + marginLeft: 6, + flex: 1, + }, + gridLikeArea: { + flexDirection: 'row', + alignItems: 'center', + }, + gridLikeCount: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + marginLeft: 4, + }, +}); + export default PostCard; \ No newline at end of file diff --git a/src/components/business/PostCard/index.ts b/src/components/business/PostCard/index.ts index b85d14e..0e8c2b6 100644 --- a/src/components/business/PostCard/index.ts +++ b/src/components/business/PostCard/index.ts @@ -26,33 +26,5 @@ export type { PostGridInfoProps, PostCardListProps, PostCardGridProps, - LegacyPostCardProps, Badge, -} from './types'; - -// 预设配置导出 -export { PostCardPresets, defaultFeatures, resolveFeatures } from './presets'; - -// Hooks 导出 -export { - usePostCardActions, - usePostCardFeatures, - usePostCardStyles, - usePostGridStyles, -} from './hooks'; -export type { PostCardStylesConfig, PostGridStylesConfig } from './hooks'; - -// 子组件导出 -export { - PostHeader, - PostTitle, - PostContent, - PostImages, - PostActions, - PostTopComment, - PostGridCover, - PostGridInfo, -} from './components'; - -// 兼容层导出(用于迁移帮助) -export { isLegacyProps, createLegacyAdapter } from './legacyAdapter'; \ No newline at end of file +} from './types'; \ No newline at end of file diff --git a/src/components/business/PostCard/types.ts b/src/components/business/PostCard/types.ts index 510edb0..5585ced 100644 --- a/src/components/business/PostCard/types.ts +++ b/src/components/business/PostCard/types.ts @@ -251,29 +251,6 @@ export interface PostCardGridProps { style?: StyleProp; } -// ==================== 旧版 Props(兼容层使用) ==================== - -/** - * 旧版 PostCard Props(用于兼容层) - * @deprecated 请迁移到新的 PostCardProps - */ -export interface LegacyPostCardProps { - post: Post; - onPress: () => void; - onUserPress: () => void; - onLike: () => void; - onComment: () => void; - onBookmark: () => void; - onShare: () => void; - onImagePress?: (images: ImageGridItem[], index: number) => void; - onDelete?: () => void; - compact?: boolean; - index?: number; - isAuthor?: boolean; - isPostAuthor?: boolean; - variant?: 'default' | 'grid'; -} - // ==================== 工具类型 ==================== /** diff --git a/src/components/business/index.ts b/src/components/business/index.ts index 5547a0d..91395e8 100644 --- a/src/components/business/index.ts +++ b/src/components/business/index.ts @@ -13,10 +13,7 @@ export type { PostCardFeaturesPreset, PostCardFeaturesConfig, PostCardProps, - LegacyPostCardProps, } from './PostCard'; -export { PostCardPresets, resolveFeatures } from './PostCard'; -export { usePostCardActions, usePostCardFeatures } from './PostCard'; export { default as CommentItem } from './CommentItem'; export { default as UserProfileHeader } from './UserProfileHeader'; export { default as SystemMessageItem } from './SystemMessageItem'; diff --git a/src/components/common/ImageGallery.tsx b/src/components/common/ImageGallery.tsx index 8cc0dc0..8e4aaf3 100644 --- a/src/components/common/ImageGallery.tsx +++ b/src/components/common/ImageGallery.tsx @@ -4,7 +4,7 @@ * 使用 expo-image,原生支持 GIF/WebP 动图 */ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { Modal, View, @@ -84,6 +84,8 @@ export const ImageGallery: React.FC = ({ onSave, backgroundOpacity = 1, }) => { + const closeTimerRef = useRef | null>(null); + const isClosingRef = useRef(false); const [currentIndex, setCurrentIndex] = useState(initialIndex); const [showControls, setShowControls] = useState(true); const [loading, setLoading] = useState(true); @@ -136,6 +138,20 @@ export const ImageGallery: React.FC = ({ } }, [visible, initialIndex, resetZoom]); + useEffect(() => { + if (visible) { + isClosingRef.current = false; + } + }, [visible]); + + useEffect(() => { + return () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + } + }; + }, []); + // 图片变化时重置加载状态和缩放 useEffect(() => { setLoading(true); @@ -156,6 +172,18 @@ export const ImageGallery: React.FC = ({ setShowControls(prev => !prev); }, []); + const requestClose = useCallback(() => { + if (isClosingRef.current) { + return; + } + + isClosingRef.current = true; + // 延迟一个短时间窗口,避免关闭同一触摸触发到底层组件 + closeTimerRef.current = setTimeout(() => { + onClose(); + }, 120); + }, [onClose]); + const goToPrev = useCallback(() => { if (currentIndex > 0) { updateIndex(currentIndex - 1); @@ -297,7 +325,7 @@ export const ImageGallery: React.FC = ({ .numberOfTaps(1) .maxDistance(10) .onEnd(() => { - runOnJS(onClose)(); + runOnJS(requestClose)(); }); // 组合手势: @@ -326,7 +354,7 @@ export const ImageGallery: React.FC = ({ visible={visible} transparent animationType="fade" - onRequestClose={onClose} + onRequestClose={requestClose} statusBarTranslucent > @@ -334,7 +362,7 @@ export const ImageGallery: React.FC = ({ {/* 顶部控制栏 */} {showControls && ( - + diff --git a/src/core/entities/Post.ts b/src/core/entities/Post.ts index 7a92309..f52cad9 100644 --- a/src/core/entities/Post.ts +++ b/src/core/entities/Post.ts @@ -17,6 +17,8 @@ export interface PostAuthor { username: string; nickname?: string; avatar?: string; + is_following?: boolean; + is_following_me?: boolean; } /** @@ -133,6 +135,8 @@ export const createPostAuthor = (data: Partial): PostAuthor => ({ username: data.username || '', nickname: data.nickname, avatar: data.avatar, + is_following: data.is_following, + is_following_me: data.is_following_me, }); /** diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index 0c189be..6ab6631 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -47,6 +47,8 @@ interface PostApiResponse { username: string; nickname?: string; avatar?: string; + is_following?: boolean; + is_following_me?: boolean; }; } @@ -102,6 +104,9 @@ export class PostRepository implements IPostRepository { username: model.author.username, nickname: model.author.nickname, avatar: model.author.avatar, + // 关注关系直接透传 API 字段,供详情页关注按钮状态使用 + is_following: response.author?.is_following, + is_following_me: response.author?.is_following_me, } : undefined, title: model.title, content: model.content, @@ -286,20 +291,7 @@ export class PostRepository implements IPostRepository { */ async getPostById(id: string): Promise { try { - // 1. 先查内存缓存 - const memoryCached = this.getFromMemoryCache(id); - if (memoryCached) { - return memoryCached; - } - - // 2. 查本地数据库缓存 - const localCached = await this.getFromLocalCache(id); - if (localCached) { - this.saveToMemoryCache(localCached); - return localCached; - } - - // 3. 从API获取 + // 详情页优先取最新服务端数据,避免旧缓存导致关注态等关系字段过期 const response = await this.api.get(`/posts/${id}`); const post = this.mapToPost(response); @@ -309,10 +301,16 @@ export class PostRepository implements IPostRepository { return post; } catch (error) { - // 如果API请求失败,尝试返回本地缓存 + // API失败时再回退缓存,保证离线/弱网可用 + const memoryCached = this.getFromMemoryCache(id); + if (memoryCached) { + console.warn('[PostRepository] API获取失败,使用内存缓存:', error); + return memoryCached; + } const localCached = await this.getFromLocalCache(id); if (localCached) { console.warn('[PostRepository] API获取失败,使用本地缓存:', error); + this.saveToMemoryCache(localCached); return localCached; } this.handleError(error, '获取帖子详情'); diff --git a/src/hooks/usePrefetch.ts b/src/hooks/usePrefetch.ts index eab5ee4..ffe0556 100644 --- a/src/hooks/usePrefetch.ts +++ b/src/hooks/usePrefetch.ts @@ -152,7 +152,10 @@ function prefetchConversations(): void { key: 'conversations:list', executor: async () => { await messageManager.initialize(); - await messageManager.fetchConversations(true); + await messageManager.requestConversationListRefresh('prefetch', { + force: true, + allowDefer: true, + }); return messageManager.getConversations(); }, priority: Priority.HIGH, diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 288e50e..7d7d067 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -512,14 +512,7 @@ export const HomeScreen: React.FC = () => { handlePostPress(post.id)} - onUserPress={() => authorId && handleUserPress(authorId)} - onLike={() => handleLike(post)} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => handleBookmark(post)} - onShare={() => handleShare(post)} - onImagePress={(images, index) => handleImagePress(images, index)} - onDelete={() => handleDeletePost(post.id)} + onAction={(action) => handlePostAction(post, action)} isPostAuthor={isPostAuthor} /> @@ -579,15 +572,8 @@ export const HomeScreen: React.FC = () => { handlePostPress(post.id)} - onUserPress={() => authorId && handleUserPress(authorId)} - onLike={() => handleLike(post)} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => handleBookmark(post)} - onShare={() => handleShare(post)} - onImagePress={(images, index) => handleImagePress(images, index)} - onDelete={() => handleDeletePost(post.id)} + variant={viewMode === 'grid' ? 'grid' : 'list'} + onAction={(action) => handlePostAction(post, action)} isPostAuthor={isPostAuthor} /> ); diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index c0ca5f5..520f4a0 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -281,8 +281,25 @@ export const PostDetailScreen: React.FC = () => { if (!post?.author) return; const author = post.author; + const handleBackPress = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + navigation.navigate('Main', { + screen: 'HomeTab', + params: { screen: 'Home', params: undefined }, + }); + }; navigation.setOptions({ + headerBackVisible: false, + headerTitleAlign: 'left', + headerLeft: () => ( + + + + ), headerTitle: () => ( { handleUserPress(author.id)}> - {author.nickname} + {author.nickname && author.nickname.length > 10 + ? `${author.nickname.slice(0, 10)}...` + : author.nickname} @@ -1701,7 +1720,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', flex: 1, - marginLeft: -8, + marginLeft: spacing.sm, }, headerAvatarWrapper: { marginRight: spacing.sm, @@ -1720,6 +1739,12 @@ const styles = StyleSheet.create({ alignItems: 'center', marginRight: spacing.sm, }, + headerBackButton: { + paddingHorizontal: spacing.xs, + paddingVertical: spacing.xs, + marginLeft: spacing.xs, + marginRight: spacing.sm, + }, // 帖子容器 postContainer: { backgroundColor: colors.background.paper, diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index a00ad87..7a6e7b9 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -22,6 +22,7 @@ import { Post, User } from '../../types'; import { useUserStore } from '../../stores'; 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 { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive'; @@ -175,6 +176,41 @@ export const SearchScreen: React.FC = ({ onBack, navigation: navigation.navigate('UserProfile', { userId }); }; + // 统一处理 PostCard 的操作(搜索页不支持删除) + const handlePostAction = (post: Post, action: PostCardAction) => { + switch (action.type) { + case 'press': + handlePostPress(post.id); + break; + case 'userPress': + if (post.author?.id) { + handleUserPress(post.author.id); + } + break; + case 'like': + postService.likePost(post.id); + break; + case 'unlike': + postService.unlikePost(post.id); + break; + case 'comment': + handlePostPress(post.id, true); + break; + case 'bookmark': + postService.favoritePost(post.id); + break; + case 'unbookmark': + postService.unfavoritePost(post.id); + break; + case 'share': + // 搜索页暂不处理分享 + break; + case 'imagePress': + case 'delete': + break; + } + }; + // 获取当前搜索类型 const getSearchType = (): SearchType => { switch (activeIndex) { @@ -222,13 +258,9 @@ export const SearchScreen: React.FC = ({ onBack, navigation: handlePostPress(post.id)} - onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => {}} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => {}} - onShare={() => {}} - compact={isMobile} + onAction={(action) => handlePostAction(post, action)} + variant="list" + features={isMobile ? 'compact' : 'full'} /> ))} @@ -248,13 +280,9 @@ export const SearchScreen: React.FC = ({ onBack, navigation: renderItem={({ item }) => ( handlePostPress(item.id)} - onUserPress={() => item.author ? handleUserPress(item.author.id) : () => {}} - onLike={() => {}} - onComment={() => handlePostPress(item.id, true)} - onBookmark={() => {}} - onShare={() => {}} - compact + onAction={(action) => handlePostAction(item, action)} + variant="list" + features="compact" /> )} keyExtractor={item => item.id} diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index b6223be..2dabce4 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -78,6 +78,8 @@ export const ChatScreen: React.FC = () => { // 输入框区域高度(用于定位浮动 mention 面板) const [inputWrapperHeight, setInputWrapperHeight] = useState(60); const [showEdgeLoadingIndicator, setShowEdgeLoadingIndicator] = useState(false); + const replyTargetMessageIdRef = useRef(null); + const replyHighlightTimerRef = useRef | null>(null); const isPreloadingRef = useRef(false); const lastScrollYRef = useRef(0); const isUserDraggingRef = useRef(false); @@ -151,6 +153,7 @@ export const ChatScreen: React.FC = () => { longPressMenuVisible, selectedMessage, selectedMessageId, + setSelectedMessageId, menuPosition, isGroupChat, groupInfo, @@ -206,6 +209,7 @@ export const ChatScreen: React.FC = () => { handleReachLatestEdge, setBrowsingHistory, } = useChatScreen(); + const displayMessages = useMemo(() => [...messages].reverse(), [messages]); useEffect(() => { if (!loadingMore && showEdgeLoadingIndicator) { @@ -218,14 +222,46 @@ export const ChatScreen: React.FC = () => { if (preloadCooldownTimerRef.current) { clearTimeout(preloadCooldownTimerRef.current); } + if (replyHighlightTimerRef.current) { + clearTimeout(replyHighlightTimerRef.current); + } }; }, []); + 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); + if (targetIndex < 0) return; + + replyTargetMessageIdRef.current = targetId; + setBrowsingHistory(true); + highlightMessageTemporarily(targetId); + + flatListRef.current?.scrollToIndex({ + index: targetIndex, + animated: true, + viewPosition: 0.5, + }); + }, [displayMessages, flatListRef, highlightMessageTemporarily, setBrowsingHistory]); + // 监听返回事件,刷新会话列表 useEffect(() => { const unsubscribe = navigation.addListener('beforeRemove', () => { // 刷新会话列表,确保已读状态正确显示 - messageManager.fetchConversations(true); + messageManager.requestConversationListRefresh('chat-before-remove', { + force: true, + allowDefer: false, + }); }); return unsubscribe; }, [navigation]); @@ -261,6 +297,7 @@ export const ChatScreen: React.FC = () => { shouldShowTime={shouldShowTime} onImagePress={handleImagePress} onReply={handleReplyMessage} + onReplyPress={handleReplyPreviewPress} /> ); }, [ @@ -280,14 +317,13 @@ export const ChatScreen: React.FC = () => { shouldShowTime, handleImagePress, handleReplyMessage, + handleReplyPreviewPress, ]); const keyExtractor = useCallback((item: any) => String(item.id), []); // 获取正在输入提示 const typingHint = getTypingHint(); - const displayMessages = useMemo(() => [...messages].reverse(), [messages]); - const handleMessageListScroll = useCallback((event: any) => { const { contentSize, contentOffset, layoutMeasurement } = event.nativeEvent; scrollPositionRef.current = { @@ -410,6 +446,26 @@ export const ChatScreen: React.FC = () => { }} scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} + onScrollToIndexFailed={(info) => { + const targetId = replyTargetMessageIdRef.current; + if (!targetId || !flatListRef.current) return; + + flatListRef.current.scrollToOffset({ + offset: Math.max(0, info.averageItemLength * info.index), + animated: true, + }); + + setTimeout(() => { + const retryIndex = displayMessages.findIndex(msg => String(msg.id) === targetId); + if (retryIndex >= 0) { + flatListRef.current?.scrollToIndex({ + index: retryIndex, + animated: true, + viewPosition: 0.5, + }); + } + }, 120); + }} /> )} {showEdgeLoadingIndicator && ( diff --git a/src/screens/message/components/ChatScreen/MessageBubble.tsx b/src/screens/message/components/ChatScreen/MessageBubble.tsx index 945460d..b4feaa0 100644 --- a/src/screens/message/components/ChatScreen/MessageBubble.tsx +++ b/src/screens/message/components/ChatScreen/MessageBubble.tsx @@ -57,6 +57,7 @@ export const MessageBubble: React.FC = ({ shouldShowTime, onImagePress, onReply, + onReplyPress, }) => { const bubbleRef = useRef(null); const pressPositionRef = useRef({ x: 0, y: 0 }); @@ -306,6 +307,7 @@ export const MessageBubble: React.FC = ({ isMe ? styles.myBubble : styles.theirBubble, hasReply && segmentStyles.replyBubble, isPureImageMessage && segmentStyles.pureImageBubble, + isSelected && (isMe ? styles.mySelectedBubble : styles.theirSelectedBubble), ]}> = ({ replyMessage={getReplyMessage()} getSenderInfo={getSenderInfo} onAtPress={() => undefined} - onReplyPress={() => undefined} + onReplyPress={onReplyPress} onImagePress={(url) => { // 查找点击的图片索引 const clickIndex = imageSegments.findIndex(img => img.url === url); diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index e414537..2fc3385 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -86,6 +86,8 @@ export interface MessageBubbleProps { onImagePress?: (images: { id: string; url: string; thumbnail_url?: string; width?: number; height?: number }[], index: number) => void; // 滑动回复回调 onReply?: (message: GroupMessage) => void; + // 点击回复预览回调(定位到原消息) + onReplyPress?: (messageId: string) => void; } // 输入框 Props diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 75458b4..8062eec 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -124,6 +124,8 @@ export const useChatScreen = () => { const isProgrammaticScrollRef = useRef(false); const suppressAutoFollowRef = useRef(false); const isBrowsingHistoryRef = useRef(false); + const hasShownMessageListRef = useRef(false); + const historyLoadingLockUntilRef = useRef(0); // 回复消息状态 const [replyingTo, setReplyingTo] = useState(null); @@ -205,9 +207,16 @@ export const useChatScreen = () => { // 加载态语义修正: // isLoadingMessages 在分页加载历史时也会短暂为 true。 // 若直接驱动 ChatScreen 的 loading,会导致消息列表被卸载重挂载,触发“回到底部”。 - // 这里只在“首屏且尚无消息”时展示 loading 占位。 + // 这里只在“首屏且尚未展示过消息列表”时展示 loading 占位。 useEffect(() => { - setLoading(isLoadingMessages && messageManagerMessages.length === 0); + if (messageManagerMessages.length > 0) { + hasShownMessageListRef.current = true; + } + const shouldShowInitialLoading = + !hasShownMessageListRef.current && + isLoadingMessages && + messageManagerMessages.length === 0; + setLoading(shouldShowInitialLoading); }, [isLoadingMessages, messageManagerMessages.length]); // 【改造】同步 hasMore 状态 @@ -281,10 +290,16 @@ export const useChatScreen = () => { enterMarkedKeyRef.current = ''; suppressAutoFollowRef.current = false; isBrowsingHistoryRef.current = false; + hasShownMessageListRef.current = false; + historyLoadingLockUntilRef.current = 0; }, [conversationId]); + const isHistoryLoadingLocked = useCallback(() => { + return Date.now() < historyLoadingLockUntilRef.current; + }, []); + const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => { - if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current)) return; + if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return; // inverted 列表下,最新消息端对应 offset=0 isProgrammaticScrollRef.current = true; flatListRef.current?.scrollToOffset({ @@ -294,7 +309,7 @@ export const useChatScreen = () => { setTimeout(() => { isProgrammaticScrollRef.current = false; }, animated ? 220 : 32); - }, [flatListRef, scrollPositionRef, messages.length]); + }, [flatListRef, isHistoryLoadingLocked]); const isNearBottom = useCallback(() => { const scrollY = scrollPositionRef.current.scrollY || 0; @@ -443,6 +458,8 @@ export const useChatScreen = () => { // 历史加载期间禁止“新消息自动跟随到底” suppressAutoFollowRef.current = true; + isBrowsingHistoryRef.current = true; + historyLoadingLockUntilRef.current = Date.now() + 3000; // 保存加载前的滚动位置和内容高度 const scrollYBefore = scrollPositionRef.current.scrollY; @@ -464,6 +481,8 @@ export const useChatScreen = () => { console.error('加载历史消息失败:', error); } finally { setLoadingMore(false); + // 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底 + historyLoadingLockUntilRef.current = Date.now() + 800; } }, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]); @@ -473,9 +492,10 @@ export const useChatScreen = () => { }, []); const handleReachLatestEdge = useCallback(() => { + if (isHistoryLoadingLocked()) return; suppressAutoFollowRef.current = false; isBrowsingHistoryRef.current = false; - }, []); + }, [isHistoryLoadingLocked]); const setBrowsingHistory = useCallback((browsing: boolean) => { isBrowsingHistoryRef.current = browsing; @@ -1221,6 +1241,7 @@ export const useChatScreen = () => { longPressMenuVisible, selectedMessage, selectedMessageId, + setSelectedMessageId, menuPosition, isGroupChat, groupInfo, diff --git a/src/screens/profile/ProfileScreen.tsx b/src/screens/profile/ProfileScreen.tsx index b100df0..1a1475d 100644 --- a/src/screens/profile/ProfileScreen.tsx +++ b/src/screens/profile/ProfileScreen.tsx @@ -1,523 +1,21 @@ /** - * 个人主页 ProfileScreen - 美化版(响应式适配) + * 个人主页 ProfileScreen * 胡萝卜BBS - 当前用户个人主页 - * 采用现代卡片式设计,优化视觉层次和交互体验 - * 支持桌面端双栏布局 + * 使用统一的 UserProfileScreen 组件,mode='self' */ -import React, { useState, useCallback, useMemo } from 'react'; -import { - View, - StyleSheet, - RefreshControl, - Animated, - ScrollView, -} from 'react-native'; -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import React from 'react'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; -import { colors, spacing } from '../../theme'; -import { Post } from '../../types'; -import { useAuthStore, useUserStore } from '../../stores'; -import { postService } from '../../services'; -import { UserProfileHeader, PostCard, TabBar } from '../../components/business'; -import { PostCardAction } from '../../components/business/PostCard'; -import { Loading, EmptyState, Text } from '../../components/common'; -import { ResponsiveContainer } from '../../components/common'; -import { useResponsive } from '../../hooks'; -import { ProfileStackParamList, HomeStackParamList, RootStackParamList } from '../../navigation/types'; +import UserProfileScreen from './UserProfileScreen'; +import { ProfileStackParamList } from '../../navigation/types'; -type NavigationProp = NativeStackNavigationProp; -type HomeNavigationProp = NativeStackNavigationProp; -type RootNavigationProp = NativeStackNavigationProp; - -const TABS = ['帖子', '收藏']; -const TAB_ICONS = ['file-document-outline', 'bookmark-outline']; +type ProfileNavigationProp = NativeStackNavigationProp; export const ProfileScreen: React.FC = () => { - const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - // 在大屏幕模式下不使用 Bottom Tab Navigator,所以 try-catch 处理 - let tabBarHeight = 0; - try { - tabBarHeight = useBottomTabBarHeight(); - } catch (e) { - // 大屏幕模式下不在 Bottom Tab Navigator 中,会抛出错误,忽略即可 - tabBarHeight = 0; - } - const homeNavigation = useNavigation(); - // 使用 any 类型来访问根导航 - const rootNavigation = useNavigation(); - const { currentUser, updateUser, fetchCurrentUser } = useAuthStore(); - const { followUser, unfollowUser, posts: storePosts } = useUserStore(); + const profileNavigation = useNavigation(); - // 响应式布局 - const { isDesktop, isTablet, width } = useResponsive(); - - // 页面滚动底部安全间距,避免内容被底部 TabBar 遮挡 - // TabBar 悬浮:高度 64 + 浮动间距 12*2 = 88 - const scrollBottomInset = useMemo(() => { - if (isDesktop) return spacing.lg; - const FLOATING_TAB_BAR_HEIGHT = 64 + 24; // TabBar高度 + 上下浮动间距 - return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md; - }, [isDesktop, insets.bottom]); - - const [activeTab, setActiveTab] = useState(0); - const [posts, setPosts] = useState([]); - const [favorites, setFavorites] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const scrollY = new Animated.Value(0); - - // 跳转到关注列表 - const handleFollowingPress = useCallback(() => { - if (currentUser) { - (rootNavigation as any).navigate('FollowList', { userId: currentUser.id, type: 'following' }); - } - }, [currentUser, rootNavigation]); - - // 跳转到粉丝列表 - const handleFollowersPress = useCallback(() => { - if (currentUser) { - (rootNavigation as any).navigate('FollowList', { userId: currentUser.id, type: 'followers' }); - } - }, [currentUser, rootNavigation]); - - // 加载用户帖子 - const loadUserPosts = useCallback(async () => { - if (currentUser) { - try { - const response = await postService.getUserPosts(currentUser.id); - setPosts(response.list); - } catch (error) { - console.error('获取用户帖子失败:', error); - } - } - setLoading(false); - }, [currentUser]); - - // 加载用户收藏 - const loadUserFavorites = useCallback(async () => { - if (currentUser) { - try { - const response = await postService.getUserFavorites(currentUser.id); - setFavorites(response.list); - } catch (error) { - console.error('获取用户收藏失败:', error); - } - } - setLoading(false); - }, [currentUser]); - - // 监听 tab 切换,只在数据为空时加载对应数据 - React.useEffect(() => { - if (activeTab === 0 && posts.length === 0) { - loadUserPosts(); - } else if (activeTab === 1 && favorites.length === 0) { - loadUserFavorites(); - } - }, [activeTab, loadUserPosts, loadUserFavorites, posts.length, favorites.length]); - - // 初始加载 - React.useEffect(() => { - loadUserPosts(); - }, [loadUserPosts]); - - // 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新) - React.useEffect(() => { - // 同步帖子列表状态 - if (posts.length > 0) { - let hasChanges = false; - const updatedPosts = posts.map(localPost => { - const storePost = storePosts.find(sp => sp.id === localPost.id); - if (storePost && ( - storePost.is_liked !== localPost.is_liked || - storePost.is_favorited !== localPost.is_favorited || - storePost.likes_count !== localPost.likes_count || - storePost.favorites_count !== localPost.favorites_count - )) { - hasChanges = true; - return { - ...localPost, - is_liked: storePost.is_liked, - is_favorited: storePost.is_favorited, - likes_count: storePost.likes_count, - favorites_count: storePost.favorites_count, - }; - } - return localPost; - }); - - if (hasChanges) { - setPosts(updatedPosts); - } - } - - // 同步收藏列表状态 - if (favorites.length > 0) { - let hasChanges = false; - const updatedFavorites = favorites.map(localPost => { - const storePost = storePosts.find(sp => sp.id === localPost.id); - if (storePost && ( - storePost.is_liked !== localPost.is_liked || - storePost.is_favorited !== localPost.is_favorited || - storePost.likes_count !== localPost.likes_count || - storePost.favorites_count !== localPost.favorites_count - )) { - hasChanges = true; - return { - ...localPost, - is_liked: storePost.is_liked, - is_favorited: storePost.is_favorited, - likes_count: storePost.likes_count, - favorites_count: storePost.favorites_count, - }; - } - return localPost; - }); - - if (hasChanges) { - setFavorites(updatedFavorites); - } - } - }, [storePosts]); - - // 下拉刷新 - const onRefresh = useCallback(async () => { - setRefreshing(true); - try { - // 刷新用户信息 - await fetchCurrentUser(); - // 刷新帖子列表 - await loadUserPosts(); - } catch (error) { - console.error('刷新失败:', error); - } finally { - setRefreshing(false); - } - }, [fetchCurrentUser, loadUserPosts]); - - // 跳转到设置页 - const handleSettings = useCallback(() => { - navigation.navigate('Settings'); - }, [navigation]); - - // 跳转到编辑资料页 - const handleEditProfile = useCallback(() => { - navigation.navigate('EditProfile'); - }, [navigation]); - - // 关注/取消关注 - const handleFollow = useCallback(() => { - if (!currentUser) return; - if (currentUser.is_following) { - unfollowUser(currentUser.id); - } else { - followUser(currentUser.id); - } - }, [currentUser, unfollowUser, followUser]); - - // 跳转到帖子详情 - const handlePostPress = useCallback((postId: string, scrollToComments: boolean = false) => { - homeNavigation.navigate('PostDetail', { postId, scrollToComments }); - }, [homeNavigation]); - - // 跳转到用户主页(当前用户) - const handleUserPress = useCallback((userId: string) => { - // 个人主页点击自己的头像不跳转 - }, []); - - // 删除帖子 - const handleDeletePost = useCallback(async (postId: string) => { - try { - const success = await postService.deletePost(postId); - if (success) { - // 从帖子列表中移除 - setPosts(prev => prev.filter(p => p.id !== postId)); - // 也从收藏列表中移除(如果存在) - setFavorites(prev => prev.filter(p => p.id !== postId)); - } else { - console.error('删除帖子失败'); - } - } catch (error) { - console.error('删除帖子失败:', error); - throw error; - } - }, []); - - // 统一的 PostCard Action 处理 - const handlePostAction = useCallback((post: Post, action: PostCardAction) => { - switch (action.type) { - case 'press': - handlePostPress(post.id); - break; - case 'userPress': - if (post.author) { - handleUserPress(post.author.id); - } - break; - case 'like': - case 'unlike': - post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id); - break; - case 'comment': - handlePostPress(post.id, true); - break; - case 'bookmark': - case 'unbookmark': - post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id); - break; - case 'share': - // 暂不处理分享 - break; - case 'delete': - handleDeletePost(post.id); - break; - } - }, [handlePostPress, handleUserPress, handleDeletePost]); - - // 渲染内容 - const renderContent = useCallback(() => { - if (loading) return ; - - if (activeTab === 0) { - // 帖子 - if (posts.length === 0) { - return ( - - ); - } - - return ( - - {posts.map((post, index) => { - const isPostAuthor = currentUser?.id === post.author?.id; - return ( - - handlePostAction(post, action)} - isPostAuthor={isPostAuthor} - /> - - ); - })} - - ); - } - - if (activeTab === 1) { - // 收藏 - if (favorites.length === 0) { - return ( - - ); - } - - return ( - - {favorites.map((post, index) => { - const isPostAuthor = currentUser?.id === post.author?.id; - return ( - - handlePostPress(post.id)} - onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id)} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id)} - onShare={() => {}} - onDelete={() => handleDeletePost(post.id)} - isPostAuthor={isPostAuthor} - /> - - ); - })} - - ); - } - - return null; - }, [loading, activeTab, posts, favorites, currentUser?.id, handlePostPress, handleUserPress, handleDeletePost]); - - // 渲染用户信息头部 - 不随 tab 变化,使用 useMemo 缓存 - const renderUserHeader = useMemo(() => ( - - ), [currentUser, handleFollow, handleSettings, handleEditProfile, handleFollowingPress, handleFollowersPress]); - - // 渲染 TabBar 和内容 - const renderTabBarAndContent = useMemo(() => ( - <> - - - - - {renderContent()} - - - ), [activeTab, renderContent]); - - if (!currentUser) { - return ( - - - - ); - } - - // 桌面端使用双栏布局 - if (isDesktop || isTablet) { - return ( - - - - {/* 左侧:用户信息 */} - - - } - > - {renderUserHeader} - - - - {/* 右侧:帖子列表 */} - - - {renderTabBarAndContent} - - - - - - ); - } - - // 移动端使用单栏布局 - return ( - - - } - contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]} - > - {/* 用户信息头部 - 固定在顶部,不受 tab 切换影响 */} - {renderUserHeader} - - {/* TabBar - 分离出来,切换 tab 不会影响上面的用户信息 */} - {renderTabBarAndContent} - - - ); + return ; }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - scrollContent: { - flexGrow: 1, - }, - // 桌面端双栏布局 - desktopContainer: { - flex: 1, - flexDirection: 'row', - gap: spacing.lg, - padding: spacing.lg, - }, - desktopSidebar: { - width: 380, - flexShrink: 0, - }, - desktopContent: { - flex: 1, - minWidth: 0, - }, - desktopScrollContent: { - flexGrow: 1, - }, - tabBarContainer: { - marginTop: spacing.xs, - marginBottom: 2, - }, - contentContainer: { - flex: 1, - minHeight: 350, - paddingTop: spacing.xs, - }, - postsContainer: { - paddingHorizontal: spacing.md, - paddingTop: spacing.sm, - }, - postWrapper: { - marginBottom: spacing.md, - backgroundColor: colors.background.paper, - borderRadius: 16, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.06, - shadowRadius: 8, - elevation: 2, - }, - lastPost: { - marginBottom: spacing['2xl'], - }, -}); - -export default ProfileScreen; +export default ProfileScreen; \ No newline at end of file diff --git a/src/screens/profile/UserProfileScreen.tsx b/src/screens/profile/UserProfileScreen.tsx new file mode 100644 index 0000000..b567d82 --- /dev/null +++ b/src/screens/profile/UserProfileScreen.tsx @@ -0,0 +1,235 @@ +/** + * 统一的用户主页组件 + * 胡萝卜BBS - 支持两种模式:self(当前用户)和 other(其他用户) + * 采用现代卡片式设计,优化视觉层次和交互体验 + * 支持桌面端双栏布局 + */ + +import React, { useCallback, useMemo } from 'react'; +import { + View, + RefreshControl, + ScrollView, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +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 模式需要 + profileNavigation?: NativeStackNavigationProp; // 仅 self 模式需要 +} + +export const UserProfileScreen: React.FC = ({ mode, userId, profileNavigation }) => { + const { isDesktop, isTablet } = useResponsive(); + + const { + user, + posts, + favorites, + loading, + refreshing, + activeTab, + setActiveTab, + scrollBottomInset, + onRefresh, + handleFollow, + handlePostAction, + handleFollowingPress, + handleFollowersPress, + handleMessage, + handleMore, + handleSettings, + handleEditProfile, + isCurrentUser, + currentUser, + } = useUserProfile({ mode, userId, isDesktop, isTablet, profileNavigation }); + + // 渲染帖子列表 + const renderPostList = useCallback((postList: Post[], emptyTitle: string, emptyDesc: string) => { + if (postList.length === 0) { + return ( + + ); + } + + return ( + + {postList.map((post, index) => { + const isPostAuthor = currentUser?.id === post.author?.id; + return ( + + handlePostAction(post, action)} + isPostAuthor={isPostAuthor} + /> + + ); + })} + + ); + }, [currentUser?.id, handlePostAction, activeTab]); + + // 渲染内容 + const renderContent = useCallback(() => { + if (loading) return ; + + if (activeTab === 0) { + return renderPostList( + posts, + mode === 'self' ? '还没有帖子' : '这个用户还没有发布任何帖子', + mode === 'self' ? '分享你的想法,发布第一条帖子吧' : '' + ); + } + + if (activeTab === 1) { + return renderPostList( + favorites, + mode === 'self' ? '还没有收藏' : '这个用户还没有收藏任何帖子', + mode === 'self' ? '发现喜欢的内容,点击收藏按钮保存' : '' + ); + } + + return null; + }, [loading, activeTab, posts, favorites, mode, renderPostList]); + + // 渲染用户信息头部 + const renderUserHeader = useMemo(() => { + if (!user) return null; + + return ( + + ); + }, [user, isCurrentUser, handleFollow, handleSettings, handleEditProfile, handleMessage, handleMore, handleFollowingPress, handleFollowersPress]); + + // 渲染 TabBar 和内容 + const renderTabBarAndContent = useMemo(() => ( + <> + + + + + {renderContent()} + + + ), [activeTab, renderContent]); + + // 未登录/用户不存在状态 + if (mode === 'self' && !currentUser) { + return ( + + + + ); + } + + if (mode === 'other' && !user && !loading) { + return ( + + + + ); + } + + // 桌面端使用双栏布局 + if (isDesktop || isTablet) { + return ( + + + + {/* 左侧:用户信息 */} + + + } + > + {renderUserHeader} + + + + {/* 右侧:帖子列表 */} + + + {renderTabBarAndContent} + + + + + + ); + } + + // 移动端使用单栏布局 + return ( + + + } + contentContainerStyle={[sharedStyles.scrollContent, { paddingBottom: scrollBottomInset }]} + > + {renderUserHeader} + {renderTabBarAndContent} + + + ); +}; + +export default UserProfileScreen; \ No newline at end of file diff --git a/src/screens/profile/UserScreen.tsx b/src/screens/profile/UserScreen.tsx index ffd5d17..358e450 100644 --- a/src/screens/profile/UserScreen.tsx +++ b/src/screens/profile/UserScreen.tsx @@ -1,578 +1,29 @@ /** - * 用户主页 UserScreen(响应式适配) + * 用户主页 UserScreen * 胡萝卜BBS - 查看其他用户资料 - * 支持桌面端双栏布局 + * 使用统一的 UserProfileScreen 组件,mode='other' */ -import React, { useState, useEffect, useCallback } from 'react'; -import { - View, - FlatList, - StyleSheet, - RefreshControl, - ScrollView, - Alert, -} 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 { colors, spacing } from '../../theme'; -import { Post, User } from '../../types'; -import { useUserStore } from '../../stores'; +import React from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; +import UserProfileScreen from './UserProfileScreen'; +import { HomeStackParamList } from '../../navigation/types'; import { useCurrentUser } from '../../stores/authStore'; -import { authService, postService, messageService } from '../../services'; -import { userManager } from '../../stores/userManager'; -import { UserProfileHeader, PostCard, TabBar } from '../../components/business'; -import { PostCardAction } from '../../components/business/PostCard'; -import { Loading, EmptyState, ResponsiveContainer } from '../../components/common'; -import { useResponsive } from '../../hooks'; -import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; -type NavigationProp = NativeStackNavigationProp; type UserRouteProp = RouteProp; -const TABS = ['帖子', '收藏']; -const TAB_ICONS = ['file-document-outline', 'bookmark-outline']; - export const UserScreen: React.FC = () => { - const navigation = useNavigation(); const route = useRoute(); const userId = route.params?.userId || ''; - - // 使用 any 类型来访问根导航 - const rootNavigation = useNavigation>(); - - const { followUser, unfollowUser, posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); + const isSelfProfile = !!currentUser?.id && currentUser.id === userId; - // 响应式布局 - const { isDesktop, isTablet } = useResponsive(); - - const [user, setUser] = useState(null); - const [posts, setPosts] = useState([]); - const [favorites, setFavorites] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [activeTab, setActiveTab] = useState(0); - const [isBlocked, setIsBlocked] = useState(false); - - // 加载用户信息 - const loadUserData = useCallback(async (forceRefresh = false) => { - if (!userId) { - setLoading(false); - return; - } - - try { - // 强制从服务器获取最新数据,确保关注状态是最新的 - const userData = await userManager.getUserById(userId, forceRefresh); - setUser(userData || null); - const blockStatus = await authService.getBlockStatus(userId); - setIsBlocked(blockStatus); - - const response = await postService.getUserPosts(userId); - setPosts(response.list); - } catch (error) { - console.error('加载用户数据失败:', error); - } - setLoading(false); - }, [userId]); - - // 加载用户收藏 - const loadUserFavorites = useCallback(async () => { - if (!userId) return; - try { - const response = await postService.getUserFavorites(userId); - setFavorites(response.list); - } catch (error) { - console.error('获取用户收藏失败:', error); - } - }, [userId]); - - // 监听 tab 切换 - useEffect(() => { - if (activeTab === 1) { - loadUserFavorites(); - } - }, [activeTab, loadUserFavorites]); - - useEffect(() => { - // 首次加载时强制刷新,确保关注状态是最新的 - loadUserData(true); - }, [loadUserData]); - - // 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新) - useEffect(() => { - // 同步帖子列表状态 - if (posts.length > 0) { - let hasChanges = false; - const updatedPosts = posts.map(localPost => { - const storePost = storePosts.find(sp => sp.id === localPost.id); - if (storePost && ( - storePost.is_liked !== localPost.is_liked || - storePost.is_favorited !== localPost.is_favorited || - storePost.likes_count !== localPost.likes_count || - storePost.favorites_count !== localPost.favorites_count - )) { - hasChanges = true; - return { - ...localPost, - is_liked: storePost.is_liked, - is_favorited: storePost.is_favorited, - likes_count: storePost.likes_count, - favorites_count: storePost.favorites_count, - }; - } - return localPost; - }); - - if (hasChanges) { - setPosts(updatedPosts); - } - } - - // 同步收藏列表状态 - if (favorites.length > 0) { - let hasChanges = false; - const updatedFavorites = favorites.map(localPost => { - const storePost = storePosts.find(sp => sp.id === localPost.id); - if (storePost && ( - storePost.is_liked !== localPost.is_liked || - storePost.is_favorited !== localPost.is_favorited || - storePost.likes_count !== localPost.likes_count || - storePost.favorites_count !== localPost.favorites_count - )) { - hasChanges = true; - return { - ...localPost, - is_liked: storePost.is_liked, - is_favorited: storePost.is_favorited, - likes_count: storePost.likes_count, - favorites_count: storePost.favorites_count, - }; - } - return localPost; - }); - - if (hasChanges) { - setFavorites(updatedFavorites); - } - } - }, [storePosts]); - - // 下拉刷新 - const onRefresh = useCallback(() => { - setRefreshing(true); - loadUserData(true); - setRefreshing(false); - }, [loadUserData]); - - // 关注/取消关注 - const handleFollow = () => { - if (!user) return; - if (user.is_following) { - unfollowUser(user.id); - setUser({ ...user, is_following: false, followers_count: user.followers_count - 1 }); - } else { - followUser(user.id); - setUser({ ...user, is_following: true, followers_count: user.followers_count + 1 }); - } - }; - - // 跳转到关注列表 - const handleFollowingPress = () => { - (rootNavigation as any).navigate('FollowList', { userId, type: 'following' }); - }; - - // 跳转到粉丝列表 - const handleFollowersPress = () => { - (rootNavigation as any).navigate('FollowList', { userId, type: 'followers' }); - }; - - // 跳转到帖子详情 - const handlePostPress = (postId: string, scrollToComments: boolean = false) => { - navigation.navigate('PostDetail', { postId, scrollToComments }); - }; - - // 跳转到用户主页(这里不做处理) - const handleUserPress = (postUserId: string) => { - if (postUserId !== userId) { - navigation.push('UserProfile', { userId: postUserId }); - } - }; - - // 删除帖子 - const handleDeletePost = async (postId: string) => { - try { - const success = await postService.deletePost(postId); - if (success) { - // 从帖子列表中移除 - setPosts(prev => prev.filter(p => p.id !== postId)); - // 也从收藏列表中移除(如果存在) - setFavorites(prev => prev.filter(p => p.id !== postId)); - } else { - console.error('删除帖子失败'); - } - } catch (error) { - console.error('删除帖子失败:', error); - throw error; - } - }; - - // 统一处理 PostCard 的所有操作 - const handlePostAction = (post: Post, action: PostCardAction) => { - switch (action.type) { - case 'press': - handlePostPress(post.id); - break; - case 'userPress': - if (post.author) handleUserPress(post.author.id); - break; - case 'like': - postService.likePost(post.id); - break; - case 'unlike': - postService.unlikePost(post.id); - break; - case 'comment': - handlePostPress(post.id, true); - break; - case 'bookmark': - postService.favoritePost(post.id); - break; - case 'unbookmark': - postService.unfavoritePost(post.id); - break; - case 'share': - // 暂不处理 - break; - case 'delete': - handleDeletePost(post.id); - break; - } - }; - - // 跳转到聊天界面 - const handleMessage = async () => { - if (!user) return; - - try { - // 前端只提供对方的用户ID,会话ID由后端生成 - const conversation = await messageService.createConversation(user.id); - if (conversation) { - // 跳转到聊天界面 - 使用 rootNavigation 确保在正确的导航栈中 - (rootNavigation as any).navigate('Chat', { - conversationId: conversation.id.toString(), - userId: user.id - }); - } - } catch (error) { - console.error('创建会话失败:', error); - } - }; - - // 处理更多按钮点击 - const handleMore = () => { - if (!user) return; - - Alert.alert( - '更多操作', - undefined, - [ - { text: '取消', style: 'cancel' }, - { - text: isBlocked ? '取消拉黑' : '拉黑用户', - style: 'destructive', - onPress: () => { - Alert.alert( - isBlocked ? '确认取消拉黑' : '确认拉黑', - isBlocked - ? '取消拉黑后,对方可以重新与你建立关系。' - : '拉黑后,对方将无法给你发送私聊消息,且会互相移除关注关系。', - [ - { text: '取消', style: 'cancel' }, - { - text: '确定', - style: 'destructive', - onPress: async () => { - const ok = isBlocked - ? await authService.unblockUser(user.id) - : await authService.blockUser(user.id); - if (!ok) { - Alert.alert('失败', isBlocked ? '取消拉黑失败,请稍后重试' : '拉黑失败,请稍后重试'); - return; - } - setUser(prev => - prev - ? { - ...prev, - is_following: false, - is_following_me: false, - } - : prev - ); - setIsBlocked(!isBlocked); - Alert.alert('成功', isBlocked ? '已取消拉黑' : '已拉黑该用户'); - }, - }, - ] - ); - }, - }, - ] - ); - }; - - // 渲染内容 - const renderContent = () => { - if (loading) return ; - - if (activeTab === 0) { - // 帖子 - if (posts.length === 0) { - return ( - - ); - } - - return ( - - {posts.map((post, index) => { - const isPostAuthor = currentUser?.id === post.author?.id; - return ( - - handlePostAction(post, action)} - isPostAuthor={isPostAuthor} - /> - - ); - })} - - ); - } - - if (activeTab === 1) { - // 收藏 - if (favorites.length === 0) { - return ( - - ); - } - - return ( - - {favorites.map((post, index) => { - const isPostAuthor = currentUser?.id === post.author?.id; - return ( - - handlePostAction(post, action)} - isPostAuthor={isPostAuthor} - /> - - ); - })} - - ); - } - - return null; - }; - - // 渲染用户信息头部 - const renderUserHeader = () => { - if (!user) return null; - return ( - - ); - }; - - // 渲染 TabBar 和内容 - const renderTabBarAndContent = () => ( - <> - - - - - {renderContent()} - - - ); - - if (loading) { - return ; - } - - if (!user) { - return ( - - - - ); - } - - // 桌面端使用双栏布局 - if (isDesktop || isTablet) { - return ( - - - - {/* 左侧:用户信息 */} - - - } - > - {renderUserHeader()} - - - - {/* 右侧:帖子列表 */} - - - {renderTabBarAndContent()} - - - - - - ); - } - - // 移动端使用单栏布局 return ( - - ( - - {renderUserHeader()} - - - - - {renderContent()} - - - )} - keyExtractor={item => item.key} - showsVerticalScrollIndicator={false} - refreshControl={ - - } - /> - + ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - // 桌面端双栏布局 - desktopContainer: { - flex: 1, - flexDirection: 'row', - gap: spacing.lg, - padding: spacing.lg, - }, - desktopSidebar: { - width: 380, - flexShrink: 0, - }, - desktopContent: { - flex: 1, - minWidth: 0, - }, - desktopScrollContent: { - flexGrow: 1, - }, - tabBarContainer: { - marginTop: spacing.sm, - marginBottom: spacing.xs, - }, - contentContainer: { - flex: 1, - minHeight: 350, - paddingTop: spacing.sm, - }, - postsContainer: { - paddingHorizontal: spacing.md, - paddingTop: spacing.sm, - }, - postWrapper: { - marginBottom: spacing.md, - backgroundColor: colors.background.paper, - borderRadius: 16, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.06, - shadowRadius: 8, - elevation: 2, - }, - lastPost: { - marginBottom: spacing['2xl'], - }, -}); - export default UserScreen; diff --git a/src/screens/profile/index.ts b/src/screens/profile/index.ts index ed6ffb5..a59ad38 100644 --- a/src/screens/profile/index.ts +++ b/src/screens/profile/index.ts @@ -2,6 +2,10 @@ * 个人中心模块导出 */ +// 统一的用户主页组件 +export { UserProfileScreen } from './UserProfileScreen'; + +// 兼容旧组件(内部使用 UserProfileScreen) export { ProfileScreen } from './ProfileScreen'; export { SettingsScreen } from './SettingsScreen'; export { EditProfileScreen } from './EditProfileScreen'; @@ -10,3 +14,7 @@ export { default as FollowListScreen } from './FollowListScreen'; export { NotificationSettingsScreen } from './NotificationSettingsScreen'; export { BlockedUsersScreen } from './BlockedUsersScreen'; export { AccountSecurityScreen } from './AccountSecurityScreen'; + +// 导出 Hook 供需要自定义的场景使用 +export { useUserProfile, sharedStyles, TABS, TAB_ICONS } from './useUserProfile'; +export type { ProfileMode, UseUserProfileOptions, UseUserProfileReturn } from './useUserProfile'; diff --git a/src/screens/profile/useUserProfile.ts b/src/screens/profile/useUserProfile.ts new file mode 100644 index 0000000..bacc757 --- /dev/null +++ b/src/screens/profile/useUserProfile.ts @@ -0,0 +1,504 @@ +/** + * 用户主页共享逻辑 Hook + * 提取 ProfileScreen 和 UserScreen 的共同逻辑 + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { colors, spacing } from '../../theme'; +import { Post, User } from '../../types'; +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 { PostCardAction } from '../../components/business/PostCard'; + +export type ProfileMode = 'self' | 'other'; + +export interface UseUserProfileOptions { + mode: ProfileMode; + userId?: string; // 仅 other 模式需要 + isDesktop?: boolean; + isTablet?: boolean; + profileNavigation?: NativeStackNavigationProp; // 仅 self 模式需要 +} + +export interface UseUserProfileReturn { + // 用户数据 + user: User | null; + posts: Post[]; + favorites: Post[]; + loading: boolean; + refreshing: boolean; + isBlocked: boolean; + + // Tab 相关 + activeTab: number; + setActiveTab: (tab: number) => void; + + // 布局相关 + scrollBottomInset: number; + + // 操作 + onRefresh: () => Promise; + handleFollow: () => void; + handlePostPress: (postId: string, scrollToComments?: boolean) => void; + handleUserPress: (postUserId: string) => void; + handleDeletePost: (postId: string) => Promise; + handleFollowingPress: () => void; + handleFollowersPress: () => void; + handlePostAction: (post: Post, action: PostCardAction) => void; + + // 仅 other 模式 + handleMessage?: () => Promise; + handleMore?: () => void; + + // 仅 self 模式 + handleSettings?: () => void; + handleEditProfile?: () => void; + + // 判断 + isCurrentUser: boolean; + currentUser: User | null; +} + +export const useUserProfile = (options: UseUserProfileOptions): UseUserProfileReturn => { + const { mode, userId, isDesktop = false, profileNavigation } = options; + + const insets = useSafeAreaInsets(); + const homeNavigation = useNavigation>(); + const rootNavigation = useNavigation>(); + + const { followUser, unfollowUser, posts: storePosts } = useUserStore(); + const currentUser = useCurrentUser(); + + // 状态 + const [user, setUser] = useState(null); + const [posts, setPosts] = useState([]); + const [favorites, setFavorites] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [activeTab, setActiveTab] = useState(0); + const [isBlocked, setIsBlocked] = useState(false); + + // 获取其他用户数据(other 模式) + const loadOtherUserData = useCallback(async (forceRefresh = false) => { + if (mode !== 'other' || !userId) { + setLoading(false); + return null; + } + + try { + const userData = await userManager.getUserById(userId, forceRefresh); + setUser(userData || null); + const blockStatus = await authService.getBlockStatus(userId); + setIsBlocked(blockStatus); + + const response = await postService.getUserPosts(userId); + setPosts(response.list); + + return userData; + } catch (error) { + console.error('加载用户数据失败:', error); + return null; + } finally { + setLoading(false); + } + }, [mode, userId]); + + // 加载当前用户的帖子(self 模式) + const loadSelfPosts = useCallback(async () => { + if (mode !== 'self' || !currentUser) { + setLoading(false); + return; + } + + try { + const response = await postService.getUserPosts(currentUser.id); + setPosts(response.list); + } catch (error) { + console.error('获取用户帖子失败:', error); + } + setLoading(false); + }, [mode, currentUser]); + + // 加载收藏 + const loadFavorites = useCallback(async () => { + const targetUserId = mode === 'self' ? currentUser?.id : userId; + if (!targetUserId) return; + + try { + const response = await postService.getUserFavorites(targetUserId); + setFavorites(response.list); + } catch (error) { + console.error('获取用户收藏失败:', error); + } + }, [mode, currentUser?.id, userId]); + + // 初始化加载 + useEffect(() => { + if (mode === 'self') { + loadSelfPosts(); + } else { + loadOtherUserData(true); + } + }, [mode, loadSelfPosts, loadOtherUserData]); + + // Tab 切换加载收藏 + useEffect(() => { + if (activeTab === 1 && favorites.length === 0) { + loadFavorites(); + } + }, [activeTab, favorites.length, loadFavorites]); + + // 同步 store 中的帖子状态 + useEffect(() => { + if (posts.length > 0) { + let hasChanges = false; + const updatedPosts = posts.map(localPost => { + const storePost = storePosts.find(sp => sp.id === localPost.id); + if (storePost && ( + storePost.is_liked !== localPost.is_liked || + storePost.is_favorited !== localPost.is_favorited || + storePost.likes_count !== localPost.likes_count || + storePost.favorites_count !== localPost.favorites_count + )) { + hasChanges = true; + return { + ...localPost, + is_liked: storePost.is_liked, + is_favorited: storePost.is_favorited, + likes_count: storePost.likes_count, + favorites_count: storePost.favorites_count, + }; + } + return localPost; + }); + + if (hasChanges) { + setPosts(updatedPosts); + } + } + + if (favorites.length > 0) { + let hasChanges = false; + const updatedFavorites = favorites.map(localPost => { + const storePost = storePosts.find(sp => sp.id === localPost.id); + if (storePost && ( + storePost.is_liked !== localPost.is_liked || + storePost.is_favorited !== localPost.is_favorited || + storePost.likes_count !== localPost.likes_count || + storePost.favorites_count !== localPost.favorites_count + )) { + hasChanges = true; + return { + ...localPost, + is_liked: storePost.is_liked, + is_favorited: storePost.is_favorited, + likes_count: storePost.likes_count, + favorites_count: storePost.favorites_count, + }; + } + return localPost; + }); + + if (hasChanges) { + setFavorites(updatedFavorites); + } + } + }, [storePosts]); + + // 下拉刷新 + const onRefresh = useCallback(async () => { + setRefreshing(true); + try { + if (mode === 'self') { + const { useAuthStore } = await import('../../stores'); + const { fetchCurrentUser } = useAuthStore.getState(); + await fetchCurrentUser(); + await loadSelfPosts(); + } else { + await loadOtherUserData(true); + } + } catch (error) { + console.error('刷新失败:', error); + } finally { + setRefreshing(false); + } + }, [mode, loadSelfPosts, loadOtherUserData]); + + // 关注/取消关注 + const handleFollow = useCallback(() => { + const targetUser = mode === 'self' ? currentUser : user; + if (!targetUser) return; + + if (targetUser.is_following) { + unfollowUser(targetUser.id); + if (mode === 'other') { + setUser({ ...targetUser, is_following: false, followers_count: targetUser.followers_count - 1 }); + } + } else { + followUser(targetUser.id); + if (mode === 'other') { + setUser({ ...targetUser, is_following: true, followers_count: targetUser.followers_count + 1 }); + } + } + }, [mode, currentUser, user, unfollowUser, followUser]); + + // 跳转到关注列表 + const handleFollowingPress = useCallback(() => { + const targetUserId = mode === 'self' ? currentUser?.id : userId; + if (targetUserId) { + (rootNavigation as any).navigate('FollowList', { userId: targetUserId, type: 'following' }); + } + }, [mode, currentUser?.id, userId, rootNavigation]); + + // 跳转到粉丝列表 + const handleFollowersPress = useCallback(() => { + const targetUserId = mode === 'self' ? currentUser?.id : userId; + if (targetUserId) { + (rootNavigation as any).navigate('FollowList', { userId: targetUserId, type: 'followers' }); + } + }, [mode, currentUser?.id, userId, rootNavigation]); + + // 跳转到帖子详情 + const handlePostPress = useCallback((postId: string, scrollToComments: boolean = false) => { + homeNavigation.navigate('PostDetail', { postId, scrollToComments }); + }, [homeNavigation]); + + // 跳转到用户主页 + const handleUserPress = useCallback((postUserId: string) => { + if (mode === 'self') { + return; + } + if (postUserId !== userId) { + homeNavigation.push('UserProfile', { userId: postUserId }); + } + }, [mode, userId, homeNavigation]); + + // 删除帖子 + const handleDeletePost = useCallback(async (postId: string) => { + try { + const success = await postService.deletePost(postId); + if (success) { + setPosts(prev => prev.filter(p => p.id !== postId)); + setFavorites(prev => prev.filter(p => p.id !== postId)); + } else { + console.error('删除帖子失败'); + } + } catch (error) { + console.error('删除帖子失败:', error); + throw error; + } + }, []); + + // 统一的 PostCard Action 处理 + const handlePostAction = useCallback((post: Post, action: PostCardAction) => { + switch (action.type) { + case 'press': + handlePostPress(post.id); + break; + case 'userPress': + if (post.author) { + handleUserPress(post.author.id); + } + break; + case 'like': + case 'unlike': + post.is_liked ? postService.unlikePost(post.id) : postService.likePost(post.id); + break; + case 'comment': + handlePostPress(post.id, true); + break; + case 'bookmark': + case 'unbookmark': + post.is_favorited ? postService.unfavoritePost(post.id) : postService.favoritePost(post.id); + break; + case 'share': + break; + case 'delete': + handleDeletePost(post.id); + break; + } + }, [handlePostPress, handleUserPress, handleDeletePost]); + + // 发消息(仅 other 模式) + const handleMessage = useCallback(async () => { + if (!user) return; + + try { + const conversation = await messageService.createConversation(user.id); + if (conversation) { + (rootNavigation as any).navigate('Chat', { + conversationId: conversation.id.toString(), + userId: user.id + }); + } + } catch (error) { + console.error('创建会话失败:', error); + } + }, [user, rootNavigation]); + + // 更多操作(仅 other 模式) + const handleMore = useCallback(() => { + if (!user) return; + + const { Alert } = require('react-native'); + Alert.alert( + '更多操作', + undefined, + [ + { text: '取消', style: 'cancel' }, + { + text: isBlocked ? '取消拉黑' : '拉黑用户', + style: 'destructive', + onPress: () => { + Alert.alert( + isBlocked ? '确认取消拉黑' : '确认拉黑', + isBlocked + ? '取消拉黑后,对方可以重新与你建立关系。' + : '拉黑后,对方将无法给你发送私聊消息,且会互相移除关注关系。', + [ + { text: '取消', style: 'cancel' }, + { + text: '确定', + style: 'destructive', + onPress: async () => { + const ok = isBlocked + ? await authService.unblockUser(user.id) + : await authService.blockUser(user.id); + if (!ok) { + Alert.alert('失败', isBlocked ? '取消拉黑失败,请稍后重试' : '拉黑失败,请稍后重试'); + return; + } + setUser(prev => + prev + ? { ...prev, is_following: false, is_following_me: false } + : prev + ); + setIsBlocked(!isBlocked); + Alert.alert('成功', isBlocked ? '已取消拉黑' : '已拉黑该用户'); + }, + }, + ] + ); + }, + }, + ] + ); + }, [user, isBlocked]); + + // 设置页跳转(仅 self 模式) + const handleSettings = useCallback(() => { + if (profileNavigation) { + profileNavigation.navigate('Settings'); + } + }, [profileNavigation]); + + // 编辑资料页跳转(仅 self 模式) + const handleEditProfile = useCallback(() => { + if (profileNavigation) { + profileNavigation.navigate('EditProfile'); + } + }, [profileNavigation]); + + // 计算滚动底部间距 + const scrollBottomInset = useMemo(() => { + if (isDesktop) return spacing.lg; + const FLOATING_TAB_BAR_HEIGHT = 64 + 24; + return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md; + }, [isDesktop, insets.bottom]); + + // 获取显示的用户 + const displayUser = mode === 'self' ? currentUser : user; + + return { + user: displayUser, + posts, + favorites, + loading, + refreshing, + isBlocked, + activeTab, + setActiveTab, + scrollBottomInset, + onRefresh, + handleFollow, + handlePostPress, + handleUserPress, + handleDeletePost, + handleFollowingPress, + handleFollowersPress, + handlePostAction, + handleMessage: mode === 'other' ? handleMessage : undefined, + handleMore: mode === 'other' ? handleMore : undefined, + handleSettings: mode === 'self' && profileNavigation ? handleSettings : undefined, + handleEditProfile: mode === 'self' && profileNavigation ? handleEditProfile : undefined, + isCurrentUser: mode === 'self', + currentUser, + }; +}; + +// 共享的样式 +import { StyleSheet } from 'react-native'; + +export const sharedStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + scrollContent: { + flexGrow: 1, + }, + desktopContainer: { + flex: 1, + flexDirection: 'row', + gap: spacing.lg, + padding: spacing.lg, + }, + desktopSidebar: { + width: 380, + flexShrink: 0, + }, + desktopContent: { + flex: 1, + minWidth: 0, + }, + desktopScrollContent: { + flexGrow: 1, + }, + tabBarContainer: { + marginTop: spacing.xs, + marginBottom: 2, + }, + contentContainer: { + flex: 1, + minHeight: 350, + paddingTop: spacing.xs, + }, + postsContainer: { + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + }, + postWrapper: { + marginBottom: spacing.md, + backgroundColor: colors.background.paper, + borderRadius: 16, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.06, + shadowRadius: 8, + elevation: 2, + }, + lastPost: { + marginBottom: spacing['2xl'], + }, +}); + +// Tab 配置 +export const TABS = ['帖子', '收藏']; +export const TAB_ICONS = ['file-document-outline', 'bookmark-outline']; diff --git a/src/services/sseService.ts b/src/services/sseService.ts index 867c7d1..438bd62 100644 --- a/src/services/sseService.ts +++ b/src/services/sseService.ts @@ -172,22 +172,28 @@ interface SSEEnvelope { class SSEService { private source: EventSource | null = null; private isConnecting = false; + private connected = false; private reconnectAttempts = 0; private maxReconnectAttempts = 20; private reconnectDelay = 3000; private reconnectTimer: NodeJS.Timeout | null = null; + private connectionWatchdogTimer: NodeJS.Timeout | null = null; + private readonly CONNECTION_IDLE_TIMEOUT_MS = 70000; private messageHandlers: Map = new Map(); private connectionHandlers: ConnectionHandler[] = []; private disconnectionHandlers: ConnectionHandler[] = []; private appStateSubscription: any = null; private lastAppState: AppStateStatus = 'active'; private lastEventId = ''; + private lastActivityAt = 0; + private shouldRun = false; private toSSEUrl(): string { return `${SSE_URL}?last_event_id=${encodeURIComponent(this.lastEventId)}`; } async connect(): Promise { + this.shouldRun = true; if (this.isConnecting || this.isConnected()) return true; this.isConnecting = true; try { @@ -201,18 +207,31 @@ class SSEService { headers: { Authorization: `Bearer ${token}`, }, + // Use one reconnect strategy only (manual), avoid library auto-reconnect fighting with us. + pollingInterval: 0, + // Be explicit to prevent platform-level short idle timeout behavior. + timeout: this.CONNECTION_IDLE_TIMEOUT_MS, }); this.source.addEventListener('open', () => { this.isConnecting = false; + this.connected = true; this.reconnectAttempts = 0; + this.markActivity(); + this.startConnectionWatchdog(); this.connectionHandlers.forEach(h => h()); }); this.source.addEventListener('error', () => { - this.isConnecting = false; - this.disconnectionHandlers.forEach(h => h()); - this.scheduleReconnect(); + this.handleDisconnected(); + }); + + this.source.addEventListener('done' as any, () => { + this.handleDisconnected(); + }); + + this.source.addEventListener('close' as any, () => { + this.handleDisconnected(); }); const events = ['chat_message', 'message_read', 'typing', 'system_notification', 'group_notice', 'message_recall', 'heartbeat']; @@ -229,6 +248,7 @@ class SSEService { } private handleIncoming(eventName: string, evt: any): void { + this.markActivity(); const rawData = typeof evt?.data === 'string' ? evt.data : '{}'; const lastEventId = evt?.lastEventId; if (lastEventId) { @@ -375,10 +395,14 @@ class SSEService { } disconnect(): void { + this.shouldRun = false; + this.connected = false; + this.isConnecting = false; if (this.source) { this.source.close(); this.source = null; } + this.stopConnectionWatchdog(); this.stopReconnect(); } @@ -399,7 +423,7 @@ class SSEService { } isConnected(): boolean { - return this.source != null; + return this.connected; } on(type: T, handler: MessageHandler>): () => void { @@ -453,6 +477,45 @@ class SSEService { } this.disconnect(); } + + private markActivity(): void { + this.lastActivityAt = Date.now(); + } + + private startConnectionWatchdog(): void { + this.stopConnectionWatchdog(); + this.connectionWatchdogTimer = setInterval(() => { + // During active app usage, if no SSE activity for too long, reconnect proactively. + if (!this.connected || this.lastActivityAt <= 0) return; + if (Date.now() - this.lastActivityAt > this.CONNECTION_IDLE_TIMEOUT_MS) { + this.handleDisconnected(); + } + }, 10000); + } + + private stopConnectionWatchdog(): void { + if (this.connectionWatchdogTimer) { + clearInterval(this.connectionWatchdogTimer); + this.connectionWatchdogTimer = null; + } + } + + private handleDisconnected(): void { + const wasActive = this.connected || this.source != null || this.isConnecting; + this.isConnecting = false; + this.connected = false; + if (this.source) { + this.source.close(); + this.source = null; + } + this.stopConnectionWatchdog(); + if (wasActive) { + this.disconnectionHandlers.forEach(h => h()); + } + if (this.shouldRun) { + this.scheduleReconnect(); + } + } } export const sseService = new SSEService(); diff --git a/src/stores/messageManager.ts b/src/stores/messageManager.ts index e95c4d9..70e88c5 100644 --- a/src/stores/messageManager.ts +++ b/src/stores/messageManager.ts @@ -165,6 +165,8 @@ class MessageManager { /** 正在加载会话列表下一页 */ private loadingMoreConversations = false; + /** 聊天页活跃期间延迟的会话列表刷新 */ + private deferredConversationRefresh = false; /** 远端会话列表(游标或页码,由注入/remoteListKind 决定) */ private readonly remoteConversationListSource: IConversationListPagedSource; @@ -274,6 +276,32 @@ class MessageManager { }); } + private shouldDeferConversationRefresh(source: string, forceRefresh: boolean): boolean { + if (!this.state.currentConversationId) return false; + if (!forceRefresh) return false; + // 列表域后台刷新来源:聊天活跃期间一律延后执行 + return source === 'sse-reconnect' || source === 'prefetch' || source === 'hooks-initial-refresh'; + } + + async requestConversationListRefresh( + source: string, + options?: { force?: boolean; allowDefer?: boolean } + ): Promise { + const forceRefresh = options?.force ?? true; + const allowDefer = options?.allowDefer ?? true; + if (allowDefer && this.shouldDeferConversationRefresh(source, forceRefresh)) { + this.deferredConversationRefresh = true; + if (__DEV__) { + console.log('[MessageManager] defer conversation refresh request', { + source, + activeConversationId: this.state.currentConversationId, + }); + } + return; + } + await this.fetchConversations(forceRefresh, source); + } + /** * 按 message_id 合并消息并按 seq 排序。 * 后者覆盖前者,可用来吸收更完整的同 ID 消息体(例如服务端回包/SSE)。 @@ -430,7 +458,7 @@ class MessageManager { this.initSSEListeners(); // 3. 加载会话列表 - await this.fetchConversations(); + await this.fetchConversations(false, 'initialize'); // 4. 加载未读数 await this.fetchUnreadCount(); @@ -539,9 +567,16 @@ class MessageManager { const now = Date.now(); if (now - this.lastReconnectSyncAt > 1500) { this.lastReconnectSyncAt = now; - this.fetchConversations(true).catch(error => { - console.error('[MessageManager] 连接后同步会话失败:', error); - }); + // 聊天页活跃时不强刷会话列表,避免触发不必要的 UI 抖动/重算 + if (!this.state.currentConversationId) { + this.requestConversationListRefresh('sse-reconnect', { force: true, allowDefer: true }).catch(error => { + console.error('[MessageManager] 连接后同步会话失败:', error); + }); + } else { + this.fetchUnreadCount().catch(error => { + console.error('[MessageManager] 连接后同步未读数失败:', error); + }); + } if (this.state.currentConversationId) { this.fetchMessages(this.state.currentConversationId).catch(error => { console.error('[MessageManager] 连接后同步当前会话消息失败:', error); @@ -1146,11 +1181,30 @@ class MessageManager { /** * 获取会话列表(首屏/下拉刷新:优先网络游标;可回退 SQLite,对 UI 透明) */ - async fetchConversations(forceRefresh = false): Promise { + async fetchConversations(forceRefresh = false, source: string = 'unknown'): Promise { if (this.state.isLoadingConversations && !forceRefresh) { return; } + if (this.shouldDeferConversationRefresh(source, forceRefresh)) { + this.deferredConversationRefresh = true; + if (__DEV__) { + console.log('[MessageManager] defer fetchConversations', { + source, + activeConversationId: this.state.currentConversationId, + }); + } + return; + } + + if (__DEV__) { + console.log('[MessageManager] fetchConversations', { + source, + forceRefresh, + activeConversationId: this.state.currentConversationId, + }); + } + // 非强制刷新且内存为空:先用本地源暖机,便于离线/弱网先展示列表 if (!forceRefresh && this.state.conversations.size === 0) { const warmed = await this.hydrateConversationsFromLocalSource(); @@ -1304,21 +1358,29 @@ class MessageManager { const mergeMessages = (base: MessageResponse[], incoming: MessageResponse[]): MessageResponse[] => { return this.mergeMessagesById(base, incoming); }; + const existingMessagesAtStart = this.state.messagesMap.get(conversationId) || []; + const hasInMemoryMessages = existingMessagesAtStart.length > 0; + let baselineMaxSeq = hasInMemoryMessages + ? existingMessagesAtStart.reduce((max, m) => Math.max(max, m.seq || 0), 0) + : 0; // 1. 先从本地数据库加载(确保立即有数据展示) if (!afterSeq) { let localMessages: any[] = []; let localMaxSeq = 0; - try { - localMessages = await getMessagesByConversation(conversationId, 20); - localMaxSeq = await getMaxSeq(conversationId); - } catch (error) { - // 架构原则:本地存储异常不阻断在线同步 - console.warn('[MessageManager] 读取本地消息失败,回退到服务端同步:', error); + if (!hasInMemoryMessages) { + try { + localMessages = await getMessagesByConversation(conversationId, 20); + localMaxSeq = await getMaxSeq(conversationId); + baselineMaxSeq = localMaxSeq; + } catch (error) { + // 架构原则:本地存储异常不阻断在线同步 + console.warn('[MessageManager] 读取本地消息失败,回退到服务端同步:', error); + } } - if (localMessages.length > 0) { + if (!hasInMemoryMessages && localMessages.length > 0) { // 转换格式 const formattedMessages: MessageResponse[] = localMessages.map(m => ({ id: m.id, @@ -1343,7 +1405,7 @@ class MessageManager { }); // 异步填充 sender 信息 this.enrichMessagesWithSenderInfo(conversationId, formattedMessages); - } else { + } else if (!hasInMemoryMessages) { // 冷启动兜底:先下发空列表事件,避免 ChatScreen 长时间停留在 loading this.state.messagesMap.set(conversationId, []); this.notifySubscribers({ @@ -1434,8 +1496,8 @@ class MessageManager { // 2.2 再基于 localMaxSeq 做增量补齐(防止快照窗口不足导致漏更老的新消息) const snapshotMaxSeq = snapshotMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0); - if (snapshotMaxSeq > localMaxSeq) { - const incrementalResp = await messageService.getMessages(conversationId, localMaxSeq); + if (snapshotMaxSeq > baselineMaxSeq) { + const incrementalResp = await messageService.getMessages(conversationId, baselineMaxSeq); const newMessages = incrementalResp?.messages || []; if (newMessages.length > 0) { const existingMessages = this.state.messagesMap.get(conversationId) || []; @@ -2226,6 +2288,12 @@ class MessageManager { setActiveConversation(conversationId: string | null): void { const normalizedConversationId = conversationId ? this.normalizeConversationId(conversationId) : null; this.state.currentConversationId = normalizedConversationId; + if (!normalizedConversationId && this.deferredConversationRefresh) { + this.deferredConversationRefresh = false; + this.requestConversationListRefresh('deferred-after-chat', { force: true, allowDefer: false }).catch(error => { + console.error('[MessageManager] 延迟会话刷新失败:', error); + }); + } } /** diff --git a/src/stores/messageManagerHooks.ts b/src/stores/messageManagerHooks.ts index 3f65986..9f455ea 100644 --- a/src/stores/messageManagerHooks.ts +++ b/src/stores/messageManagerHooks.ts @@ -55,7 +55,10 @@ export function useConversations(): UseConversationsReturn { // 冷启动兜底:延迟做一次强制刷新,避免首次因时序问题拿到空列表 const coldStartSyncTimer = setTimeout(() => { - messageManager.fetchConversations(true).catch(error => { + messageManager.requestConversationListRefresh('hooks-initial-refresh', { + force: true, + allowDefer: true, + }).catch(error => { console.error('[useConversations] 冷启动强制刷新失败:', error); }); }, 1200); @@ -67,7 +70,10 @@ export function useConversations(): UseConversationsReturn { }, []); const refresh = useCallback(async () => { - await messageManager.fetchConversations(true); + await messageManager.requestConversationListRefresh('hooks-manual-refresh', { + force: true, + allowDefer: false, + }); }, []); const loadMore = useCallback(async () => { @@ -530,7 +536,10 @@ export function useMessageListRefresh(): UseMessageListRefreshReturn { setIsRefreshing(true); try { - await messageManager.fetchConversations(true); + await messageManager.requestConversationListRefresh('hooks-load-more-fallback', { + force: true, + allowDefer: false, + }); } catch (error) { console.error('[useMessageListRefresh] 刷新失败:', error); } finally {