feat(Notification): implement notification preferences management and enhance notification handling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 8m28s
Frontend CI / ota-android (push) Successful in 11m6s
Frontend CI / build-android-apk (push) Successful in 1h3m35s

- 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.
This commit is contained in:
lafay
2026-03-25 01:30:00 +08:00
parent cedb8284ba
commit 583ac64dfd
19 changed files with 666 additions and 396 deletions

View File

@@ -20,30 +20,61 @@ let writeQueue: Promise<void> = Promise.resolve();
// 当前数据库对应的用户ID
let currentDbUserId: string | null = null;
/**
* 串行化 open/close避免并发 initDatabase如 React Strict Mode 双次 effect、登录与冷启动校验交错
* 导致后完成的 open 覆盖先完成的连接,进而在 Android 上出现 NativeDatabase.execAsync NPE。
*/
let dbOpenCloseMutex: Promise<void> = Promise.resolve();
async function withDbOpenCloseLock<T>(fn: () => Promise<T>): Promise<T> {
const previous = dbOpenCloseMutex;
let release!: () => void;
dbOpenCloseMutex = new Promise<void>((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<void> => {
/** 关闭连接(须在已持有 dbOpenCloseMutex 时调用,或作为 withDbOpenCloseLock 内的实现) */
const closeDatabaseUnlocked = async (): Promise<void> => {
if (db) {
try {
await db.closeAsync();
} catch (error) {
console.error('[Database] 关闭数据库失败:', error);
}
db = null;
currentDbUserId = null;
}
};
async function initDatabaseUnlocked(userId?: string): Promise<void> {
// 如果指定了用户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<void> => {
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<void> => {
return withDbOpenCloseLock(() => initDatabaseUnlocked(userId));
};
/**
* 关闭数据库连接
*/
export const closeDatabase = async (): Promise<void> => {
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<SQLite.SQLiteDatabase> => {
// 清理当前引用,随后用当前用户上下文重建连接
db = null;
await initDatabase(currentDbUserId || undefined);
return getDb();
/** 在已持有 dbOpenCloseMutex 时重建连接(禁止在持有 SQLiteDatabase 期间 await 会抢锁的 recoverDbConnection */
const reconnectDatabaseUnlocked = async (): Promise<void> => {
await closeDatabaseUnlocked();
await initDatabaseUnlocked(currentDbUserId || undefined);
};
/**
* 读路径与 init/close/recover 共用同一把锁,避免:
* 线程 A 持有 database 引用执行 prepareAsync 时,线程 B 的 recover 关闭连接导致 NPE。
*/
const withDbRead = async <T>(operation: (database: SQLite.SQLiteDatabase) => Promise<T>): Promise<T> => {
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 <T>(operation: () => Promise<T>): Promise<T> => {
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 <T>(operation: () => Promise<T>): Promise<T> => {
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<number> => {
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<number> => {
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<any[]> => {
const database = await getDb();
const result = await database.getAllAsync<any>(
`SELECT * FROM conversations ORDER BY updatedAt DESC`
);
return result;
return withDbRead(async (database) => {
return database.getAllAsync<any>(`SELECT * FROM conversations ORDER BY updatedAt DESC`);
});
};
// 更新会话未读数
@@ -587,21 +628,20 @@ export const deleteConversation = async (conversationId: string): Promise<void>
// 搜索消息
export const searchMessages = async (keyword: string): Promise<CachedMessage[]> => {
const database = await getDb();
const result = await database.getAllAsync<any>(
`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<any>(
`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<UserDTO | null> => {
};
export const getLatestUserCache = async (): Promise<UserDTO | null> => {
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<UserDTO>(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<UserDTO>(row.data);
});
};
export const saveCurrentUserCache = async (user: UserDTO): Promise<void> => {

View File

@@ -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<NotificationPrefs> {
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<void> {
cached.vibrationEnabled = enabled;
setVibrationEnabled(enabled);
await AsyncStorage.setItem(NOTIFICATION_PREF_KEYS.vibration, JSON.stringify(enabled));
}
export async function setPushNotificationsPreference(enabled: boolean): Promise<void> {
cached.pushEnabled = enabled;
await AsyncStorage.setItem(NOTIFICATION_PREF_KEYS.push, JSON.stringify(enabled));
}
export async function setSoundPreference(enabled: boolean): Promise<void> {
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,
};
},
});
}

View File

@@ -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(() => {});
}
}

View File

@@ -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<string, string | number | object> | 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<void> {
if (!getNotificationPreferencesSync().pushEnabled) {
return;
}
// 仅在后台时显示通知,前台时不显示(用户正在使用应用,可以直接看到消息)
if (this.currentAppState !== 'active') {
// 判断是否是聊天消息(通过 segments 字段)