diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx index 79cb809..0150ea8 100644 --- a/app/(app)/(tabs)/_layout.tsx +++ b/app/(app)/(tabs)/_layout.tsx @@ -1,11 +1,12 @@ +import { useMemo } from 'react'; import { Platform, useWindowDimensions } from 'react-native'; -import { Tabs } from 'expo-router'; +import { Tabs, usePathname } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors, shadows } from '../../../src/theme'; import { BREAKPOINTS } from '../../../src/hooks/useResponsive'; -import { useTotalUnreadCount } from '../../../src/stores'; +import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores'; const TAB_BAR_HEIGHT = 56; const TAB_BAR_FLOATING_MARGIN = 12; @@ -14,31 +15,50 @@ const TAB_BAR_MARGIN = 20; export default function TabsLayout() { const { width } = useWindowDimensions(); const insets = useSafeAreaInsets(); + const pathname = usePathname(); const unreadCount = useTotalUnreadCount(); + const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop; + const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/'); + + const tabBarStyle = useMemo(() => { + if (hideTabBar) { + return { display: 'none' as const, height: 0, overflow: 'hidden' as const }; + } + const visibleStyle = { + position: 'absolute' as const, + left: 0, + right: 0, + marginHorizontal: TAB_BAR_MARGIN, + bottom: TAB_BAR_FLOATING_MARGIN + insets.bottom, + height: TAB_BAR_HEIGHT, + borderRadius: 22, + backgroundColor: colors.background.paper, + ...shadows.lg, + borderTopWidth: 0, + elevation: 100, + zIndex: 100, + }; + if (isHomeStackRoute && scrollHideTabBar) { + return { + ...visibleStyle, + display: 'none' as const, + height: 0, + opacity: 0, + overflow: 'hidden' as const, + }; + } + return visibleStyle; + }, [hideTabBar, insets.bottom, isHomeStackRoute, scrollHideTabBar]); + return ( 0 ? (unreadCount > 99 ? 99 : unreadCount) : undefined, + title: '应用', tabBarIcon: ({ color, focused }) => ( @@ -76,12 +95,13 @@ export default function TabsLayout() { }} /> 0 ? (unreadCount > 99 ? 99 : unreadCount) : undefined, tabBarIcon: ({ color, focused }) => ( diff --git a/app/_layout.tsx b/app/_layout.tsx index 1cdf676..ef03b99 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,6 +8,7 @@ import { PaperProvider } from 'react-native-paper'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as Notifications from 'expo-notifications'; +import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences'; import { paperTheme } from '../src/theme'; import { AppBackButton } from '../src/components/common'; import AppPromptBar from '../src/components/common/AppPromptBar'; @@ -16,15 +17,7 @@ import { installAlertOverride } from '../src/services/alertOverride'; import { useAuthStore } from '../src/stores'; import { colors } from '../src/theme'; -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: true, - shouldShowBanner: true, - shouldShowList: true, - }), -}); +registerNotificationPresentationHandler(); const queryClient = new QueryClient({ defaultOptions: { @@ -78,6 +71,8 @@ function NotificationBootstrap() { useEffect(() => { const initNotifications = async () => { + const { loadNotificationPreferences } = await import('../src/services/notificationPreferences'); + await loadNotificationPreferences(); const { systemNotificationService } = await import('../src/services/systemNotificationService'); await systemNotificationService.initialize(); const { initBackgroundService } = await import('../src/services/backgroundService'); diff --git a/src/app-navigation/AppDesktopShell.tsx b/src/app-navigation/AppDesktopShell.tsx index 8b7fff2..e9e5d9e 100644 --- a/src/app-navigation/AppDesktopShell.tsx +++ b/src/app-navigation/AppDesktopShell.tsx @@ -23,8 +23,8 @@ const SIDEBAR_COLLAPSED_WIDTH = 72; const NAV_ITEMS: { name: TabName; label: string; href: string; icon: string; iconOutline: string }[] = [ { name: 'HomeTab', label: '首页', href: '/home', icon: 'home', iconOutline: 'home-outline' }, - { name: 'MessageTab', label: '消息', href: '/messages', icon: 'message-text', iconOutline: 'message-text-outline' }, { name: 'AppsTab', label: '应用', href: '/apps', icon: 'view-grid', iconOutline: 'view-grid-outline' }, + { name: 'MessageTab', label: '消息', href: '/messages', icon: 'message-text', iconOutline: 'message-text-outline' }, { name: 'ProfileTab', label: '我的', href: '/profile', icon: 'account', iconOutline: 'account-outline' }, ]; diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index 97ca349..6507764 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -346,14 +346,6 @@ const PostCardInner: React.FC = (normalizedProps) => { {!showGrid && ( - {!!post.channel?.name && ( - - - - {post.channel.name} - - - )} {post.is_pinned && ( @@ -402,9 +394,19 @@ const PostCardInner: React.FC = (normalizedProps) => { {renderTopComment()} - - - {post.views_count || 0} + + + + {post.views_count || 0} + + {!!post.channel?.name && ( + + + + {post.channel.name} + + + )} @@ -571,9 +573,22 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', alignItems: 'center', }, + actionsLeading: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + minWidth: 0, + marginRight: spacing.sm, + gap: spacing.sm, + }, + channelTagAfterViews: { + maxWidth: 130, + flexShrink: 1, + }, viewsWrap: { flexDirection: 'row', alignItems: 'center', + flexShrink: 0, }, viewsText: { color: colors.text.hint, diff --git a/src/components/business/TabBar.tsx b/src/components/business/TabBar.tsx index bcca877..8b29117 100644 --- a/src/components/business/TabBar.tsx +++ b/src/components/business/TabBar.tsx @@ -5,7 +5,7 @@ */ import React, { ReactNode } from 'react'; -import { View, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react-native'; +import { View, TouchableOpacity, StyleSheet, ScrollView, Animated, ViewStyle, StyleProp } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import Text from '../common/Text'; @@ -20,6 +20,8 @@ interface TabBarProps { rightContent?: ReactNode; variant?: TabBarVariant; icons?: string[]; + /** 合并到最外层容器,用于按页面微调外边距等 */ + style?: StyleProp; } const TabBar: React.FC = ({ @@ -30,6 +32,7 @@ const TabBar: React.FC = ({ rightContent, variant = 'default', icons, + style, }) => { const renderTabs = () => { return tabs.map((tab, index) => { @@ -157,7 +160,7 @@ const TabBar: React.FC = ({ if (scrollable) { return ( - + = ({ } return ( - + {renderTabs()} {rightContent && {rightContent}} diff --git a/src/core/entities/Post.ts b/src/core/entities/Post.ts index 4d42131..684d3b3 100644 --- a/src/core/entities/Post.ts +++ b/src/core/entities/Post.ts @@ -77,6 +77,8 @@ export interface Post { status: PostStatus; /** 所属频道ID */ channelId?: string; + /** 频道摘要(列表 API 填充,供 PostCard 等展示) */ + channel?: { id: string; name: string } | null; /** 标签列表 */ tags: string[]; /** 创建时间 */ diff --git a/src/data/mappers/PostMapper.ts b/src/data/mappers/PostMapper.ts index 6e7c745..cac4a74 100644 --- a/src/data/mappers/PostMapper.ts +++ b/src/data/mappers/PostMapper.ts @@ -25,6 +25,10 @@ export class PostMapper { isTop: response.is_pinned || false, status: (response.status || 'published') as 'published' | 'draft' | 'deleted', channelId: response.channel_id, + channel: + response.channel && response.channel.id + ? { id: String(response.channel.id), name: String(response.channel.name || '') } + : undefined, tags: [], createdAt: new Date(response.created_at || Date.now()), // 空字符串/缺省时用 created_at,避免误用 Date.now() 导致全部显示「刚修改」 diff --git a/src/data/models/index.ts b/src/data/models/index.ts index f2f2a41..c8bc744 100644 --- a/src/data/models/index.ts +++ b/src/data/models/index.ts @@ -86,6 +86,8 @@ export interface PostModel { isTop: boolean; status: 'published' | 'draft' | 'deleted'; channelId?: string; + /** 频道展示用(与 PostDTO.channel 一致) */ + channel?: { id: string; name: string } | null; tags?: string[]; createdAt: Date; updatedAt: Date; diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index 14177fe..54fd3d1 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -42,6 +42,7 @@ interface PostApiResponse { created_at: string; updated_at: string; content_edited_at?: string; + channel?: { id: string; name: string } | null; author?: { id: number; username: string; @@ -125,6 +126,7 @@ export class PostRepository implements IPostRepository { isPinned: model.isTop, status: model.status, channelId: model.channelId, + channel: model.channel, tags: model.tags || [], createdAt: model.createdAt.toISOString(), updatedAt: model.updatedAt.toISOString(), diff --git a/src/screens/apps/AppsScreen.tsx b/src/screens/apps/AppsScreen.tsx index e8a59ac..88dad4b 100644 --- a/src/screens/apps/AppsScreen.tsx +++ b/src/screens/apps/AppsScreen.tsx @@ -1,25 +1,23 @@ /** - * 应用中心:聚合站内轻应用入口 + * 应用中心:与首页顶栏、个人主页帖子卡片同一套圆角 / 阴影 / 主色体系 */ -import React, { useCallback, useMemo, useState } from 'react'; -import { View, StyleSheet, ScrollView, TouchableOpacity } from 'react-native'; +import React, { useCallback } from 'react'; +import { View, StyleSheet, ScrollView, TouchableOpacity, StatusBar } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { StatusBar } from 'expo-status-bar'; import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { LinearGradient } from 'expo-linear-gradient'; import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; import * as hrefs from '../../navigation/hrefs'; import { Text, ResponsiveContainer } from '../../components/common'; -import { useResponsive } from '../../hooks/useResponsive'; +import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive'; + type AppItem = { id: string; title: string; subtitle: string; icon: React.ComponentProps['name']; - gradient: readonly [string, string, ...string[]]; href: string; }; @@ -27,28 +25,32 @@ const APP_ENTRIES: AppItem[] = [ { id: 'schedule', title: '课表', - subtitle: '周视图 · 教务同步', + subtitle: '周课表、教务同步与课程管理', icon: 'calendar-week', - gradient: [colors.primary.main, colors.primary.light], href: hrefs.hrefSchedule(), }, ]; +/** 与个人主页 PostCard 外层 `postWrapper` 一致 */ +const postCardShell = { + marginBottom: spacing.md, + backgroundColor: colors.background.paper, + borderRadius: 16, + overflow: 'hidden' as const, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.06, + shadowRadius: 8, + elevation: 2, +}; + export const AppsScreen: React.FC = () => { const router = useRouter(); const insets = useSafeAreaInsets(); - const { isMobile, isTablet, width } = useResponsive(); - - const columns = useMemo(() => { - if (width >= 900) return 4; - if (isTablet || width >= 600) return 3; - return 2; - }, [isTablet, width]); - - const gap = spacing.md; - const [gridWidth, setGridWidth] = useState(0); - const cardWidth = - gridWidth > 0 ? (gridWidth - gap * (columns - 1)) / columns : undefined; + const { isMobile } = useResponsive(); + /** 与 MessageListScreen 顶栏宽屏 padding 一致 */ + const isWideScreen = useBreakpointGTE('lg'); + const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); const scrollBottomInset = isMobile ? 88 + insets.bottom + spacing.md : spacing['3xl']; @@ -59,74 +61,82 @@ export const AppsScreen: React.FC = () => { [router] ); - return ( - - - - - - - - - + const renderTiles = () => ( + <> + + 与社区账号打通的轻应用入口 + + {APP_ENTRIES.map(item => ( + onOpenApp(item.href)} + activeOpacity={0.88} + > + + + - 应用 - 学习与生活常用工具,持续扩充中 - - - 全部应用 - setGridWidth(e.nativeEvent.layout.width)} - > - {APP_ENTRIES.map(item => ( - onOpenApp(item.href)} - activeOpacity={0.88} - > - - - - - - - {item.title} - - - {item.subtitle} - - - 打开 - - - - ))} + + + {item.title} + + + {item.subtitle} + + + + + ))} - 更多应用敬请期待 + + + 更多应用陆续上线 + + + + ); + + return ( + + + + {/* 与 MessageListScreen 顶栏同一结构 / 样式 */} + + + + + 应用 + + + + + + + + {isWideScreen ? ( + + + {renderTiles()} + - + ) : ( + + {renderTiles()} + + )} ); }; @@ -134,115 +144,92 @@ export const AppsScreen: React.FC = () => { export default AppsScreen; const styles = StyleSheet.create({ - safe: { + container: { flex: 1, backgroundColor: colors.background.default, }, + msgHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + backgroundColor: '#FAFAFA', + ...shadows.sm, + }, + msgHeaderWide: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.lg, + }, + msgHeaderLeft: { + width: 44, + }, + msgHeaderCenter: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + msgHeaderTitle: { + fontSize: 19, + fontWeight: '700', + color: '#333', + }, + msgHeaderTitleWide: { + fontSize: 22, + }, + msgHeaderRight: { + flexDirection: 'row', + alignItems: 'center', + }, + /** 与消息页「+」按钮同占位宽度,标题视觉居中 */ + msgHeaderRightSpacer: { + width: 36, + height: 44, + }, + pageSubtitle: { + marginBottom: spacing.md, + lineHeight: fontSizes.sm * 1.45, + }, + /** 与消息列表区同色,避免顶栏阴影落在灰底上像一条线 */ scroll: { flex: 1, + backgroundColor: '#FAFAFA', }, scrollContent: { paddingTop: spacing.md, + flexGrow: 1, + backgroundColor: '#FAFAFA', + }, + appCardInner: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.lg, paddingHorizontal: spacing.lg, }, - hero: { - borderRadius: borderRadius['2xl'], - paddingVertical: spacing['2xl'], - paddingHorizontal: spacing.xl, - marginBottom: spacing['2xl'], - overflow: 'hidden', - }, - heroIconWrap: { - marginBottom: spacing.md, - }, - heroIconGradient: { + /** 与首页发帖 FAB 同主色实心圆 */ + appIconCircle: { width: 52, height: 52, - borderRadius: borderRadius.xl, - alignItems: 'center', - justifyContent: 'center', - ...shadows.md, - }, - heroTitle: { - fontSize: fontSizes['3xl'], - fontWeight: '800', - color: colors.text.primary, - letterSpacing: -0.5, - }, - heroSubtitle: { - marginTop: spacing.xs, - fontSize: fontSizes.sm, - color: colors.text.secondary, - lineHeight: 20, - maxWidth: 320, - }, - sectionLabel: { - fontSize: fontSizes.xs, - fontWeight: '700', - color: colors.text.secondary, - textTransform: 'uppercase', - letterSpacing: 1.2, - marginBottom: spacing.md, - marginLeft: 2, - }, - grid: { - flexDirection: 'row', - flexWrap: 'wrap', - width: '100%', - }, - cardFlex: { - flex: 1, - minWidth: 140, - maxWidth: '100%', - }, - card: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.xl, - padding: spacing.lg, - borderWidth: 1, - borderColor: `${colors.divider}99`, - }, - cardIconRing: { - width: 52, - height: 52, - borderRadius: borderRadius.lg, - padding: 2, - marginBottom: spacing.md, - alignSelf: 'flex-start', - }, - cardIconInner: { - flex: 1, - borderRadius: borderRadius.md, - backgroundColor: colors.background.paper, + borderRadius: borderRadius.full, + backgroundColor: colors.primary.main, alignItems: 'center', justifyContent: 'center', }, - cardTitle: { - fontSize: fontSizes.lg, + appCardText: { + flex: 1, + marginLeft: spacing.md, + marginRight: spacing.sm, + }, + appTitle: { fontWeight: '700', - color: colors.text.primary, + fontSize: fontSizes.md + 1, }, - cardSubtitle: { - marginTop: spacing.xs, - fontSize: fontSizes.sm, - color: colors.text.secondary, - lineHeight: 18, - minHeight: 36, + appSubtitle: { + marginTop: 4, }, - cardFooter: { - flexDirection: 'row', + footer: { alignItems: 'center', - marginTop: spacing.md, - }, - cardAction: { - fontSize: fontSizes.sm, - fontWeight: '600', - color: colors.primary.main, - }, - hint: { - marginTop: spacing['3xl'], - textAlign: 'center', - fontSize: fontSizes.sm, - color: colors.text.hint, + paddingTop: spacing.xl, + paddingBottom: spacing.md, }, }); diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index aa5701b..0687598 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -21,12 +21,12 @@ import { Modal, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useRouter } from 'expo-router'; +import { useRouter, useFocusEffect } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { Post } from '../../types'; -import { useUserStore } from '../../stores'; +import { useUserStore, useHomeTabBarVisibilityStore } from '../../stores'; import { useCurrentUser } from '../../stores/authStore'; import { channelService, postService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; @@ -47,6 +47,10 @@ const SWIPE_COOLDOWN_MS = 300; const MOBILE_TAB_BAR_HEIGHT = 64; const MOBILE_TAB_FLOATING_MARGIN = 12; const MOBILE_FAB_GAP = 12; +/** 首页纵向滑动超过此阈值视为明确向下/向上划,用于隐藏或显示底部 Tab */ +const TAB_BAR_SCROLL_DELTA_Y = 10; +/** 停止滑动多久后自动恢复底部 Tab */ +const TAB_BAR_IDLE_RESTORE_MS = 2200; type ViewMode = 'list' | 'grid'; type PostType = 'follow' | 'hot' | 'latest'; @@ -93,6 +97,65 @@ export const HomeScreen: React.FC = () => { const isLoadingMoreRef = useRef(false); + const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll); + const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); + const homeListScrollYRef = useRef(0); + const tabBarIdleRestoreTimerRef = useRef | null>(null); + + const clearTabBarIdleRestoreTimer = useCallback(() => { + if (tabBarIdleRestoreTimerRef.current != null) { + clearTimeout(tabBarIdleRestoreTimerRef.current); + tabBarIdleRestoreTimerRef.current = null; + } + }, []); + + const scheduleTabBarIdleRestore = useCallback(() => { + clearTabBarIdleRestoreTimer(); + tabBarIdleRestoreTimerRef.current = setTimeout(() => { + setBottomTabBarHiddenByScroll(false); + tabBarIdleRestoreTimerRef.current = null; + }, TAB_BAR_IDLE_RESTORE_MS); + }, [clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]); + + const handleHomeVerticalScroll = useCallback( + (event: NativeSyntheticEvent) => { + const y = event.nativeEvent.contentOffset.y; + const dy = y - homeListScrollYRef.current; + homeListScrollYRef.current = y; + + scheduleTabBarIdleRestore(); + + if (y <= 0) { + setBottomTabBarHiddenByScroll(false); + return; + } + + if (dy > TAB_BAR_SCROLL_DELTA_Y) { + setBottomTabBarHiddenByScroll(true); + } else if (dy < -TAB_BAR_SCROLL_DELTA_Y) { + setBottomTabBarHiddenByScroll(false); + } + }, + [scheduleTabBarIdleRestore, setBottomTabBarHiddenByScroll] + ); + + useFocusEffect( + useCallback(() => { + return () => { + clearTabBarIdleRestoreTimer(); + homeListScrollYRef.current = 0; + setBottomTabBarHiddenByScroll(false); + }; + }, [clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]) + ); + + useEffect(() => { + if (showSearch) { + clearTabBarIdleRestoreTimer(); + setBottomTabBarHiddenByScroll(false); + } + }, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]); + /** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */ const capsuleHScrollRef = useRef(null); const capsuleScrollXRef = useRef(0); @@ -133,6 +196,11 @@ export const HomeScreen: React.FC = () => { const isLatestTab = activeIndex === 1; const currentChannelId = isLatestTab && activeCapsuleId ? activeCapsuleId : undefined; + useEffect(() => { + homeListScrollYRef.current = 0; + setBottomTabBarHiddenByScroll(false); + }, [activeIndex, currentChannelId, viewMode, setBottomTabBarHiddenByScroll]); + useLayoutEffect(() => { if (!isLatestTab) return; restoreCapsuleStripScroll(); @@ -183,18 +251,21 @@ export const HomeScreen: React.FC = () => { } }, [posts.length, listKey, hasMore, isLoadingMore]); - // 网格模式滚动处理 - 检测是否滚动到底部 - const handleGridScroll = useCallback((event: any) => { - const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; - const scrollY = contentOffset.y; - const visibleHeight = layoutMeasurement.height; - const contentHeight = contentSize.height; - - // 当滚动到距离底部 200px 时触发加载更多 - if (scrollY + visibleHeight >= contentHeight - 200) { - loadMore(); - } - }, [loadMore]); + // 网格模式滚动处理 - 检测是否滚动到底部 + 底部 Tab 显隐 + const handleGridScroll = useCallback( + (event: NativeSyntheticEvent) => { + handleHomeVerticalScroll(event); + const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; + const scrollY = contentOffset.y; + const visibleHeight = layoutMeasurement.height; + const contentHeight = contentSize.height; + + if (scrollY + visibleHeight >= contentHeight - 200) { + loadMore(); + } + }, + [handleHomeVerticalScroll, loadMore] + ); // 刷新方法 - 先获取正确类型的帖子,再刷新 const refresh = useCallback(async () => { @@ -227,10 +298,13 @@ export const HomeScreen: React.FC = () => { return posts.map(post => { const storePost = postsMap.get(post.id); if (storePost) { - // 如果 store 中有这个帖子,使用 store 的最新状态 - return storePost; + // store 同步点赞等状态;channel 等列表专用字段在 store 里常缺失,从列表帖合并 + return { + ...post, + ...storePost, + channel: storePost.channel ?? post.channel, + }; } - // 如果 store 中没有这个帖子,使用原始数据 return post; }); }, [posts, postsMap]); @@ -281,10 +355,12 @@ export const HomeScreen: React.FC = () => { if (!isMobile) { return undefined; } + if (bottomTabBarHiddenByScroll) { + return MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP + insets.bottom; + } // TabBar 悬浮在底部,发帖按钮需要在 TabBar 上方 - // TabBar 高度 64 + TabBar 浮动间距 12 + 按钮与 TabBar 间距 16 return MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN * 2 + MOBILE_FAB_GAP + insets.bottom; - }, [isMobile, insets.bottom]); + }, [isMobile, insets.bottom, bottomTabBarHiddenByScroll]); // 切换视图模式 const toggleViewMode = () => { @@ -639,7 +715,7 @@ export const HomeScreen: React.FC = () => { } ]} showsVerticalScrollIndicator={false} - scrollEventThrottle={100} + scrollEventThrottle={16} onScroll={handleGridScroll} refreshControl={ { } onEndReached={loadMore} onEndReachedThreshold={0.3} + onScroll={handleHomeVerticalScroll} scrollEventThrottle={16} ListEmptyComponent={renderEmpty} ListFooterComponent={isLoadingMore ? : null} @@ -783,6 +860,7 @@ export const HomeScreen: React.FC = () => { activeIndex={activeIndex} onTabChange={changeTab} variant="modern" + style={styles.homeTabBar} rightContent={ { // 底部间距,避免被 TabBar 遮挡 const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; - // 加载设置 + // 加载设置(与启动时 hydrate 一致,避免从其它入口进页时 UI 与内存不一致) useEffect(() => { const loadSettings = async () => { try { - const vibrationStored = await AsyncStorage.getItem(VIBRATION_ENABLED_KEY); - if (vibrationStored !== null) { - const enabled = JSON.parse(vibrationStored); - setVibrationEnabledState(enabled); - setVibrationEnabled(enabled); - } + const prefs = await loadNotificationPreferences(); + setVibrationEnabledState(prefs.vibrationEnabled); + setPushEnabled(prefs.pushEnabled); + setSoundEnabled(prefs.soundEnabled); } catch (error) { - console.error('加载震动设置失败:', error); + console.error('加载通知设置失败:', error); } }; loadSettings(); }, []); - // 切换震动 const toggleVibration = async (value: boolean) => { try { setVibrationEnabledState(value); - setVibrationEnabled(value); - await AsyncStorage.setItem(VIBRATION_ENABLED_KEY, JSON.stringify(value)); + await setVibrationPreference(value); } catch (error) { console.error('保存震动设置失败:', error); } }; + const togglePush = async (value: boolean) => { + try { + setPushEnabled(value); + await setPushNotificationsPreference(value); + } catch (error) { + console.error('保存推送开关失败:', error); + } + }; + + const toggleSound = async (value: boolean) => { + try { + setSoundEnabled(value); + await setSoundPreference(value); + } catch (error) { + console.error('保存提示音设置失败:', error); + } + }; + const settings: NotificationSettingItem[] = [ { key: 'push', @@ -75,7 +91,7 @@ export const NotificationSettingsScreen: React.FC = () => { subtitle: '接收新消息、点赞、评论等推送', icon: 'bell-outline', value: pushEnabled, - onValueChange: setPushEnabled, + onValueChange: togglePush, }, { key: 'vibration', @@ -91,7 +107,7 @@ export const NotificationSettingsScreen: React.FC = () => { subtitle: '收到新消息时播放提示音', icon: 'volume-high', value: soundEnabled, - onValueChange: setSoundEnabled, + onValueChange: toggleSound, }, ]; diff --git a/src/services/database.ts b/src/services/database.ts index 8a5294e..746930f 100644 --- a/src/services/database.ts +++ b/src/services/database.ts @@ -20,30 +20,61 @@ let writeQueue: Promise = Promise.resolve(); // 当前数据库对应的用户ID let currentDbUserId: string | null = null; +/** + * 串行化 open/close,避免并发 initDatabase(如 React Strict Mode 双次 effect、登录与冷启动校验交错) + * 导致后完成的 open 覆盖先完成的连接,进而在 Android 上出现 NativeDatabase.execAsync NPE。 + */ +let dbOpenCloseMutex: Promise = Promise.resolve(); + +async function withDbOpenCloseLock(fn: () => Promise): Promise { + const previous = dbOpenCloseMutex; + let release!: () => void; + dbOpenCloseMutex = new Promise((resolve) => { + release = resolve; + }); + try { + await previous; + return await fn(); + } finally { + release(); + } +} + // 数据库版本,用于迁移 const DB_VERSION = 2; -/** - * 初始化用户数据库 - * @param userId 用户ID,用于创建用户专属数据库文件 - */ -export const initDatabase = async (userId?: string): Promise => { +/** 关闭连接(须在已持有 dbOpenCloseMutex 时调用,或作为 withDbOpenCloseLock 内的实现) */ +const closeDatabaseUnlocked = async (): Promise => { + if (db) { + try { + await db.closeAsync(); + } catch (error) { + console.error('[Database] 关闭数据库失败:', error); + } + db = null; + currentDbUserId = null; + } +}; + +async function initDatabaseUnlocked(userId?: string): Promise { + // 如果指定了用户ID,使用用户专属数据库 + // 否则使用默认数据库(兼容旧版本) + const dbName = userId ? `carrot_bbs_${userId}.db` : 'carrot_bbs.db'; + + // 如果存在旧的数据库连接且用户ID变化,需要关闭旧连接 + if (db && currentDbUserId !== userId) { + await closeDatabaseUnlocked(); + } + + // 如果已经打开了正确的数据库,直接返回 + if (db && currentDbUserId === userId) { + return; + } + + let opened: SQLite.SQLiteDatabase | null = null; try { - // 如果指定了用户ID,使用用户专属数据库 - // 否则使用默认数据库(兼容旧版本) - const dbName = userId ? `carrot_bbs_${userId}.db` : 'carrot_bbs.db'; - - // 如果存在旧的数据库连接且用户ID变化,需要关闭旧连接 - if (db && currentDbUserId !== userId) { - await closeDatabase(); - } - - // 如果已经打开了正确的数据库,直接返回 - if (db && currentDbUserId === userId) { - return; - } - - db = await SQLite.openDatabaseAsync(dbName); + opened = await SQLite.openDatabaseAsync(dbName); + db = opened; currentDbUserId = userId || null; // 创建消息表(包含新字段 seq, status, segments) @@ -149,26 +180,34 @@ export const initDatabase = async (userId?: string): Promise => { await db.execAsync(` CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq); `); - } catch (error) { console.error('数据库初始化失败:', error); + try { + if (opened && opened === db) { + await opened.closeAsync(); + } + } catch { + /* ignore */ + } + db = null; + currentDbUserId = null; throw error; } +} + +/** + * 初始化用户数据库 + * @param userId 用户ID,用于创建用户专属数据库文件 + */ +export const initDatabase = async (userId?: string): Promise => { + return withDbOpenCloseLock(() => initDatabaseUnlocked(userId)); }; /** * 关闭数据库连接 */ export const closeDatabase = async (): Promise => { - if (db) { - try { - await db.closeAsync(); - } catch (error) { - console.error('[Database] 关闭数据库失败:', error); - } - db = null; - currentDbUserId = null; - } + return withDbOpenCloseLock(() => closeDatabaseUnlocked()); }; /** @@ -237,39 +276,47 @@ const isRecoverableDbError = (error: unknown): boolean => { ); }; -const recoverDbConnection = async (): Promise => { - // 清理当前引用,随后用当前用户上下文重建连接 - db = null; - await initDatabase(currentDbUserId || undefined); - return getDb(); +/** 在已持有 dbOpenCloseMutex 时重建连接(禁止在持有 SQLiteDatabase 期间 await 会抢锁的 recoverDbConnection) */ +const reconnectDatabaseUnlocked = async (): Promise => { + await closeDatabaseUnlocked(); + await initDatabaseUnlocked(currentDbUserId || undefined); }; +/** + * 读路径与 init/close/recover 共用同一把锁,避免: + * 线程 A 持有 database 引用执行 prepareAsync 时,线程 B 的 recover 关闭连接导致 NPE。 + */ const withDbRead = async (operation: (database: SQLite.SQLiteDatabase) => Promise): Promise => { - let database = await getDb(); - try { - return await operation(database); - } catch (error) { - if (!isRecoverableDbError(error)) { - throw error; - } - console.error('数据库读取异常,尝试重连后重试:', error); - database = await recoverDbConnection(); - return operation(database); - } -}; - -const enqueueWrite = async (operation: () => Promise): Promise => { - const wrappedOperation = async () => { + return withDbOpenCloseLock(async () => { try { - return await operation(); + const database = await getDb(); + return await operation(database); } catch (error) { if (!isRecoverableDbError(error)) { throw error; } - console.error('数据库写入异常,尝试重连后重试:', error); - await recoverDbConnection(); - return operation(); + console.error('数据库读取异常,尝试重连后重试:', error); + await reconnectDatabaseUnlocked(); + const database = await getDb(); + return await operation(database); } + }); +}; + +const enqueueWrite = async (operation: () => Promise): Promise => { + const wrappedOperation = async () => { + return withDbOpenCloseLock(async () => { + try { + return await operation(); + } catch (error) { + if (!isRecoverableDbError(error)) { + throw error; + } + console.error('数据库写入异常,尝试重连后重试:', error); + await reconnectDatabaseUnlocked(); + return await operation(); + } + }); }; const queued = writeQueue.then(wrappedOperation, wrappedOperation); writeQueue = queued.then(() => undefined, () => undefined); @@ -411,26 +458,24 @@ export const getMessagesBeforeSeq = async (conversationId: string, beforeSeq: nu // 获取本地消息数量(用于判断是否还有更多历史) export const getLocalMessageCountBeforeSeq = async (conversationId: string, beforeSeq: number): Promise => { - const database = await getDb(); - - const result = await database.getFirstAsync<{ count: number }>( - `SELECT COUNT(*) as count FROM messages WHERE conversationId = ? AND seq < ?`, - [conversationId, beforeSeq] - ); - - return result?.count || 0; + return withDbRead(async (database) => { + const result = await database.getFirstAsync<{ count: number }>( + `SELECT COUNT(*) as count FROM messages WHERE conversationId = ? AND seq < ?`, + [conversationId, beforeSeq] + ); + return result?.count || 0; + }); }; // 获取消息数量 export const getMessageCount = async (conversationId: string): Promise => { - const database = await getDb(); - - const result = await database.getFirstAsync<{ count: number }>( - `SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, - [conversationId] - ); - - return result?.count || 0; + return withDbRead(async (database) => { + const result = await database.getFirstAsync<{ count: number }>( + `SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, + [conversationId] + ); + return result?.count || 0; + }); }; // 标记消息为已读 @@ -532,13 +577,9 @@ export const saveConversation = async (conversation: { // 获取所有会话 export const getAllConversations = async (): Promise => { - const database = await getDb(); - - const result = await database.getAllAsync( - `SELECT * FROM conversations ORDER BY updatedAt DESC` - ); - - return result; + return withDbRead(async (database) => { + return database.getAllAsync(`SELECT * FROM conversations ORDER BY updatedAt DESC`); + }); }; // 更新会话未读数 @@ -587,21 +628,20 @@ export const deleteConversation = async (conversationId: string): Promise // 搜索消息 export const searchMessages = async (keyword: string): Promise => { - const database = await getDb(); - - const result = await database.getAllAsync( - `SELECT * FROM messages - WHERE content LIKE ? - ORDER BY createdAt DESC - LIMIT 50`, - [`%${keyword}%`] - ); - - return result.map(msg => ({ - ...msg, - isRead: msg.isRead === 1, - segments: msg.segments ? JSON.parse(msg.segments) : undefined, - })); + return withDbRead(async (database) => { + const result = await database.getAllAsync( + `SELECT * FROM messages + WHERE content LIKE ? + ORDER BY createdAt DESC + LIMIT 50`, + [`%${keyword}%`] + ); + return result.map(msg => ({ + ...msg, + isRead: msg.isRead === 1, + segments: msg.segments ? JSON.parse(msg.segments) : undefined, + })); + }); }; // ==================== 工具函数 ==================== @@ -611,20 +651,18 @@ export const getDatabaseStats = async (): Promise<{ totalMessages: number; totalConversations: number; }> => { - const database = await getDb(); - - const msgCount = await database.getFirstAsync<{ count: number }>( - `SELECT COUNT(*) as count FROM messages` - ); - - const convCount = await database.getFirstAsync<{ count: number }>( - `SELECT COUNT(*) as count FROM conversations` - ); - - return { - totalMessages: msgCount?.count || 0, - totalConversations: convCount?.count || 0, - }; + return withDbRead(async (database) => { + const msgCount = await database.getFirstAsync<{ count: number }>( + `SELECT COUNT(*) as count FROM messages` + ); + const convCount = await database.getFirstAsync<{ count: number }>( + `SELECT COUNT(*) as count FROM conversations` + ); + return { + totalMessages: msgCount?.count || 0, + totalConversations: convCount?.count || 0, + }; + }); }; // 清空所有数据(用于调试或用户退出登录) @@ -689,12 +727,13 @@ export const getUserCache = async (userId: string): Promise => { }; export const getLatestUserCache = async (): Promise => { - const database = await getDb(); - const row = await database.getFirstAsync<{ data: string }>( - `SELECT data FROM users ORDER BY updatedAt DESC LIMIT 1` - ); - if (!row?.data) return null; - return safeParseJson(row.data); + return withDbRead(async (database) => { + const row = await database.getFirstAsync<{ data: string }>( + `SELECT data FROM users ORDER BY updatedAt DESC LIMIT 1` + ); + if (!row?.data) return null; + return safeParseJson(row.data); + }); }; export const saveCurrentUserCache = async (user: UserDTO): Promise => { diff --git a/src/services/notificationPreferences.ts b/src/services/notificationPreferences.ts new file mode 100644 index 0000000..8751067 --- /dev/null +++ b/src/services/notificationPreferences.ts @@ -0,0 +1,94 @@ +/** + * 通知相关本地偏好(推送开关、提示音、震动) + * 启动时 loadNotificationPreferences 会同步到内存,供 SSE / 系统通知 / Handler 读取 + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Notifications from 'expo-notifications'; +import { setVibrationEnabled } from './messageVibrationService'; + +export const NOTIFICATION_PREF_KEYS = { + /** 与历史版本保持一致 */ + vibration: 'vibration_enabled', + push: 'notification_push_enabled', + sound: 'notification_sound_enabled', +} as const; + +export type NotificationPrefs = { + pushEnabled: boolean; + soundEnabled: boolean; + vibrationEnabled: boolean; +}; + +const defaults: NotificationPrefs = { + pushEnabled: true, + soundEnabled: true, + vibrationEnabled: true, +}; + +let cached: NotificationPrefs = { ...defaults }; + +export function getNotificationPreferencesSync(): NotificationPrefs { + return { ...cached }; +} + +function parseBool(raw: string | null, fallback: boolean): boolean { + if (raw === null) return fallback; + try { + return JSON.parse(raw) as boolean; + } catch { + return fallback; + } +} + +/** 应用启动时调用:从 AsyncStorage 恢复并写入震动服务内存态 */ +export async function loadNotificationPreferences(): Promise { + try { + const [vibrationRaw, pushRaw, soundRaw] = await Promise.all([ + AsyncStorage.getItem(NOTIFICATION_PREF_KEYS.vibration), + AsyncStorage.getItem(NOTIFICATION_PREF_KEYS.push), + AsyncStorage.getItem(NOTIFICATION_PREF_KEYS.sound), + ]); + cached = { + vibrationEnabled: parseBool(vibrationRaw, defaults.vibrationEnabled), + pushEnabled: parseBool(pushRaw, defaults.pushEnabled), + soundEnabled: parseBool(soundRaw, defaults.soundEnabled), + }; + setVibrationEnabled(cached.vibrationEnabled); + return { ...cached }; + } catch { + return { ...cached }; + } +} + +export async function setVibrationPreference(enabled: boolean): Promise { + cached.vibrationEnabled = enabled; + setVibrationEnabled(enabled); + await AsyncStorage.setItem(NOTIFICATION_PREF_KEYS.vibration, JSON.stringify(enabled)); +} + +export async function setPushNotificationsPreference(enabled: boolean): Promise { + cached.pushEnabled = enabled; + await AsyncStorage.setItem(NOTIFICATION_PREF_KEYS.push, JSON.stringify(enabled)); +} + +export async function setSoundPreference(enabled: boolean): Promise { + cached.soundEnabled = enabled; + await AsyncStorage.setItem(NOTIFICATION_PREF_KEYS.sound, JSON.stringify(enabled)); +} + +/** 注册 expo-notifications 展示策略(读取内存缓存,切换开关后立即生效) */ +export function registerNotificationPresentationHandler(): void { + Notifications.setNotificationHandler({ + handleNotification: async () => { + const p = getNotificationPreferencesSync(); + return { + shouldShowAlert: p.pushEnabled, + shouldPlaySound: p.soundEnabled, + shouldSetBadge: true, + shouldShowBanner: p.pushEnabled, + shouldShowList: p.pushEnabled, + }; + }, + }); +} diff --git a/src/services/sseService.ts b/src/services/sseService.ts index 5b2550a..e0f7abf 100644 --- a/src/services/sseService.ts +++ b/src/services/sseService.ts @@ -4,6 +4,7 @@ import EventSource from 'react-native-sse'; import { api, SSE_URL } from './api'; import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment } from '../types/dto'; import { systemNotificationService } from './systemNotificationService'; +import { getNotificationPreferencesSync } from './notificationPreferences'; import { vibrateOnMessage } from './messageVibrationService'; export type WSMessageType = @@ -384,7 +385,9 @@ class SSEService { created_at: payload.created_at || new Date().toISOString(), }; this.emit('notification', m); - vibrateOnMessage('notification').catch(() => {}); + if (getNotificationPreferencesSync().pushEnabled) { + vibrateOnMessage('notification').catch(() => {}); + } systemNotificationService.handleWSMessage(m as any).catch(() => {}); } } diff --git a/src/services/systemNotificationService.ts b/src/services/systemNotificationService.ts index 9555219..81fc223 100644 --- a/src/services/systemNotificationService.ts +++ b/src/services/systemNotificationService.ts @@ -8,6 +8,7 @@ import * as Notifications from 'expo-notifications'; import { Platform, AppState, AppStateStatus } from 'react-native'; import type { WSChatMessage, WSNotificationMessage, WSAnnouncementMessage } from './sseService'; import { extractTextFromSegments } from '../types/dto'; +import { getNotificationPreferencesSync } from './notificationPreferences'; // 通知渠道配置 const CHANNEL_ID = 'default'; @@ -71,17 +72,6 @@ class SystemNotificationService { return false; } - // 设置 notification handler,确保前台也能显示通知、播放声音和震动 - Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: true, - shouldSetBadge: true, - shouldShowBanner: true, - shouldShowList: true, - }), - }); - if (Platform.OS === 'android') { await Notifications.setNotificationChannelAsync(CHANNEL_ID, { name: CHANNEL_NAME, @@ -120,15 +110,16 @@ class SystemNotificationService { // 使用 scheduleNotificationAsync 立即显示通知 // 配合 setNotificationHandler 确保前台也能显示 + const { vibrationEnabled } = getNotificationPreferencesSync(); // 构建通知内容 const content: Notifications.NotificationContentInput = { title: options.title, body: options.body, data: options.data as Record | undefined, - // 显式设置震动(仅 Android 生效) - ...(Platform.OS === 'android' ? { - vibrationPattern: [0, 300, 200, 300], - } : {}), + // 显式设置震动(仅 Android 生效,且尊重「消息震动」开关) + ...(Platform.OS === 'android' && vibrationEnabled + ? { vibrationPattern: [0, 300, 200, 300] } + : {}), }; const notificationId = await Notifications.scheduleNotificationAsync({ @@ -179,6 +170,9 @@ class SystemNotificationService { } async handleWSMessage(message: WSChatMessage | WSNotificationMessage | WSAnnouncementMessage): Promise { + if (!getNotificationPreferencesSync().pushEnabled) { + return; + } // 仅在后台时显示通知,前台时不显示(用户正在使用应用,可以直接看到消息) if (this.currentAppState !== 'active') { // 判断是否是聊天消息(通过 segments 字段) diff --git a/src/stores/homeTabBarVisibilityStore.ts b/src/stores/homeTabBarVisibilityStore.ts new file mode 100644 index 0000000..902fa96 --- /dev/null +++ b/src/stores/homeTabBarVisibilityStore.ts @@ -0,0 +1,12 @@ +import { create } from 'zustand'; + +interface HomeTabBarVisibilityState { + /** 首页列表下滑时隐藏底部 Tab,上滑或闲置后恢复 */ + bottomTabBarHiddenByScroll: boolean; + setBottomTabBarHiddenByScroll: (hidden: boolean) => void; +} + +export const useHomeTabBarVisibilityStore = create((set) => ({ + bottomTabBarHiddenByScroll: false, + setBottomTabBarHiddenByScroll: (hidden) => set({ bottomTabBarHiddenByScroll: hidden }), +})); diff --git a/src/stores/index.ts b/src/stores/index.ts index fe00c21..9707373 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -42,6 +42,9 @@ export { enrichGroupMembersWithUserProfiles } from './groupMemberProfileResolver export { postManager } from './postManager'; export { groupManager } from './groupManager'; export { userManager } from './userManager'; +export { + useHomeTabBarVisibilityStore, +} from './homeTabBarVisibilityStore'; export { useConversations as useMessageManagerConversations, useMessages,