Files
frontend/src/screens/message/NotificationsScreen.tsx
2026-03-16 17:47:10 +08:00

600 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 通知页 NotificationsScreen
* 胡萝卜BBS - 系统消息列表
* 使用新的系统消息API
* 支持响应式布局
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import {
View,
FlatList,
StyleSheet,
TouchableOpacity,
RefreshControl,
ActivityIndicator,
} 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 } 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 { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
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<NativeStackNavigationProp<RootStackParamList>>();
// 响应式布局
const { isDesktop, isTablet } = useResponsive();
const isWideScreen = useBreakpointGTE('lg');
const [messages, setMessages] = useState<SystemMessageResponse[]>([]);
const [activeType, setActiveType] = useState('all');
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
// 同一 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 fetchMessages = useCallback(async () => {
try {
setLoading(true);
const response = await messageService.getSystemMessages(50, 1);
// 添加防御性检查,确保 messages 数组存在
setMessages(response.messages || []);
setHasMore(response.has_more ?? false);
} catch (error) {
console.error('获取系统消息失败:', error);
} finally {
setLoading(false);
}
}, []);
// 获取未读数
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();
setMessages(prev => prev.map(m => ({ ...m, is_read: true })));
setUnreadCount(0);
setSystemUnreadCount(0);
// 同步更新全局 TabBar 红点
fetchMessageUnreadCount();
// 刷新消息列表
fetchMessages();
} catch (error) {
console.error('一键已读失败:', error);
}
}, [fetchMessages, fetchMessageUnreadCount, setSystemUnreadCount]);
// 页面加载和获得焦点时刷新,并自动标记所有消息为已读
useEffect(() => {
if (isFocused) {
fetchMessages();
fetchUnreadCount();
// 进入界面自动标记所有消息为已读
handleMarkAllRead();
}
}, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]);
// 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
useEffect(() => {
if (!isFocused && onBack) {
onBack();
}
}, [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([fetchMessages(), fetchUnreadCount()]);
setRefreshing(false);
}, [fetchMessages, fetchUnreadCount]);
// 加载更多
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore || messages.length === 0) return;
try {
setLoadingMore(true);
// 使用时间戳或seq作为游标分页后端使用page分页
const nextPage = Math.floor((messages.length / 20)) + 1;
const response = await messageService.getSystemMessages(20, nextPage);
// 添加防御性检查
const newMessages = response.messages || [];
setMessages(prev => [...prev, ...newMessages]);
setHasMore(response.has_more ?? false);
} catch (error) {
console.error('加载更多失败:', error);
} finally {
setLoadingMore(false);
}
}, [loadingMore, hasMore, messages]);
// 标记单条消息已读并处理导航
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 设为帖子IDlike_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);
setMessages(prev =>
prev.map(m => (String(m.id) === messageId ? { ...m, is_read: true } : m))
);
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 }) => (
<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>
);
};
// 渲染头部(包含返回按钮)
const renderHeader = () => {
// 大屏模式(>= lg 断点)隐藏返回按钮
const shouldShowBackButton = onBack && !isWideScreen;
return (
<View style={[styles.header, isWideScreen && styles.headerWide]}>
{shouldShowBackButton ? (
<TouchableOpacity
style={styles.backButton}
onPress={onBack}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
</TouchableOpacity>
) : (
<View style={styles.backButtonPlaceholder} />
)}
<Text style={[styles.headerTitle, ...(isWideScreen ? [styles.headerTitleWide] : [])]}>
</Text>
<View style={styles.backButtonPlaceholder} />
</View>
);
};
// 渲染空状态
const renderEmpty = () => (
<EmptyState
title="暂无通知"
description="关注一些用户来获取通知吧"
icon="bell-outline"
/>
);
return (
<SafeAreaView style={styles.container} edges={[]}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={900}>
{/* 头部 - 大屏模式下隐藏返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWide]}>
{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;
return (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
activeType === type.key && styles.filterTagActive,
isWideScreen && styles.filterTagWide,
]}
onPress={() => setActiveType(type.key)}
>
<Text
variant="body"
color={activeType === type.key ? colors.primary.main : colors.text.secondary}
>
{type.title}
</Text>
{count > 0 && (
<Text
variant="caption"
color={activeType === type.key ? colors.primary.main : colors.text.hint}
style={styles.filterCount}
>
{count}
</Text>
)}
</TouchableOpacity>
);
})}
</View>
{/* 消息列表 */}
{loading ? (
<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}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
)}
</ResponsiveContainer>
) : (
<>
{/* 头部 - 小屏模式下显示返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
<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;
return (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
activeType === type.key && styles.filterTagActive,
]}
onPress={() => setActiveType(type.key)}
>
<Text
variant="body"
color={activeType === type.key ? colors.primary.main : colors.text.secondary}
>
{type.title}
</Text>
{count > 0 && (
<Text
variant="caption"
color={activeType === type.key ? colors.primary.main : colors.text.hint}
style={styles.filterCount}
>
{count}
</Text>
)}
</TouchableOpacity>
);
})}
</View>
{/* 消息列表 */}
{loading ? (
<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}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
)}
</>
)}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
filterContainer: {
flexDirection: 'row',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
filterContainerWide: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
justifyContent: 'center',
},
filterTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
marginRight: spacing.sm,
borderRadius: 16,
backgroundColor: colors.background.default,
},
filterTagWide: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
marginRight: spacing.md,
},
filterTagActive: {
backgroundColor: colors.primary.light + '30',
},
filterCount: {
marginLeft: spacing.xs,
},
// Header 样式
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
headerWide: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.lg,
},
backButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'flex-start',
},
backButtonPlaceholder: {
width: 40,
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text.primary,
},
headerTitleWide: {
fontSize: 20,
},
listContent: {
flexGrow: 1,
},
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,
},
});