2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 通知页 NotificationsScreen
|
|
|
|
|
|
* 胡萝卜BBS - 系统消息列表
|
2026-03-20 23:00:27 +08:00
|
|
|
|
* 【游标分页】使用 messageService.getSystemMessagesCursor
|
2026-03-09 21:29:03 +08:00
|
|
|
|
* 支持响应式布局
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-21 03:22:28 +08:00
|
|
|
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import {
|
|
|
|
|
|
View,
|
|
|
|
|
|
FlatList,
|
|
|
|
|
|
StyleSheet,
|
|
|
|
|
|
TouchableOpacity,
|
|
|
|
|
|
RefreshControl,
|
|
|
|
|
|
ActivityIndicator,
|
2026-03-17 12:29:04 +08:00
|
|
|
|
Dimensions,
|
2026-03-21 12:08:45 +08:00
|
|
|
|
BackHandler,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} from 'react-native';
|
2026-03-25 05:16:54 +08:00
|
|
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import { useIsFocused } from '@react-navigation/native';
|
2026-03-24 14:21:31 +08:00
|
|
|
|
import { useRouter } from 'expo-router';
|
2026-03-16 17:47:10 +08:00
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
2026-03-25 05:16:54 +08:00
|
|
|
|
import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import { SystemMessageResponse } from '../../types/dto';
|
|
|
|
|
|
import { messageService } from '../../services/messageService';
|
|
|
|
|
|
import { commentService } from '../../services/commentService';
|
|
|
|
|
|
import { SystemMessageItem } from '../../components/business';
|
2026-03-24 14:21:31 +08:00
|
|
|
|
import { Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common';
|
2026-03-20 23:00:27 +08:00
|
|
|
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
2026-03-24 14:21:31 +08:00
|
|
|
|
import * as hrefs from '../../navigation/hrefs';
|
refactor: migrate post operations to use case architecture with differential updates
Migrate post-related operations from direct service/store calls to a new use case architecture featuring differential updates. Key changes include: (1) introduce ProcessPostUseCase to handle like, unlike, favorite, unfavorite, delete, and fetch operations with proper state management, (2) add useDifferentialPosts hook for efficient list rendering with minimal re-renders, (3) add PostDiffCalculator and PostUpdateBatcher in infrastructure layer for incremental updates, (4) deprecate NotificationItem component in favor of SystemMessageItem, (5) remove deprecated interactive methods from userStore, (6) add posts_cache table to local datasource, (7) clean up legacy MessageDTO and ConversationDTO types, (8) remove deprecated hook useBreakpointCheck, (9) refactor MessageBubble to use measureInWindow directly for better React Native compatibility
2026-03-21 20:55:36 +08:00
|
|
|
|
import { useMessageManagerSystemUnreadCount } from '../../stores';
|
|
|
|
|
|
import { messageManager } from '../../stores/messageManager';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
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']);
|
|
|
|
|
|
|
2026-03-16 17:47:10 +08:00
|
|
|
|
export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => {
|
2026-03-25 05:16:54 +08:00
|
|
|
|
const colors = useAppColors();
|
|
|
|
|
|
const styles = useMemo(() => createNotificationsStyles(colors), [colors]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const isFocused = useIsFocused();
|
|
|
|
|
|
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
|
2026-03-24 14:21:31 +08:00
|
|
|
|
const router = useRouter();
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
2026-03-17 12:29:04 +08:00
|
|
|
|
// 修复竞态条件:使用本地状态管理窗口尺寸,避免 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;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
const [activeType, setActiveType] = useState('all');
|
2026-03-20 23:00:27 +08:00
|
|
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
|
|
|
|
|
|
|
|
|
|
// 【游标分页】使用 useCursorPagination hook 获取系统消息列表
|
2026-03-21 03:22:28 +08:00
|
|
|
|
// 使用 useCallback 包裹 fetchFunction,避免无限重新创建导致循环
|
|
|
|
|
|
const fetchSystemMessages = useCallback(
|
|
|
|
|
|
async ({ cursor, pageSize }: { cursor?: string; pageSize: number }) => {
|
|
|
|
|
|
return await messageService.getSystemMessagesCursor({
|
|
|
|
|
|
cursor,
|
|
|
|
|
|
page_size: pageSize,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
[]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-20 23:00:27 +08:00
|
|
|
|
const {
|
2026-03-23 03:58:26 +08:00
|
|
|
|
list: messages,
|
2026-03-20 23:00:27 +08:00
|
|
|
|
isLoading,
|
|
|
|
|
|
isRefreshing,
|
|
|
|
|
|
hasMore,
|
|
|
|
|
|
loadMore,
|
|
|
|
|
|
refresh,
|
|
|
|
|
|
error: paginationError,
|
2026-03-21 03:22:28 +08:00
|
|
|
|
} = useCursorPagination(fetchSystemMessages, { pageSize: 20 });
|
2026-03-20 23:00:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 本地刷新状态(仅用于下拉刷新的UI显示)
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// 同一 flag 只要有人审批过,就将待处理消息同步展示为已处理状态
|
|
|
|
|
|
const displayMessages = useMemo(() => {
|
|
|
|
|
|
const reviewedByFlag = new Map<string, SystemMessageResponse>();
|
|
|
|
|
|
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();
|
2026-03-20 23:00:27 +08:00
|
|
|
|
// 刷新消息列表
|
|
|
|
|
|
await refresh();
|
2026-03-09 21:29:03 +08:00
|
|
|
|
setUnreadCount(0);
|
|
|
|
|
|
setSystemUnreadCount(0);
|
|
|
|
|
|
// 同步更新全局 TabBar 红点
|
refactor: migrate post operations to use case architecture with differential updates
Migrate post-related operations from direct service/store calls to a new use case architecture featuring differential updates. Key changes include: (1) introduce ProcessPostUseCase to handle like, unlike, favorite, unfavorite, delete, and fetch operations with proper state management, (2) add useDifferentialPosts hook for efficient list rendering with minimal re-renders, (3) add PostDiffCalculator and PostUpdateBatcher in infrastructure layer for incremental updates, (4) deprecate NotificationItem component in favor of SystemMessageItem, (5) remove deprecated interactive methods from userStore, (6) add posts_cache table to local datasource, (7) clean up legacy MessageDTO and ConversationDTO types, (8) remove deprecated hook useBreakpointCheck, (9) refactor MessageBubble to use measureInWindow directly for better React Native compatibility
2026-03-21 20:55:36 +08:00
|
|
|
|
messageManager.fetchUnreadCount();
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('一键已读失败:', error);
|
|
|
|
|
|
}
|
refactor: migrate post operations to use case architecture with differential updates
Migrate post-related operations from direct service/store calls to a new use case architecture featuring differential updates. Key changes include: (1) introduce ProcessPostUseCase to handle like, unlike, favorite, unfavorite, delete, and fetch operations with proper state management, (2) add useDifferentialPosts hook for efficient list rendering with minimal re-renders, (3) add PostDiffCalculator and PostUpdateBatcher in infrastructure layer for incremental updates, (4) deprecate NotificationItem component in favor of SystemMessageItem, (5) remove deprecated interactive methods from userStore, (6) add posts_cache table to local datasource, (7) clean up legacy MessageDTO and ConversationDTO types, (8) remove deprecated hook useBreakpointCheck, (9) refactor MessageBubble to use measureInWindow directly for better React Native compatibility
2026-03-21 20:55:36 +08:00
|
|
|
|
}, [refresh, setSystemUnreadCount]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 页面加载和获得焦点时刷新,并自动标记所有消息为已读
|
2026-03-21 03:22:28 +08:00
|
|
|
|
// 使用 ref 防止重复执行,避免无限循环
|
|
|
|
|
|
const hasInitializedRef = useRef(false);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
useEffect(() => {
|
2026-03-21 03:22:28 +08:00
|
|
|
|
if (isFocused && !hasInitializedRef.current) {
|
|
|
|
|
|
hasInitializedRef.current = true;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
fetchUnreadCount();
|
|
|
|
|
|
// 进入界面自动标记所有消息为已读
|
|
|
|
|
|
handleMarkAllRead();
|
|
|
|
|
|
}
|
2026-03-21 03:22:28 +08:00
|
|
|
|
}, [isFocused]); // 只依赖 isFocused,函数使用 ref 稳定引用
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
2026-03-16 17:47:10 +08:00
|
|
|
|
// 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
|
2026-03-09 21:29:03 +08:00
|
|
|
|
useEffect(() => {
|
2026-03-16 17:47:10 +08:00
|
|
|
|
if (!isFocused && onBack) {
|
|
|
|
|
|
onBack();
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
2026-03-16 17:47:10 +08:00
|
|
|
|
}, [isFocused, onBack]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
2026-03-21 12:08:45 +08:00
|
|
|
|
// 处理 Android 物理返回键 - 当 onBack 存在时(内嵌模式),拦截返回键
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!onBack) return;
|
|
|
|
|
|
|
|
|
|
|
|
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
|
|
|
|
if (isFocused) {
|
|
|
|
|
|
onBack();
|
|
|
|
|
|
return true; // 阻止默认行为
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return () => backHandler.remove();
|
|
|
|
|
|
}, [isFocused, onBack]);
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 筛选消息
|
|
|
|
|
|
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);
|
2026-03-20 23:00:27 +08:00
|
|
|
|
await Promise.all([refresh(), fetchUnreadCount()]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
setRefreshing(false);
|
2026-03-20 23:00:27 +08:00
|
|
|
|
}, [refresh, fetchUnreadCount]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 加载更多
|
2026-03-20 23:00:27 +08:00
|
|
|
|
const onEndReached = useCallback(async () => {
|
|
|
|
|
|
if (isLoading || loadingMore || !hasMore) return;
|
|
|
|
|
|
setLoadingMore(true);
|
|
|
|
|
|
await loadMore();
|
|
|
|
|
|
setLoadingMore(false);
|
|
|
|
|
|
}, [isLoading, loadingMore, hasMore, loadMore]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 标记单条消息已读并处理导航
|
|
|
|
|
|
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<string | null> => {
|
|
|
|
|
|
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);
|
2026-03-20 23:00:27 +08:00
|
|
|
|
// 【游标分页】不再直接修改 messages 状态,而是通过刷新获取最新数据
|
2026-03-09 21:29:03 +08:00
|
|
|
|
if (wasUnread) {
|
|
|
|
|
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
|
|
|
|
|
decrementSystemUnreadCount(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
// 更新本地未读数以及全局 TabBar 红点
|
|
|
|
|
|
fetchUnreadCount();
|
refactor: migrate post operations to use case architecture with differential updates
Migrate post-related operations from direct service/store calls to a new use case architecture featuring differential updates. Key changes include: (1) introduce ProcessPostUseCase to handle like, unlike, favorite, unfavorite, delete, and fetch operations with proper state management, (2) add useDifferentialPosts hook for efficient list rendering with minimal re-renders, (3) add PostDiffCalculator and PostUpdateBatcher in infrastructure layer for incremental updates, (4) deprecate NotificationItem component in favor of SystemMessageItem, (5) remove deprecated interactive methods from userStore, (6) add posts_cache table to local datasource, (7) clean up legacy MessageDTO and ConversationDTO types, (8) remove deprecated hook useBreakpointCheck, (9) refactor MessageBubble to use measureInWindow directly for better React Native compatibility
2026-03-21 20:55:36 +08:00
|
|
|
|
messageManager.fetchUnreadCount();
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 根据消息类型处理导航
|
|
|
|
|
|
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) {
|
2026-03-24 14:21:31 +08:00
|
|
|
|
router.push(hrefs.hrefPostDetail(postId));
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else if (system_type === 'follow') {
|
|
|
|
|
|
// 关注 - 跳转到用户主页
|
|
|
|
|
|
if (extra_data?.actor_id_str) {
|
2026-03-24 14:21:31 +08:00
|
|
|
|
router.push(hrefs.hrefUserProfile(extra_data.actor_id_str));
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else if (system_type === 'group_join_apply') {
|
2026-03-24 14:21:31 +08:00
|
|
|
|
router.push(hrefs.hrefGroupRequestDetail(message));
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} else if (system_type === 'group_invite') {
|
2026-03-24 14:21:31 +08:00
|
|
|
|
router.push(hrefs.hrefGroupInviteDetail(message));
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 其他类型暂不处理跳转
|
|
|
|
|
|
} 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) {
|
2026-03-24 14:21:31 +08:00
|
|
|
|
router.push(hrefs.hrefUserProfile(actorId));
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染消息项
|
|
|
|
|
|
const renderMessage = ({ item }: { item: SystemMessageResponse }) => (
|
|
|
|
|
|
<SystemMessageItem
|
|
|
|
|
|
message={item}
|
|
|
|
|
|
onPress={() => handleMessagePress(item)}
|
|
|
|
|
|
onAvatarPress={() => handleAvatarPress(item)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染底部加载指示器
|
|
|
|
|
|
const renderFooter = () => {
|
|
|
|
|
|
if (!loadingMore) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View style={styles.loadingMore}>
|
|
|
|
|
|
<ActivityIndicator size="small" color={colors.primary.main} />
|
|
|
|
|
|
<Text variant="caption" color={colors.text.secondary} style={styles.loadingMoreText}>
|
|
|
|
|
|
加载中...
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-16 17:47:10 +08:00
|
|
|
|
// 渲染头部(包含返回按钮)
|
|
|
|
|
|
const renderHeader = () => {
|
|
|
|
|
|
// 大屏模式(>= lg 断点)隐藏返回按钮
|
|
|
|
|
|
const shouldShowBackButton = onBack && !isWideScreen;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-17 12:29:04 +08:00
|
|
|
|
<View style={[styles.header, isWideScreen ? styles.headerWide : null]}>
|
2026-03-16 17:47:10 +08:00
|
|
|
|
{shouldShowBackButton ? (
|
2026-03-24 14:21:31 +08:00
|
|
|
|
<AppBackButton style={styles.backButton} onPress={onBack} />
|
2026-03-16 17:47:10 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
<View style={styles.backButtonPlaceholder} />
|
|
|
|
|
|
)}
|
2026-03-21 03:22:28 +08:00
|
|
|
|
<View style={styles.headerTitleContainer}>
|
|
|
|
|
|
<Text style={[styles.headerTitle, ...(isWideScreen ? [styles.headerTitleWide] : [])]}>
|
|
|
|
|
|
系统通知
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
{unreadCount > 0 && (
|
|
|
|
|
|
<View style={styles.unreadBadge}>
|
|
|
|
|
|
<Text style={styles.unreadBadgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
2026-03-16 17:47:10 +08:00
|
|
|
|
<View style={styles.backButtonPlaceholder} />
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 渲染空状态
|
|
|
|
|
|
const renderEmpty = () => (
|
2026-03-17 12:29:04 +08:00
|
|
|
|
<View style={[styles.emptyContainer, isWideScreen && styles.emptyContainerWeb]}>
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
title="暂无通知"
|
|
|
|
|
|
description="关注一些用户来获取通知吧"
|
|
|
|
|
|
icon="bell-outline"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-17 12:29:04 +08:00
|
|
|
|
// 如果还未hydrated,显示加载占位符,避免竞态条件导致的渲染问题
|
|
|
|
|
|
if (!isHydrated) {
|
|
|
|
|
|
return (
|
2026-03-18 10:58:41 +08:00
|
|
|
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
2026-03-17 12:29:04 +08:00
|
|
|
|
<View style={styles.loadingContainer}>
|
|
|
|
|
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</SafeAreaView>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
return (
|
2026-03-18 10:58:41 +08:00
|
|
|
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
{isWideScreen ? (
|
2026-03-17 12:29:04 +08:00
|
|
|
|
<ResponsiveContainer maxWidth={containerMaxWidth}>
|
2026-03-16 17:47:10 +08:00
|
|
|
|
{/* 头部 - 大屏模式下隐藏返回按钮 */}
|
|
|
|
|
|
{renderHeader()}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
{/* 分类筛选 */}
|
2026-03-17 12:29:04 +08:00
|
|
|
|
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWideWeb]}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
{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;
|
2026-03-21 03:22:28 +08:00
|
|
|
|
const isActive = activeType === type.key;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
key={type.key}
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.filterTag,
|
2026-03-21 03:22:28 +08:00
|
|
|
|
isActive && styles.filterTagActive,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
isWideScreen && styles.filterTagWide,
|
|
|
|
|
|
]}
|
|
|
|
|
|
onPress={() => setActiveType(type.key)}
|
2026-03-21 03:22:28 +08:00
|
|
|
|
activeOpacity={0.8}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="body"
|
2026-03-21 03:22:28 +08:00
|
|
|
|
color={isActive ? colors.text.inverse : colors.text.secondary}
|
|
|
|
|
|
style={isActive ? styles.filterTagTextActive : undefined}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
>
|
|
|
|
|
|
{type.title}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
{count > 0 && (
|
2026-03-21 03:22:28 +08:00
|
|
|
|
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="caption"
|
|
|
|
|
|
color={isActive ? colors.text.inverse : colors.text.hint}
|
|
|
|
|
|
>
|
|
|
|
|
|
{count}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 消息列表 */}
|
2026-03-20 23:00:27 +08:00
|
|
|
|
{isLoading && messages.length === 0 ? (
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<View style={styles.loadingContainer}>
|
|
|
|
|
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
|
|
|
|
|
</View>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<FlatList
|
|
|
|
|
|
data={filteredMessages}
|
|
|
|
|
|
renderItem={renderMessage}
|
|
|
|
|
|
keyExtractor={item => item.id.toString()}
|
|
|
|
|
|
contentContainerStyle={[styles.listContent, isWideScreen && styles.listContentWide]}
|
|
|
|
|
|
showsVerticalScrollIndicator={false}
|
|
|
|
|
|
ListEmptyComponent={renderEmpty}
|
|
|
|
|
|
ListFooterComponent={renderFooter}
|
2026-03-20 23:00:27 +08:00
|
|
|
|
onEndReached={onEndReached}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
onEndReachedThreshold={0.3}
|
|
|
|
|
|
refreshControl={
|
|
|
|
|
|
<RefreshControl
|
|
|
|
|
|
refreshing={refreshing}
|
|
|
|
|
|
onRefresh={onRefresh}
|
|
|
|
|
|
colors={[colors.primary.main]}
|
|
|
|
|
|
tintColor={colors.primary.main}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-03-16 17:47:10 +08:00
|
|
|
|
{/* 头部 - 小屏模式下显示返回按钮 */}
|
|
|
|
|
|
{renderHeader()}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
{/* 分类筛选 */}
|
|
|
|
|
|
<View style={styles.filterContainer}>
|
|
|
|
|
|
{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;
|
2026-03-21 03:22:28 +08:00
|
|
|
|
const isActive = activeType === type.key;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
key={type.key}
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.filterTag,
|
2026-03-21 03:22:28 +08:00
|
|
|
|
isActive && styles.filterTagActive,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
]}
|
|
|
|
|
|
onPress={() => setActiveType(type.key)}
|
2026-03-21 03:22:28 +08:00
|
|
|
|
activeOpacity={0.8}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="body"
|
2026-03-21 03:22:28 +08:00
|
|
|
|
color={isActive ? colors.text.inverse : colors.text.secondary}
|
|
|
|
|
|
style={isActive ? styles.filterTagTextActive : undefined}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
>
|
|
|
|
|
|
{type.title}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
{count > 0 && (
|
2026-03-21 03:22:28 +08:00
|
|
|
|
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="caption"
|
|
|
|
|
|
color={isActive ? colors.text.inverse : colors.text.hint}
|
|
|
|
|
|
>
|
|
|
|
|
|
{count}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 消息列表 */}
|
2026-03-20 23:00:27 +08:00
|
|
|
|
{isLoading && messages.length === 0 ? (
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<View style={styles.loadingContainer}>
|
|
|
|
|
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
|
|
|
|
|
</View>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<FlatList
|
|
|
|
|
|
data={filteredMessages}
|
|
|
|
|
|
renderItem={renderMessage}
|
|
|
|
|
|
keyExtractor={item => item.id.toString()}
|
|
|
|
|
|
contentContainerStyle={styles.listContent}
|
|
|
|
|
|
showsVerticalScrollIndicator={false}
|
|
|
|
|
|
ListEmptyComponent={renderEmpty}
|
|
|
|
|
|
ListFooterComponent={renderFooter}
|
2026-03-20 23:00:27 +08:00
|
|
|
|
onEndReached={onEndReached}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
onEndReachedThreshold={0.3}
|
|
|
|
|
|
refreshControl={
|
|
|
|
|
|
<RefreshControl
|
|
|
|
|
|
refreshing={refreshing}
|
|
|
|
|
|
onRefresh={onRefresh}
|
|
|
|
|
|
colors={[colors.primary.main]}
|
|
|
|
|
|
tintColor={colors.primary.main}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SafeAreaView>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-25 05:16:54 +08:00
|
|
|
|
function createNotificationsStyles(colors: AppColors) {
|
|
|
|
|
|
return 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,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|