From 5fa5403d6ab15ab22f35d8faa149c599f5253d0c Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sat, 25 Apr 2026 21:23:22 +0800 Subject: [PATCH] feat(message): add conversation notification mute feature Add notification mute functionality for conversations with the following changes: - Add notification_muted field to ConversationResponse and related DTOs - Add notificationMutedMap to message store for tracking mute state - Add setConversationNotificationMuted API method - Integrate mute toggle in GroupInfoScreen and PrivateChatInfoScreen - Skip system notifications for muted conversations - Suppress vibration for muted conversations in WS handler - Clean up selected mentions when @mentions are removed from text Chore changes: - Update PostCard bookmark icon from 'bookmark' to 'star' - Refine card styles across AppsScreen, MaterialsScreen, and SubjectMaterialsScreen - Improve week selector scrolling to center selected week in ScheduleScreen --- src/components/business/PostCard/PostCard.tsx | 2 +- src/screens/apps/AppsScreen.tsx | 24 ++-- src/screens/material/MaterialDetailScreen.tsx | 25 ++-- src/screens/material/MaterialsScreen.tsx | 19 ++- .../material/SubjectMaterialsScreen.tsx | 18 +-- src/screens/message/GroupInfoScreen.tsx | 32 +++++ src/screens/message/PrivateChatInfoScreen.tsx | 10 +- .../components/ChatScreen/useChatScreen.ts | 19 ++- src/screens/schedule/ScheduleScreen.tsx | 126 ++++++++++++------ src/services/message/messageService.ts | 6 + .../notification/systemNotificationService.ts | 9 +- src/stores/message/MessageManager.ts | 15 +++ .../message/services/WSMessageHandler.ts | 3 +- src/stores/message/store.ts | 40 +++++- src/types/dto.ts | 5 +- 15 files changed, 252 insertions(+), 101 deletions(-) diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index 35016c2..50d70d4 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -721,7 +721,7 @@ const PostCardInner: React.FC = (normalizedProps) => { diff --git a/src/screens/apps/AppsScreen.tsx b/src/screens/apps/AppsScreen.tsx index cfd1665..9aab6d2 100644 --- a/src/screens/apps/AppsScreen.tsx +++ b/src/screens/apps/AppsScreen.tsx @@ -59,13 +59,13 @@ export const AppsScreen: React.FC = () => { () => ({ marginBottom: spacing.md, backgroundColor: colors.background.paper, - borderRadius: 16, + borderRadius: borderRadius.lg, overflow: 'hidden' as const, shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.06, - shadowRadius: 8, - elevation: 2, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.04, + shadowRadius: 4, + elevation: 1, maxWidth: APP_CARD_MAX_WIDTH, alignSelf: 'center' as const, width: '100%' as const, @@ -102,7 +102,7 @@ export const AppsScreen: React.FC = () => { > - + @@ -112,7 +112,7 @@ export const AppsScreen: React.FC = () => { {item.subtitle} - + ))} @@ -223,12 +223,12 @@ function createAppsStyles(colors: AppColors) { paddingVertical: spacing.lg, paddingHorizontal: spacing.lg, }, - /** 与首页发帖 FAB 同主色实心圆 */ + /** 扁平化主色图标背景 */ appIconCircle: { - width: 52, - height: 52, - borderRadius: borderRadius.full, - backgroundColor: colors.primary.main, + width: 48, + height: 48, + borderRadius: borderRadius.lg, + backgroundColor: `${colors.primary.main}14`, alignItems: 'center', justifyContent: 'center', }, diff --git a/src/screens/material/MaterialDetailScreen.tsx b/src/screens/material/MaterialDetailScreen.tsx index 8731605..0b7dacc 100644 --- a/src/screens/material/MaterialDetailScreen.tsx +++ b/src/screens/material/MaterialDetailScreen.tsx @@ -117,13 +117,13 @@ export const MaterialDetailScreen: React.FC = () => { const cardStyle = useMemo( () => ({ backgroundColor: colors.background.paper, - borderRadius: 16, + borderRadius: borderRadius.lg, overflow: 'hidden' as const, shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.06, - shadowRadius: 8, - elevation: 2, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.04, + shadowRadius: 4, + elevation: 1, }), [colors] ); @@ -390,7 +390,7 @@ function createMaterialDetailStyles(colors: AppColors) { marginBottom: spacing.md, }, fileTypeBanner: { - height: 100, + height: 88, alignItems: 'center', justifyContent: 'center', }, @@ -461,10 +461,9 @@ function createMaterialDetailStyles(colors: AppColors) { tag: { paddingVertical: spacing.xs, paddingHorizontal: spacing.sm, - backgroundColor: colors.background.paper, - borderRadius: borderRadius.sm, - borderWidth: 1, - borderColor: colors.primary.main, + backgroundColor: `${colors.primary.main}10`, + borderRadius: borderRadius.md, + borderWidth: 0, }, actionButtons: { flexDirection: 'row', @@ -477,8 +476,10 @@ function createMaterialDetailStyles(colors: AppColors) { alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.md, - borderRadius: borderRadius.lg, + borderRadius: borderRadius.md, gap: spacing.xs, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, }, downloadButton: { flex: 1, @@ -486,7 +487,7 @@ function createMaterialDetailStyles(colors: AppColors) { alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.md, - borderRadius: borderRadius.lg, + borderRadius: borderRadius.md, gap: spacing.xs, }, actionButtonText: { diff --git a/src/screens/material/MaterialsScreen.tsx b/src/screens/material/MaterialsScreen.tsx index d00c90f..13ad2a3 100644 --- a/src/screens/material/MaterialsScreen.tsx +++ b/src/screens/material/MaterialsScreen.tsx @@ -84,13 +84,13 @@ export const MaterialsScreen: React.FC = () => { const cardStyle = useMemo( () => ({ backgroundColor: colors.background.paper, - borderRadius: 16, + borderRadius: borderRadius.lg, overflow: 'hidden' as const, shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.06, - shadowRadius: 8, - elevation: 2, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.04, + shadowRadius: 4, + elevation: 1, maxWidth: SUBJECT_CARD_MAX_WIDTH, alignSelf: 'center' as const, width: '100%' as const, @@ -117,9 +117,6 @@ export const MaterialsScreen: React.FC = () => { - - {subject.material_count} - @@ -271,9 +268,9 @@ function createMaterialsStyles(colors: AppColors) { paddingHorizontal: spacing.lg, }, subjectIconCircle: { - width: 56, - height: 56, - borderRadius: borderRadius.full, + width: 48, + height: 48, + borderRadius: borderRadius.lg, alignItems: 'center', justifyContent: 'center', }, diff --git a/src/screens/material/SubjectMaterialsScreen.tsx b/src/screens/material/SubjectMaterialsScreen.tsx index 4c12942..131a214 100644 --- a/src/screens/material/SubjectMaterialsScreen.tsx +++ b/src/screens/material/SubjectMaterialsScreen.tsx @@ -117,11 +117,11 @@ export const SubjectMaterialsScreen: React.FC = () => { const cardStyle = useMemo( () => ({ backgroundColor: colors.background.paper, - borderRadius: 12, + borderRadius: borderRadius.lg, overflow: 'hidden' as const, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, + shadowOpacity: 0.04, shadowRadius: 4, elevation: 1, maxWidth: MATERIAL_CARD_MAX_WIDTH, @@ -178,14 +178,14 @@ export const SubjectMaterialsScreen: React.FC = () => { style={[ styles.filterTab, selectedFileType === type && styles.filterTabActive, - selectedFileType === type && { backgroundColor: colors.primary.main }, + selectedFileType === type && { backgroundColor: `${colors.primary.main}14` }, ]} onPress={() => setSelectedFileType(type)} activeOpacity={0.7} > {label} @@ -337,11 +337,13 @@ function createSubjectMaterialsStyles(colors: AppColors) { filterTab: { paddingVertical: spacing.sm, paddingHorizontal: spacing.md, - borderRadius: borderRadius.full, + borderRadius: borderRadius.md, backgroundColor: colors.background.paper, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, }, filterTabActive: { - // backgroundColor set inline + borderColor: colors.primary.main, }, filterTabText: { fontWeight: '600', @@ -354,8 +356,8 @@ function createSubjectMaterialsStyles(colors: AppColors) { marginBottom: spacing.sm, }, fileTypeIcon: { - width: 44, - height: 44, + width: 40, + height: 40, borderRadius: borderRadius.md, alignItems: 'center', justifyContent: 'center', diff --git a/src/screens/message/GroupInfoScreen.tsx b/src/screens/message/GroupInfoScreen.tsx index 2321602..023807e 100644 --- a/src/screens/message/GroupInfoScreen.tsx +++ b/src/screens/message/GroupInfoScreen.tsx @@ -50,6 +50,7 @@ import { blurActiveElement } from '../../infrastructure/platform'; import { firstRouteParam } from '../../navigation/paramUtils'; import * as hrefs from '../../navigation/hrefs'; import { messageManager } from '../../stores/message'; +import { useMessageStore } from '../../stores/message/store'; import { groupManager } from '../../stores/group'; import MutualFollowSelectorModal from './components/MutualFollowSelectorModal'; import { AppBackButton } from '../../components/common'; @@ -95,6 +96,9 @@ const GroupInfoScreen: React.FC = () => { const [loading, setLoading] = useState(true); const [isPinned, setIsPinned] = useState(false); const [pinLoading, setPinLoading] = useState(false); + const isNotificationMuted = useMessageStore(state => + conversationId ? (state.notificationMutedMap.get(conversationId) || false) : false + ); // 编辑模态框状态 const [editModalVisible, setEditModalVisible] = useState(false); @@ -212,6 +216,16 @@ const GroupInfoScreen: React.FC = () => { } }; + // 设置会话免打扰 + const handleToggleNotificationMuted = async () => { + if (!conversationId) return; + try { + await messageManager.toggleNotificationMuted(conversationId, !isNotificationMuted); + } catch (error: any) { + Alert.alert('错误', error?.message || '设置免打扰失败'); + } + }; + // 打开编辑模态框 const openEditModal = () => { if (group) { @@ -799,6 +813,24 @@ const GroupInfoScreen: React.FC = () => { thumbColor={isPinned ? colors.primary.main : colors.background.paper} /> + + + + + + 消息免打扰 + + {conversationId ? (isNotificationMuted ? '已开启' : '未开启') : '请从聊天页面进入后设置'} + + + + diff --git a/src/screens/message/PrivateChatInfoScreen.tsx b/src/screens/message/PrivateChatInfoScreen.tsx index 0fa4f71..1f4d6a1 100644 --- a/src/screens/message/PrivateChatInfoScreen.tsx +++ b/src/screens/message/PrivateChatInfoScreen.tsx @@ -23,6 +23,7 @@ import { messageService } from '@/services/message'; import { ApiError } from '@/services/core'; import { messageRepository, conversationRepository } from '@/database'; import { messageManager } from '../../stores/message'; +import { useMessageStore } from '../../stores/message/store'; import { userManager } from '../../stores/user'; import { Avatar, Text, Button, Loading, Divider } from '../../components/common'; import { User } from '../../types'; @@ -51,7 +52,7 @@ const PrivateChatInfoScreen: React.FC = () => { const [isBlocked, setIsBlocked] = useState(false); // 聊天设置状态 - const [isMuted, setIsMuted] = useState(false); + const isMuted = useMessageStore(state => state.notificationMutedMap.get(conversationId) || false); const [isPinned, setIsPinned] = useState(false); // 加载用户信息 @@ -96,15 +97,12 @@ const PrivateChatInfoScreen: React.FC = () => { loadChatSettings(); }, [loadUserInfo, loadChatSettings]); - // 切换免打扰(仅本地状态,实际需要API支持) + // 切换免打扰 const toggleMute = async () => { try { const newValue = !isMuted; - setIsMuted(newValue); - // TODO: 调用API更新免打扰状态 - // await conversationService.updateConversationMute(conversationId, newValue); + await messageManager.toggleNotificationMuted(conversationId, newValue); } catch (error) { - setIsMuted(!isMuted); Alert.alert('错误', '设置免打扰失败'); } }; diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 0031600..8a3628b 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -705,8 +705,25 @@ export const useChatScreen = (props?: ChatScreenProps) => { } else { setActivePanel('none'); } + + if (selectedMentions.length > 0) { + const stillPresent = selectedMentions.filter(userId => { + const member = groupMembers.find(m => m.user_id === userId); + const nickname = member?.nickname || member?.user?.nickname; + if (!nickname) return false; + const escapedName = nickname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`@${escapedName}(\\s|$)`, 'g').test(text); + }); + if (stillPresent.length !== selectedMentions.length) { + setSelectedMentions(stillPresent); + } + } + + if (mentionAll && !text.includes('@所有人')) { + setMentionAll(false); + } } - }, [isGroupChat]); + }, [isGroupChat, selectedMentions, mentionAll, groupMembers]); // 【改造】获取发送者信息(群聊)- 优先从消息的 sender 字段获取 const getSenderInfo = useCallback((senderId: string): SenderInfo => { diff --git a/src/screens/schedule/ScheduleScreen.tsx b/src/screens/schedule/ScheduleScreen.tsx index af437e7..d71eb9e 100644 --- a/src/screens/schedule/ScheduleScreen.tsx +++ b/src/screens/schedule/ScheduleScreen.tsx @@ -47,6 +47,12 @@ import { scheduleService } from '@/services/platform'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); +// 周选择器固定宽度常量(用于精确计算滚动位置,确保选中周始终居中) +const WEEK_ITEM_WIDTH = 64; // 固定宽度,确保"第20周"能放下 +const WEEK_ITEM_MARGIN_HORIZONTAL = 3; // marginHorizontal 单侧值 +const WEEK_LIST_PADDING = 12; // contentContainer paddingHorizontal = spacing.sm +const SCROLL_VIEW_VISIBLE_WIDTH = SCREEN_WIDTH - 40 - 40; // 扣除返回按钮(40)和设置按钮(40) + // 时间段列宽度(按需求缩短) const TIME_COLUMN_WIDTH = 38; const VISIBLE_DAY_VALUES = [0, 1, 2, 3, 4, 5, 6]; // 周一到周日(0=周一) @@ -183,8 +189,8 @@ export const ScheduleScreen: React.FC = () => { // 电脑端:第六节课刚好填到底部(可用高度 = 屏幕高度 - 周选择器 - 星期标题) const sectionHeight = useMemo((): number => { const totalHeaderHeight = WEEK_SELECTOR_HEIGHT + HEADER_HEIGHT; - // 悬浮TabBar高度 + 安全区域底部 - const bottomOffset = isMobile ? FLOATING_TAB_BAR_HEIGHT + insets.bottom : 0; + // 悬浮TabBar高度 + 安全区域底部 + 额外底部padding避免遮挡 + const bottomOffset = isMobile ? FLOATING_TAB_BAR_HEIGHT + insets.bottom + 8 : 0; // 可用高度 = 屏幕高度 - 顶部区域 - 底部偏移 const availableHeight = screenHeight - totalHeaderHeight - bottomOffset; // 6节课,每节课高度 = 可用高度 / 6 @@ -256,11 +262,24 @@ export const ScheduleScreen: React.FC = () => { return Array.from({ length: TOTAL_WEEKS }, (_, i) => i + 1); }, []); + // 将当前选中周滚动到 ScrollView 可视区域正中央 useEffect(() => { - const WEEK_ITEM_ESTIMATED_WIDTH = 92; - const centerOffset = SCREEN_WIDTH / 2 - WEEK_ITEM_ESTIMATED_WIDTH / 2 - 40; - const targetX = Math.max(0, (currentWeek - 1) * WEEK_ITEM_ESTIMATED_WIDTH - centerOffset); - weekScrollRef.current?.scrollTo({ x: targetX, animated: true }); + // 第 N 个 item 的左边缘在内容坐标系中的位置 + // = padding + (N-1) * (width + margin*2) + margin + const itemLeft = WEEK_LIST_PADDING + + (currentWeek - 1) * (WEEK_ITEM_WIDTH + WEEK_ITEM_MARGIN_HORIZONTAL * 2) + + WEEK_ITEM_MARGIN_HORIZONTAL; + + // 目标滚动偏移 = item中心 - 可视区域中心 + const targetX = itemLeft + WEEK_ITEM_WIDTH / 2 - SCROLL_VIEW_VISIBLE_WIDTH / 2; + + // 限制在有效范围内(不小于0,不超过最大可滚动距离) + const totalContentWidth = WEEK_LIST_PADDING * 2 + + TOTAL_WEEKS * (WEEK_ITEM_WIDTH + WEEK_ITEM_MARGIN_HORIZONTAL * 2); + const maxScrollX = Math.max(0, totalContentWidth - SCROLL_VIEW_VISIBLE_WIDTH); + const clampedX = Math.max(0, Math.min(targetX, maxScrollX)); + + weekScrollRef.current?.scrollTo({ x: clampedX, animated: true }); }, [currentWeek]); useFocusEffect( @@ -427,14 +446,14 @@ export const ScheduleScreen: React.FC = () => { accessibilityRole="button" accessibilityLabel="返回" > - + ) : null} setIsSettingsModalVisible(true)} > - + { showsHorizontalScrollIndicator={false} contentContainerStyle={styles.weekList} > - {weeks.map(week => ( - setCurrentWeek(week)} - > - { + const isCurrentRealWeek = week === INITIAL_WEEK; + const isSelected = currentWeek === week; + return ( + setCurrentWeek(week)} > - 第{week}周 - - - ))} + + 第{week}周 + + + ); + })} ); @@ -805,7 +830,7 @@ export const ScheduleScreen: React.FC = () => { return ( - + {/* 周选择器 */} {renderWeekSelector()} @@ -1147,20 +1172,23 @@ function createScheduleStyles(colors: AppColors) { // ── 周选择器 ────────────────────────────────────────── weekSelector: { height: WEEK_SELECTOR_HEIGHT, - backgroundColor: colors.primary.main, + backgroundColor: colors.background.paper, flexDirection: 'row', alignItems: 'center', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.divider, + paddingHorizontal: spacing.sm, }, weekBarIconButton: { - width: 40, - height: 40, + width: 36, + height: 36, justifyContent: 'center', alignItems: 'center', - marginLeft: -8, + marginLeft: -4, }, settingsButton: { - width: 40, - height: 40, + width: 36, + height: 36, justifyContent: 'center', alignItems: 'center', marginLeft: spacing.xs, @@ -1170,31 +1198,41 @@ function createScheduleStyles(colors: AppColors) { alignItems: 'center', }, weekItem: { - paddingHorizontal: spacing.md, + width: WEEK_ITEM_WIDTH, paddingVertical: 5, - marginHorizontal: 3, - borderRadius: borderRadius.full, + marginHorizontal: WEEK_ITEM_MARGIN_HORIZONTAL, + borderRadius: borderRadius.md, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.background.default, }, weekItemActive: { - backgroundColor: 'rgba(255,255,255,0.28)', + backgroundColor: colors.primary.main, + }, + weekItemCurrentWeek: { + backgroundColor: `${colors.primary.main}18`, }, weekItemText: { fontSize: fontSizes.sm, fontWeight: '500', - color: 'rgba(255,255,255,0.75)', + color: colors.text.secondary, letterSpacing: 0.2, }, weekItemTextActive: { color: colors.text.inverse, fontWeight: '700', }, + weekItemTextCurrentWeek: { + color: colors.primary.main, + fontWeight: '600', + }, // ── 星期标题行 ───────────────────────────────────────── header: { flexDirection: 'row', height: HEADER_HEIGHT, backgroundColor: colors.background.paper, - borderBottomWidth: 1, + borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.divider, }, timeHeader: { @@ -1268,7 +1306,7 @@ function createScheduleStyles(colors: AppColors) { alignItems: 'center', justifyContent: 'center', borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#EBEBEB', + borderBottomColor: colors.divider, gap: 4, }, timeSectionBadge: { @@ -1303,22 +1341,22 @@ function createScheduleStyles(colors: AppColors) { // ── 单日列 ──────────────────────────────────────────── dayColumn: { flex: 1, - backgroundColor: colors.background.default, // 使用主题默认背景色 - borderRightWidth: 1, - borderRightColor: '#EBEBEB', + backgroundColor: colors.background.default, + borderRightWidth: StyleSheet.hairlineWidth, + borderRightColor: colors.divider, }, gridCell: { borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#EBEBEB', + borderBottomColor: colors.divider, }, // ── 课程卡片 ────────────────────────────────────────── courseCard: { position: 'absolute', - borderRadius: borderRadius.xl, + borderRadius: borderRadius.lg, paddingVertical: spacing.xs + 2, paddingHorizontal: spacing.xs + 1, - ...shadows.md, + ...shadows.sm, }, courseName: { fontSize: 10, diff --git a/src/services/message/messageService.ts b/src/services/message/messageService.ts index 784d660..b1863c3 100644 --- a/src/services/message/messageService.ts +++ b/src/services/message/messageService.ts @@ -274,6 +274,12 @@ class MessageService { }); } + async setConversationNotificationMuted(conversationId: string, notificationMuted: boolean): Promise { + await api.put(`/conversations/${encodeURIComponent(conversationId)}/notification_muted`, { + notification_muted: notificationMuted, + }); + } + // ==================== 消息相关 ==================== /** diff --git a/src/services/notification/systemNotificationService.ts b/src/services/notification/systemNotificationService.ts index abe713f..bc5b386 100644 --- a/src/services/notification/systemNotificationService.ts +++ b/src/services/notification/systemNotificationService.ts @@ -10,6 +10,8 @@ import type { WSChatMessage, WSNotificationMessage, WSAnnouncementMessage } from import { extractTextFromSegments } from '@/types/dto'; import { getNotificationPreferencesSync } from './notificationPreferences'; import { vibrateOnMessage } from '../platform/messageVibrationService'; +import { useMessageStore } from '@/stores/message/store'; +import { normalizeConversationId } from '@/stores/message/store'; // 通知渠道配置 const CHANNEL_ID = 'default'; @@ -207,9 +209,12 @@ class SystemNotificationService { // 判断是否是聊天消息(通过 segments 字段) if ('segments' in message) { const chatMsg = message as WSChatMessage; - const body = extractTextFromSegments(chatMsg.segments); + const conversationId = normalizeConversationId(chatMsg.conversation_id); + const isNotificationMuted = useMessageStore.getState().isNotificationMuted(conversationId); + if (isNotificationMuted) { + return; + } void chatMsg; - void body; await this.showChatNotification(chatMsg); } else { const notifMsg = message as WSNotificationMessage | WSAnnouncementMessage; diff --git a/src/stores/message/MessageManager.ts b/src/stores/message/MessageManager.ts index 1d730ee..4d94009 100644 --- a/src/stores/message/MessageManager.ts +++ b/src/stores/message/MessageManager.ts @@ -310,6 +310,21 @@ class MessageManager { return useMessageStore.getState().setMutedStatus(groupId, isMuted); } + isNotificationMuted(conversationId: string): boolean { + return useMessageStore.getState().isNotificationMuted(conversationId); + } + + setNotificationMuted(conversationId: string, notificationMuted: boolean): void { + return useMessageStore.getState().setNotificationMuted(conversationId, notificationMuted); + } + + async toggleNotificationMuted(conversationId: string, notificationMuted: boolean): Promise { + const { messageService } = await import('@/services/message'); + await messageService.setConversationNotificationMuted(conversationId, notificationMuted); + this.setNotificationMuted(conversationId, notificationMuted); + this.updateConversation(conversationId, { notification_muted: notificationMuted }); + } + // ==================== 活动会话管理 ==================== setActiveConversation(conversationId: string | null): void { diff --git a/src/stores/message/services/WSMessageHandler.ts b/src/stores/message/services/WSMessageHandler.ts index 7b3051f..8f6164b 100644 --- a/src/stores/message/services/WSMessageHandler.ts +++ b/src/stores/message/services/WSMessageHandler.ts @@ -317,7 +317,8 @@ export class WSMessageHandler implements IWSMessageHandler { !_isAck; if (shouldIncrementUnread) { - if (!suppressVibration) { + const isNotificationMuted = store.isNotificationMuted(normalizedConversationId); + if (!suppressVibration && !isNotificationMuted) { const vibrationType = message.type === 'group_message' ? 'group_message' : 'message'; eventBus.emit({ type: 'VIBRATE', payload: { type: vibrationType } }); } diff --git a/src/stores/message/store.ts b/src/stores/message/store.ts index b7ff030..75b1179 100644 --- a/src/stores/message/store.ts +++ b/src/stores/message/store.ts @@ -55,6 +55,9 @@ export interface MessageState { // 当前用户的禁言状态 - 按群组ID存储 mutedStatusMap: Map; + + // 会话免打扰状态 - 按会话ID存储 + notificationMutedMap: Map; } // ==================== Actions 接口 ==================== @@ -72,6 +75,7 @@ export interface MessageActions { getTypingUsers: (groupId: string) => string[]; isMuted: (groupId: string) => boolean; getActiveConversation: () => string | null; + isNotificationMuted: (conversationId: string) => boolean; // 设置状态 setConversations: (conversations: Map) => void; @@ -88,6 +92,7 @@ export interface MessageActions { setLoadingMessages: (conversationId: string, loading: boolean) => void; setTypingUsers: (groupId: string, users: string[]) => void; setMutedStatus: (groupId: string, isMuted: boolean) => void; + setNotificationMuted: (conversationId: string, notificationMuted: boolean) => void; setInitialized: (initialized: boolean) => void; // 重置 @@ -110,6 +115,7 @@ const initialState: MessageState = { isInitialized: false, typingUsersMap: new Map(), mutedStatusMap: new Map(), + notificationMutedMap: new Map(), }; // ==================== 工具函数 ==================== @@ -177,6 +183,7 @@ export const useMessageStore = create((set, get) => ({ isInitialized: state.isInitialized, typingUsersMap: state.typingUsersMap, mutedStatusMap: state.mutedStatusMap, + notificationMutedMap: state.notificationMutedMap, }; }, @@ -227,11 +234,25 @@ export const useMessageStore = create((set, get) => ({ return get().currentConversationId; }, + isNotificationMuted: (conversationId: string) => { + return get().notificationMutedMap.get(conversationId) || false; + }, + // ==================== 设置状态 ==================== setConversations: (conversations: Map) => { const conversationList = sortConversationList(conversations); - set({ conversations, conversationList }); + const notificationMutedMap = new Map(); + conversations.forEach((conv, id) => { + if (conv.notification_muted) { + notificationMutedMap.set(id, true); + } + }); + set(state => ({ + conversations, + conversationList, + notificationMutedMap: new Map([...state.notificationMutedMap, ...notificationMutedMap]), + })); }, updateConversation: (conversation: ConversationResponse) => { @@ -240,7 +261,13 @@ export const useMessageStore = create((set, get) => ({ const newConversations = new Map(state.conversations); newConversations.set(id, { ...conversation, id }); const conversationList = sortConversationList(newConversations); - return { conversations: newConversations, conversationList }; + const newNotificationMutedMap = new Map(state.notificationMutedMap); + if (conversation.notification_muted) { + newNotificationMutedMap.set(id, true); + } else { + newNotificationMutedMap.delete(id); + } + return { conversations: newConversations, conversationList, notificationMutedMap: newNotificationMutedMap }; }); }, @@ -374,6 +401,14 @@ export const useMessageStore = create((set, get) => ({ }); }, + setNotificationMuted: (conversationId: string, notificationMuted: boolean) => { + set(state => { + const newNotificationMutedMap = new Map(state.notificationMutedMap); + newNotificationMutedMap.set(conversationId, notificationMuted); + return { notificationMutedMap: newNotificationMutedMap }; + }); + }, + setInitialized: (initialized: boolean) => { set({ isInitialized: initialized }); }, @@ -388,6 +423,7 @@ export const useMessageStore = create((set, get) => ({ loadingMessagesSet: new Set(), typingUsersMap: new Map(), mutedStatusMap: new Map(), + notificationMutedMap: new Map(), }); AsyncStorage.removeItem(LAST_SYSTEM_MESSAGE_AT_KEY).catch(() => {}); }, diff --git a/src/types/dto.ts b/src/types/dto.ts index 2bb7b34..eeadcfa 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -266,6 +266,7 @@ export interface ConversationResponse { id: string; // 雪花算法ID (使用string避免JavaScript精度丢失) type: ConversationType; is_pinned?: boolean; + notification_muted?: boolean; last_seq: number; last_message?: MessageResponse; last_message_at: string; @@ -283,7 +284,8 @@ export interface ConversationResponse { export interface ConversationParticipantResponse { user_id: string; last_read_seq: number; - muted: boolean; + notification_muted: boolean; + is_pinned: boolean; } // 会话详情响应 @@ -291,6 +293,7 @@ export interface ConversationDetailResponse { id: string; type: ConversationType; is_pinned?: boolean; + notification_muted?: boolean; last_seq: number; last_message?: MessageResponse; last_message_at: string;