Files
frontend/src/screens/message/NotificationsScreen.tsx

630 lines
21 KiB
TypeScript
Raw Normal View History

/**
* NotificationsScreen
* BBS -
* 使 messageService.getSystemMessagesCursor
*
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import {
View,
FlatList,
StyleSheet,
TouchableOpacity,
RefreshControl,
ActivityIndicator,
2026-03-17 12:29:04 +08:00
Dimensions,
} from 'react-native';
2026-03-16 17:47:10 +08:00
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';
2026-03-16 17:47:10 +08:00
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 { 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']);
2026-03-16 17:47:10 +08:00
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>>();
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;
const [activeType, setActiveType] = useState('all');
const [unreadCount, setUnreadCount] = useState(0);
// 【游标分页】使用 useCursorPagination hook 获取系统消息列表
const {
items: messages,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
error: paginationError,
} = useCursorPagination(
async ({ cursor, pageSize }) => {
return await messageService.getSystemMessagesCursor({
cursor,
page_size: pageSize,
});
},
{ pageSize: 20 }
);
// 本地刷新状态仅用于下拉刷新的UI显示
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();
// 刷新消息列表
await refresh();
setUnreadCount(0);
setSystemUnreadCount(0);
// 同步更新全局 TabBar 红点
fetchMessageUnreadCount();
} catch (error) {
console.error('一键已读失败:', error);
}
}, [refresh, fetchMessageUnreadCount, setSystemUnreadCount]);
// 页面加载和获得焦点时刷新,并自动标记所有消息为已读
useEffect(() => {
if (isFocused) {
fetchUnreadCount();
// 进入界面自动标记所有消息为已读
handleMarkAllRead();
}
}, [isFocused, fetchUnreadCount, handleMarkAllRead]);
2026-03-16 17:47:10 +08:00
// 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
useEffect(() => {
2026-03-16 17:47:10 +08:00
if (!isFocused && onBack) {
onBack();
}
2026-03-16 17:47:10 +08:00
}, [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<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);
// 【游标分页】不再直接修改 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 }) => (
<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 ? (
<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 = () => (
2026-03-17 12:29:04 +08:00
<View style={[styles.emptyContainer, isWideScreen && styles.emptyContainerWeb]}>
<EmptyState
title="暂无通知"
description="关注一些用户来获取通知吧"
icon="bell-outline"
/>
</View>
);
2026-03-17 12:29:04 +08:00
// 如果还未hydrated显示加载占位符避免竞态条件导致的渲染问题
if (!isHydrated) {
return (
<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>
);
}
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
2026-03-17 12:29:04 +08:00
<ResponsiveContainer maxWidth={containerMaxWidth}>
2026-03-16 17:47:10 +08:00
{/* 头部 - 大屏模式下隐藏返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
2026-03-17 12:29:04 +08:00
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWideWeb]}>
{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>
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<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={onEndReached}
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()}
{/* 分类筛选 */}
<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>
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<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={onEndReached}
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,
},
2026-03-17 12:29:04 +08:00
filterContainerWideWeb: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
justifyContent: 'center',
2026-03-17 12:29:04 +08:00
backgroundColor: colors.background.paper,
},
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,
},
2026-03-16 17:47:10 +08:00
// 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,
},
2026-03-17 12:29:04 +08:00
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
emptyContainerWeb: {
minHeight: 500,
justifyContent: 'center',
paddingTop: 100,
},
});