/** * 通知页 NotificationsScreen * 胡萝卜BBS - 系统消息列表 * 【游标分页】使用 messageService.getSystemMessagesCursor * 支持响应式布局 */ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { View, FlatList, StyleSheet, TouchableOpacity, RefreshControl, ActivityIndicator, Dimensions, BackHandler, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useIsFocused } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { SystemMessageResponse } from '../../types/dto'; import { messageService } from '../../services/messageService'; import { commentService } from '../../services/commentService'; import { SystemMessageItem } from '../../components/business'; import { Text, EmptyState, ResponsiveContainer } from '../../components/common'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { RootStackParamList } from '../../navigation/types'; import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores'; const MESSAGE_TYPES = [ { key: 'all', title: '全部' }, { key: 'like_post', title: '点赞' }, { key: 'comment', title: '评论' }, { key: 'follow', title: '关注' }, { key: 'group', title: '群通知' }, { key: 'system', title: '系统' }, { key: 'announcement', title: '公告' }, ]; const LIKE_SYSTEM_TYPES = new Set(['like_post', 'like_comment', 'like_reply', 'favorite_post']); const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected']); export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => { const isFocused = useIsFocused(); const fetchMessageUnreadCount = useUserStore(state => state.fetchMessageUnreadCount); const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount(); const navigation = useNavigation>(); // 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题 const [windowSize, setWindowSize] = useState({ width: 0, height: 0 }); const [isHydrated, setIsHydrated] = useState(false); useEffect(() => { // 延迟设置窗口尺寸,确保在客户端完全加载后再获取 const timer = setTimeout(() => { const { width, height } = Dimensions.get('window'); setWindowSize({ width, height }); setIsHydrated(true); }, 100); return () => clearTimeout(timer); }, []); // 使用本地尺寸状态计算断点 (lg = 768) const isWideScreen = windowSize.width >= 768; const isDesktop = windowSize.width >= 1024; const isTablet = windowSize.width >= 768 && windowSize.width < 1024; // Web端使用更大的容器宽度 const containerMaxWidth = isDesktop ? 1200 : isTablet ? 1000 : 900; const [activeType, setActiveType] = useState('all'); const [unreadCount, setUnreadCount] = useState(0); // 【游标分页】使用 useCursorPagination hook 获取系统消息列表 // 使用 useCallback 包裹 fetchFunction,避免无限重新创建导致循环 const fetchSystemMessages = useCallback( async ({ cursor, pageSize }: { cursor?: string; pageSize: number }) => { return await messageService.getSystemMessagesCursor({ cursor, page_size: pageSize, }); }, [] ); const { items: messages, isLoading, isRefreshing, hasMore, loadMore, refresh, error: paginationError, } = useCursorPagination(fetchSystemMessages, { pageSize: 20 }); // 本地刷新状态(仅用于下拉刷新的UI显示) const [refreshing, setRefreshing] = useState(false); const [loadingMore, setLoadingMore] = useState(false); // 同一 flag 只要有人审批过,就将待处理消息同步展示为已处理状态 const displayMessages = useMemo(() => { const reviewedByFlag = new Map(); messages.forEach((msg) => { if (msg.system_type !== 'group_join_apply') return; const flag = msg.extra_data?.flag; const status = msg.extra_data?.request_status; if (!flag) return; if (status === 'accepted' || status === 'rejected') { reviewedByFlag.set(flag, msg); } }); if (reviewedByFlag.size === 0) { return messages; } return messages.map((msg) => { if (msg.system_type !== 'group_join_apply') return msg; const flag = msg.extra_data?.flag; if (!flag) return msg; const reviewedMsg = reviewedByFlag.get(flag); if (!reviewedMsg) return msg; const reviewedStatus = reviewedMsg.extra_data?.request_status; if (reviewedStatus !== 'accepted' && reviewedStatus !== 'rejected') return msg; return { ...msg, extra_data: { ...msg.extra_data, request_status: reviewedStatus, actor_name: reviewedMsg.extra_data?.actor_name || reviewedMsg.extra_data?.operator_name || msg.extra_data?.actor_name, avatar_url: reviewedMsg.extra_data?.avatar_url || reviewedMsg.extra_data?.operator_avatar || msg.extra_data?.avatar_url, }, }; }); }, [messages]); // 获取未读数 const fetchUnreadCount = useCallback(async () => { try { const response = await messageService.getSystemUnreadCount(); setUnreadCount(response.unread_count); } catch (error) { console.error('获取未读数失败:', error); } }, []); // 一键已读 const handleMarkAllRead = useCallback(async () => { try { await messageService.markAllSystemMessagesRead(); // 刷新消息列表 await refresh(); setUnreadCount(0); setSystemUnreadCount(0); // 同步更新全局 TabBar 红点 fetchMessageUnreadCount(); } catch (error) { console.error('一键已读失败:', error); } }, [refresh, fetchMessageUnreadCount, setSystemUnreadCount]); // 页面加载和获得焦点时刷新,并自动标记所有消息为已读 // 使用 ref 防止重复执行,避免无限循环 const hasInitializedRef = useRef(false); useEffect(() => { if (isFocused && !hasInitializedRef.current) { hasInitializedRef.current = true; fetchUnreadCount(); // 进入界面自动标记所有消息为已读 handleMarkAllRead(); } }, [isFocused]); // 只依赖 isFocused,函数使用 ref 稳定引用 // 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式) useEffect(() => { if (!isFocused && onBack) { onBack(); } }, [isFocused, onBack]); // 处理 Android 物理返回键 - 当 onBack 存在时(内嵌模式),拦截返回键 useEffect(() => { if (!onBack) return; const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { if (isFocused) { onBack(); return true; // 阻止默认行为 } return false; }); return () => backHandler.remove(); }, [isFocused, onBack]); // 筛选消息 const filteredMessages = activeType === 'all' ? displayMessages : displayMessages.filter((m) => { if (activeType === 'like_post') { return LIKE_SYSTEM_TYPES.has(m.system_type); } if (activeType === 'group') { return GROUP_SYSTEM_TYPES.has(m.system_type); } return m.system_type === activeType; }); // 下拉刷新 const onRefresh = useCallback(async () => { setRefreshing(true); await Promise.all([refresh(), fetchUnreadCount()]); setRefreshing(false); }, [refresh, fetchUnreadCount]); // 加载更多 const onEndReached = useCallback(async () => { if (isLoading || loadingMore || !hasMore) return; setLoadingMore(true); await loadMore(); setLoadingMore(false); }, [isLoading, loadingMore, hasMore, loadMore]); // 标记单条消息已读并处理导航 const extractPostIdFromActionUrl = (actionUrl?: string): string | null => { if (!actionUrl) return null; const postPathMatch = actionUrl.match(/\/posts\/([^/?#]+)/); if (postPathMatch?.[1]) { return decodeURIComponent(postPathMatch[1]); } const query = actionUrl.split('?')[1]; if (!query) return null; const params = new URLSearchParams(query); return params.get('post') || params.get('post_id'); }; const resolvePostId = async (message: SystemMessageResponse): Promise => { const { extra_data, system_type } = message; // 后端已统一将 target_id 设为帖子ID(like_post/comment/mention/favorite_post/like_reply/like_comment) if ( extra_data?.target_id && ['like_post', 'like_comment', 'like_reply', 'comment', 'mention', 'favorite_post'].includes(system_type) ) { return extra_data.target_id; } // reply 通知:target_id 是 replyID,从 action_url 解析帖子ID const postIdFromUrl = extractPostIdFromActionUrl(extra_data?.action_url); if (postIdFromUrl) { return postIdFromUrl; } // 兜底:通过评论详情反查(仅旧数据兜底) if (extra_data?.target_id && system_type === 'reply') { const comment = await commentService.getComment(extra_data.target_id); if (comment?.post_id) { return comment.post_id; } } return null; }; const handleMessagePress = async (message: SystemMessageResponse) => { try { const messageId = String(message.id); const wasUnread = message.is_read !== true; await messageService.markSystemMessageRead(messageId); // 【游标分页】不再直接修改 messages 状态,而是通过刷新获取最新数据 if (wasUnread) { setUnreadCount(prev => Math.max(0, prev - 1)); decrementSystemUnreadCount(1); } // 更新本地未读数以及全局 TabBar 红点 fetchUnreadCount(); fetchMessageUnreadCount(); // 根据消息类型处理导航 const { system_type, extra_data } = message; if ( ['like_post', 'comment', 'mention', 'reply', 'like_comment', 'like_reply', 'favorite_post'].includes(system_type) ) { const postId = await resolvePostId(message); if (postId) { navigation.navigate('PostDetail', { postId }); } } else if (system_type === 'follow') { // 关注 - 跳转到用户主页 if (extra_data?.actor_id_str) { navigation.navigate('UserProfile', { userId: extra_data.actor_id_str }); } } else if (system_type === 'group_join_apply') { navigation.navigate('GroupRequestDetail', { message }); } else if (system_type === 'group_invite') { navigation.navigate('GroupInviteDetail', { message }); } // 其他类型暂不处理跳转 } catch (error) { console.error('标记已读失败:', error); } }; // 处理头像点击 - 跳转到用户主页 const handleAvatarPress = (message: SystemMessageResponse) => { const { extra_data } = message; // 优先使用 actor_id_str (UUID格式),兼容 actor_id (数字格式) const actorId = extra_data?.actor_id_str || (extra_data?.actor_id ? String(extra_data.actor_id) : null); if (actorId) { navigation.navigate('UserProfile', { userId: actorId }); } }; // 渲染消息项 const renderMessage = ({ item }: { item: SystemMessageResponse }) => ( handleMessagePress(item)} onAvatarPress={() => handleAvatarPress(item)} /> ); // 渲染底部加载指示器 const renderFooter = () => { if (!loadingMore) return null; return ( 加载中... ); }; // 渲染头部(包含返回按钮) const renderHeader = () => { // 大屏模式(>= lg 断点)隐藏返回按钮 const shouldShowBackButton = onBack && !isWideScreen; return ( {shouldShowBackButton ? ( ) : ( )} 系统通知 {unreadCount > 0 && ( {unreadCount > 99 ? '99+' : unreadCount} )} ); }; // 渲染空状态 const renderEmpty = () => ( ); // 如果还未hydrated,显示加载占位符,避免竞态条件导致的渲染问题 if (!isHydrated) { return ( ); } return ( {isWideScreen ? ( {/* 头部 - 大屏模式下隐藏返回按钮 */} {renderHeader()} {/* 分类筛选 */} {MESSAGE_TYPES.map(type => { const count = type.key === 'all' ? displayMessages.length : displayMessages.filter((m) => { if (type.key === 'like_post') { return LIKE_SYSTEM_TYPES.has(m.system_type); } if (type.key === 'group') { return GROUP_SYSTEM_TYPES.has(m.system_type); } return m.system_type === type.key; }).length; const isActive = activeType === type.key; return ( setActiveType(type.key)} activeOpacity={0.8} > {type.title} {count > 0 && ( {count} )} ); })} {/* 消息列表 */} {isLoading && messages.length === 0 ? ( ) : ( item.id.toString()} contentContainerStyle={[styles.listContent, isWideScreen && styles.listContentWide]} showsVerticalScrollIndicator={false} ListEmptyComponent={renderEmpty} ListFooterComponent={renderFooter} onEndReached={onEndReached} onEndReachedThreshold={0.3} refreshControl={ } /> )} ) : ( <> {/* 头部 - 小屏模式下显示返回按钮 */} {renderHeader()} {/* 分类筛选 */} {MESSAGE_TYPES.map(type => { const count = type.key === 'all' ? displayMessages.length : displayMessages.filter((m) => { if (type.key === 'like_post') { return LIKE_SYSTEM_TYPES.has(m.system_type); } if (type.key === 'group') { return GROUP_SYSTEM_TYPES.has(m.system_type); } return m.system_type === type.key; }).length; const isActive = activeType === type.key; return ( setActiveType(type.key)} activeOpacity={0.8} > {type.title} {count > 0 && ( {count} )} ); })} {/* 消息列表 */} {isLoading && messages.length === 0 ? ( ) : ( item.id.toString()} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} ListEmptyComponent={renderEmpty} ListFooterComponent={renderFooter} onEndReached={onEndReached} onEndReachedThreshold={0.3} refreshControl={ } /> )} )} ); }; const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.background.default, }, // Header 样式 - 美化版 header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing.lg, paddingVertical: spacing.md, backgroundColor: colors.background.paper, ...shadows.sm, }, headerWide: { paddingHorizontal: spacing.xl, paddingVertical: spacing.lg, }, headerTitleContainer: { flexDirection: 'row', alignItems: 'center', flex: 1, justifyContent: 'center', }, headerTitle: { fontSize: 18, fontWeight: '600', color: colors.text.primary, }, headerTitleWide: { fontSize: 20, }, unreadBadge: { backgroundColor: colors.primary.main, borderRadius: 10, paddingHorizontal: 6, paddingVertical: 2, marginLeft: spacing.sm, minWidth: 20, alignItems: 'center', justifyContent: 'center', }, unreadBadgeText: { color: colors.text.inverse, fontSize: 11, fontWeight: '600', }, backButton: { width: 40, height: 40, justifyContent: 'center', alignItems: 'flex-start', }, backButtonPlaceholder: { width: 40, }, // 分类筛选 - 美化版(使用分段式设计) filterContainer: { flexDirection: 'row', paddingHorizontal: spacing.md, paddingVertical: spacing.sm, backgroundColor: colors.background.paper, }, filterContainerWideWeb: { paddingHorizontal: spacing.xl, paddingVertical: spacing.md, justifyContent: 'center', backgroundColor: colors.background.paper, }, filterTag: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.md, paddingVertical: spacing.sm, marginRight: spacing.xs, borderRadius: borderRadius.lg, backgroundColor: colors.background.default, borderWidth: 1, borderColor: 'transparent', }, filterTagWide: { paddingHorizontal: spacing.lg, paddingVertical: spacing.md, marginRight: spacing.sm, }, filterTagActive: { backgroundColor: colors.primary.main, borderColor: colors.primary.main, }, filterTagTextActive: { fontWeight: '600', }, filterCount: { marginLeft: spacing.xs, backgroundColor: colors.primary.light + '40', paddingHorizontal: 6, paddingVertical: 1, borderRadius: 8, minWidth: 18, alignItems: 'center', }, filterCountActive: { backgroundColor: 'rgba(255, 255, 255, 0.25)', }, listContent: { flexGrow: 1, paddingTop: spacing.md, paddingBottom: spacing.lg, }, listContentWide: { paddingHorizontal: spacing.xl, }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingVertical: spacing.xl * 2, }, loadingMore: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', paddingVertical: spacing.lg, }, loadingMoreText: { marginLeft: spacing.sm, }, emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: spacing['3xl'], }, emptyContainerWeb: { minHeight: 500, justifyContent: 'center', paddingTop: 100, }, });