From 583ac64dfd04d43c877d93b9f04e6776e63c1e4c Mon Sep 17 00:00:00 2001
From: lafay <2021211506@stu.hit.edu.cn>
Date: Wed, 25 Mar 2026 01:30:00 +0800
Subject: [PATCH] feat(Notification): implement notification preferences
management and enhance notification handling
- Introduced a new service for managing notification preferences, including push notifications, sound, and vibration settings.
- Updated the notification handling logic to respect user preferences, ensuring notifications are displayed according to user settings.
- Refactored the App and various screens to integrate the new notification preferences, improving user experience and consistency.
- Enhanced the HomeScreen and NotificationSettingsScreen to load and update notification settings seamlessly.
- Implemented a mechanism to hide the bottom tab bar based on scroll events, improving navigation usability.
---
app/(app)/(tabs)/_layout.tsx | 70 ++--
app/_layout.tsx | 13 +-
src/app-navigation/AppDesktopShell.tsx | 2 +-
src/components/business/PostCard/PostCard.tsx | 37 +-
src/components/business/TabBar.tsx | 9 +-
src/core/entities/Post.ts | 2 +
src/data/mappers/PostMapper.ts | 4 +
src/data/models/index.ts | 2 +
src/data/repositories/PostRepository.ts | 2 +
src/screens/apps/AppsScreen.tsx | 339 +++++++++---------
src/screens/home/HomeScreen.tsx | 125 +++++--
src/screens/profile/EditProfileScreen.tsx | 6 +-
.../profile/NotificationSettingsScreen.tsx | 50 ++-
src/services/database.ts | 263 ++++++++------
src/services/notificationPreferences.ts | 94 +++++
src/services/sseService.ts | 5 +-
src/services/systemNotificationService.ts | 24 +-
src/stores/homeTabBarVisibilityStore.ts | 12 +
src/stores/index.ts | 3 +
19 files changed, 666 insertions(+), 396 deletions(-)
create mode 100644 src/services/notificationPreferences.ts
create mode 100644 src/stores/homeTabBarVisibilityStore.ts
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,