feat(message): add conversation notification mute feature
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 57s
Frontend CI / ota-android (push) Successful in 10m53s
Frontend CI / build-android-apk (push) Failing after 1h12m12s

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
This commit is contained in:
lafay
2026-04-25 21:23:22 +08:00
parent de0afa93a1
commit 5fa5403d6a
15 changed files with 252 additions and 101 deletions

View File

@@ -721,7 +721,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}> <TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}>
<MaterialCommunityIcons <MaterialCommunityIcons
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'} name={post.is_favorited ? 'star' : 'star-outline'}
size={18} size={18}
color={post.is_favorited ? colors.warning.main : colors.text.secondary} color={post.is_favorited ? colors.warning.main : colors.text.secondary}
/> />

View File

@@ -59,13 +59,13 @@ export const AppsScreen: React.FC = () => {
() => ({ () => ({
marginBottom: spacing.md, marginBottom: spacing.md,
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderRadius: 16, borderRadius: borderRadius.lg,
overflow: 'hidden' as const, overflow: 'hidden' as const,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06, shadowOpacity: 0.04,
shadowRadius: 8, shadowRadius: 4,
elevation: 2, elevation: 1,
maxWidth: APP_CARD_MAX_WIDTH, maxWidth: APP_CARD_MAX_WIDTH,
alignSelf: 'center' as const, alignSelf: 'center' as const,
width: '100%' as const, width: '100%' as const,
@@ -102,7 +102,7 @@ export const AppsScreen: React.FC = () => {
> >
<View style={styles.appCardInner}> <View style={styles.appCardInner}>
<View style={styles.appIconCircle}> <View style={styles.appIconCircle}>
<MaterialCommunityIcons name={item.icon} size={26} color={colors.primary.contrast} /> <MaterialCommunityIcons name={item.icon} size={24} color={colors.primary.main} />
</View> </View>
<View style={styles.appCardText}> <View style={styles.appCardText}>
<Text variant="body" color={colors.text.primary} style={styles.appTitle}> <Text variant="body" color={colors.text.primary} style={styles.appTitle}>
@@ -112,7 +112,7 @@ export const AppsScreen: React.FC = () => {
{item.subtitle} {item.subtitle}
</Text> </Text>
</View> </View>
<MaterialCommunityIcons name="chevron-right" size={22} color={colors.primary.main} /> <MaterialCommunityIcons name="chevron-right" size={22} color={colors.text.hint} />
</View> </View>
</TouchableOpacity> </TouchableOpacity>
))} ))}
@@ -223,12 +223,12 @@ function createAppsStyles(colors: AppColors) {
paddingVertical: spacing.lg, paddingVertical: spacing.lg,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
}, },
/** 与首页发帖 FAB 同主色实心圆 */ /** 扁平化主色图标背景 */
appIconCircle: { appIconCircle: {
width: 52, width: 48,
height: 52, height: 48,
borderRadius: borderRadius.full, borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main, backgroundColor: `${colors.primary.main}14`,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },

View File

@@ -117,13 +117,13 @@ export const MaterialDetailScreen: React.FC = () => {
const cardStyle = useMemo( const cardStyle = useMemo(
() => ({ () => ({
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderRadius: 16, borderRadius: borderRadius.lg,
overflow: 'hidden' as const, overflow: 'hidden' as const,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06, shadowOpacity: 0.04,
shadowRadius: 8, shadowRadius: 4,
elevation: 2, elevation: 1,
}), }),
[colors] [colors]
); );
@@ -390,7 +390,7 @@ function createMaterialDetailStyles(colors: AppColors) {
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
fileTypeBanner: { fileTypeBanner: {
height: 100, height: 88,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
@@ -461,10 +461,9 @@ function createMaterialDetailStyles(colors: AppColors) {
tag: { tag: {
paddingVertical: spacing.xs, paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
backgroundColor: colors.background.paper, backgroundColor: `${colors.primary.main}10`,
borderRadius: borderRadius.sm, borderRadius: borderRadius.md,
borderWidth: 1, borderWidth: 0,
borderColor: colors.primary.main,
}, },
actionButtons: { actionButtons: {
flexDirection: 'row', flexDirection: 'row',
@@ -477,8 +476,10 @@ function createMaterialDetailStyles(colors: AppColors) {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: spacing.md, paddingVertical: spacing.md,
borderRadius: borderRadius.lg, borderRadius: borderRadius.md,
gap: spacing.xs, gap: spacing.xs,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
}, },
downloadButton: { downloadButton: {
flex: 1, flex: 1,
@@ -486,7 +487,7 @@ function createMaterialDetailStyles(colors: AppColors) {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: spacing.md, paddingVertical: spacing.md,
borderRadius: borderRadius.lg, borderRadius: borderRadius.md,
gap: spacing.xs, gap: spacing.xs,
}, },
actionButtonText: { actionButtonText: {

View File

@@ -84,13 +84,13 @@ export const MaterialsScreen: React.FC = () => {
const cardStyle = useMemo( const cardStyle = useMemo(
() => ({ () => ({
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderRadius: 16, borderRadius: borderRadius.lg,
overflow: 'hidden' as const, overflow: 'hidden' as const,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06, shadowOpacity: 0.04,
shadowRadius: 8, shadowRadius: 4,
elevation: 2, elevation: 1,
maxWidth: SUBJECT_CARD_MAX_WIDTH, maxWidth: SUBJECT_CARD_MAX_WIDTH,
alignSelf: 'center' as const, alignSelf: 'center' as const,
width: '100%' as const, width: '100%' as const,
@@ -117,9 +117,6 @@ export const MaterialsScreen: React.FC = () => {
</Text> </Text>
</View> </View>
<View style={styles.subjectMeta}> <View style={styles.subjectMeta}>
<Text variant="caption" color={colors.text.hint}>
{subject.material_count}
</Text>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} /> <MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@@ -271,9 +268,9 @@ function createMaterialsStyles(colors: AppColors) {
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
}, },
subjectIconCircle: { subjectIconCircle: {
width: 56, width: 48,
height: 56, height: 48,
borderRadius: borderRadius.full, borderRadius: borderRadius.lg,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },

View File

@@ -117,11 +117,11 @@ export const SubjectMaterialsScreen: React.FC = () => {
const cardStyle = useMemo( const cardStyle = useMemo(
() => ({ () => ({
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderRadius: 12, borderRadius: borderRadius.lg,
overflow: 'hidden' as const, overflow: 'hidden' as const,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05, shadowOpacity: 0.04,
shadowRadius: 4, shadowRadius: 4,
elevation: 1, elevation: 1,
maxWidth: MATERIAL_CARD_MAX_WIDTH, maxWidth: MATERIAL_CARD_MAX_WIDTH,
@@ -178,14 +178,14 @@ export const SubjectMaterialsScreen: React.FC = () => {
style={[ style={[
styles.filterTab, styles.filterTab,
selectedFileType === type && styles.filterTabActive, selectedFileType === type && styles.filterTabActive,
selectedFileType === type && { backgroundColor: colors.primary.main }, selectedFileType === type && { backgroundColor: `${colors.primary.main}14` },
]} ]}
onPress={() => setSelectedFileType(type)} onPress={() => setSelectedFileType(type)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text <Text
variant="caption" variant="caption"
color={selectedFileType === type ? colors.primary.contrast : colors.text.secondary} color={selectedFileType === type ? colors.primary.main : colors.text.secondary}
style={styles.filterTabText} style={styles.filterTabText}
> >
{label} {label}
@@ -337,11 +337,13 @@ function createSubjectMaterialsStyles(colors: AppColors) {
filterTab: { filterTab: {
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
borderRadius: borderRadius.full, borderRadius: borderRadius.md,
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
}, },
filterTabActive: { filterTabActive: {
// backgroundColor set inline borderColor: colors.primary.main,
}, },
filterTabText: { filterTabText: {
fontWeight: '600', fontWeight: '600',
@@ -354,8 +356,8 @@ function createSubjectMaterialsStyles(colors: AppColors) {
marginBottom: spacing.sm, marginBottom: spacing.sm,
}, },
fileTypeIcon: { fileTypeIcon: {
width: 44, width: 40,
height: 44, height: 40,
borderRadius: borderRadius.md, borderRadius: borderRadius.md,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',

View File

@@ -50,6 +50,7 @@ import { blurActiveElement } from '../../infrastructure/platform';
import { firstRouteParam } from '../../navigation/paramUtils'; import { firstRouteParam } from '../../navigation/paramUtils';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { messageManager } from '../../stores/message'; import { messageManager } from '../../stores/message';
import { useMessageStore } from '../../stores/message/store';
import { groupManager } from '../../stores/group'; import { groupManager } from '../../stores/group';
import MutualFollowSelectorModal from './components/MutualFollowSelectorModal'; import MutualFollowSelectorModal from './components/MutualFollowSelectorModal';
import { AppBackButton } from '../../components/common'; import { AppBackButton } from '../../components/common';
@@ -95,6 +96,9 @@ const GroupInfoScreen: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isPinned, setIsPinned] = useState(false); const [isPinned, setIsPinned] = useState(false);
const [pinLoading, setPinLoading] = useState(false); const [pinLoading, setPinLoading] = useState(false);
const isNotificationMuted = useMessageStore(state =>
conversationId ? (state.notificationMutedMap.get(conversationId) || false) : false
);
// 编辑模态框状态 // 编辑模态框状态
const [editModalVisible, setEditModalVisible] = useState(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 = () => { const openEditModal = () => {
if (group) { if (group) {
@@ -799,6 +813,24 @@ const GroupInfoScreen: React.FC = () => {
thumbColor={isPinned ? colors.primary.main : colors.background.paper} thumbColor={isPinned ? colors.primary.main : colors.background.paper}
/> />
</View> </View>
<View style={styles.settingItem}>
<View style={styles.settingIconContainer}>
<MaterialCommunityIcons name="bell-off-outline" size={20} color={colors.primary.main} />
</View>
<View style={styles.settingContent}>
<Text variant="body"></Text>
<Text variant="caption" color={colors.text.secondary}>
{conversationId ? (isNotificationMuted ? '已开启' : '未开启') : '请从聊天页面进入后设置'}
</Text>
</View>
<Switch
value={isNotificationMuted}
onValueChange={handleToggleNotificationMuted}
disabled={!conversationId}
trackColor={{ false: colors.divider, true: colors.primary.light }}
thumbColor={isNotificationMuted ? colors.primary.main : colors.background.paper}
/>
</View>
</View> </View>
</View> </View>

View File

@@ -23,6 +23,7 @@ import { messageService } from '@/services/message';
import { ApiError } from '@/services/core'; import { ApiError } from '@/services/core';
import { messageRepository, conversationRepository } from '@/database'; import { messageRepository, conversationRepository } from '@/database';
import { messageManager } from '../../stores/message'; import { messageManager } from '../../stores/message';
import { useMessageStore } from '../../stores/message/store';
import { userManager } from '../../stores/user'; import { userManager } from '../../stores/user';
import { Avatar, Text, Button, Loading, Divider } from '../../components/common'; import { Avatar, Text, Button, Loading, Divider } from '../../components/common';
import { User } from '../../types'; import { User } from '../../types';
@@ -51,7 +52,7 @@ const PrivateChatInfoScreen: React.FC = () => {
const [isBlocked, setIsBlocked] = useState(false); const [isBlocked, setIsBlocked] = useState(false);
// 聊天设置状态 // 聊天设置状态
const [isMuted, setIsMuted] = useState(false); const isMuted = useMessageStore(state => state.notificationMutedMap.get(conversationId) || false);
const [isPinned, setIsPinned] = useState(false); const [isPinned, setIsPinned] = useState(false);
// 加载用户信息 // 加载用户信息
@@ -96,15 +97,12 @@ const PrivateChatInfoScreen: React.FC = () => {
loadChatSettings(); loadChatSettings();
}, [loadUserInfo, loadChatSettings]); }, [loadUserInfo, loadChatSettings]);
// 切换免打扰仅本地状态实际需要API支持 // 切换免打扰
const toggleMute = async () => { const toggleMute = async () => {
try { try {
const newValue = !isMuted; const newValue = !isMuted;
setIsMuted(newValue); await messageManager.toggleNotificationMuted(conversationId, newValue);
// TODO: 调用API更新免打扰状态
// await conversationService.updateConversationMute(conversationId, newValue);
} catch (error) { } catch (error) {
setIsMuted(!isMuted);
Alert.alert('错误', '设置免打扰失败'); Alert.alert('错误', '设置免打扰失败');
} }
}; };

View File

@@ -705,8 +705,25 @@ export const useChatScreen = (props?: ChatScreenProps) => {
} else { } else {
setActivePanel('none'); 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);
} }
}, [isGroupChat]); }
if (mentionAll && !text.includes('@所有人')) {
setMentionAll(false);
}
}
}, [isGroupChat, selectedMentions, mentionAll, groupMembers]);
// 【改造】获取发送者信息(群聊)- 优先从消息的 sender 字段获取 // 【改造】获取发送者信息(群聊)- 优先从消息的 sender 字段获取
const getSenderInfo = useCallback((senderId: string): SenderInfo => { const getSenderInfo = useCallback((senderId: string): SenderInfo => {

View File

@@ -47,6 +47,12 @@ import { scheduleService } from '@/services/platform';
const { width: SCREEN_WIDTH } = Dimensions.get('window'); 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 TIME_COLUMN_WIDTH = 38;
const VISIBLE_DAY_VALUES = [0, 1, 2, 3, 4, 5, 6]; // 周一到周日0=周一) 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 sectionHeight = useMemo((): number => {
const totalHeaderHeight = WEEK_SELECTOR_HEIGHT + HEADER_HEIGHT; const totalHeaderHeight = WEEK_SELECTOR_HEIGHT + HEADER_HEIGHT;
// 悬浮TabBar高度 + 安全区域底部 // 悬浮TabBar高度 + 安全区域底部 + 额外底部padding避免遮挡
const bottomOffset = isMobile ? FLOATING_TAB_BAR_HEIGHT + insets.bottom : 0; const bottomOffset = isMobile ? FLOATING_TAB_BAR_HEIGHT + insets.bottom + 8 : 0;
// 可用高度 = 屏幕高度 - 顶部区域 - 底部偏移 // 可用高度 = 屏幕高度 - 顶部区域 - 底部偏移
const availableHeight = screenHeight - totalHeaderHeight - bottomOffset; const availableHeight = screenHeight - totalHeaderHeight - bottomOffset;
// 6节课每节课高度 = 可用高度 / 6 // 6节课每节课高度 = 可用高度 / 6
@@ -256,11 +262,24 @@ export const ScheduleScreen: React.FC = () => {
return Array.from({ length: TOTAL_WEEKS }, (_, i) => i + 1); return Array.from({ length: TOTAL_WEEKS }, (_, i) => i + 1);
}, []); }, []);
// 将当前选中周滚动到 ScrollView 可视区域正中央
useEffect(() => { useEffect(() => {
const WEEK_ITEM_ESTIMATED_WIDTH = 92; // 第 N 个 item 的左边缘在内容坐标系中的位置
const centerOffset = SCREEN_WIDTH / 2 - WEEK_ITEM_ESTIMATED_WIDTH / 2 - 40; // = padding + (N-1) * (width + margin*2) + margin
const targetX = Math.max(0, (currentWeek - 1) * WEEK_ITEM_ESTIMATED_WIDTH - centerOffset); const itemLeft = WEEK_LIST_PADDING
weekScrollRef.current?.scrollTo({ x: targetX, animated: true }); + (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]); }, [currentWeek]);
useFocusEffect( useFocusEffect(
@@ -427,14 +446,14 @@ export const ScheduleScreen: React.FC = () => {
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="返回" accessibilityLabel="返回"
> >
<MaterialCommunityIcons name="chevron-left" size={28} color="#FFFFFF" /> <MaterialCommunityIcons name="chevron-left" size={28} color={colors.text.primary} />
</TouchableOpacity> </TouchableOpacity>
) : null} ) : null}
<TouchableOpacity <TouchableOpacity
style={styles.settingsButton} style={styles.settingsButton}
onPress={() => setIsSettingsModalVisible(true)} onPress={() => setIsSettingsModalVisible(true)}
> >
<MaterialCommunityIcons name="cog" size={24} color="#FFFFFF" /> <MaterialCommunityIcons name="cog" size={22} color={colors.text.secondary} />
</TouchableOpacity> </TouchableOpacity>
<ScrollView <ScrollView
ref={weekScrollRef} ref={weekScrollRef}
@@ -442,25 +461,31 @@ export const ScheduleScreen: React.FC = () => {
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.weekList} contentContainerStyle={styles.weekList}
> >
{weeks.map(week => ( {weeks.map(week => {
const isCurrentRealWeek = week === INITIAL_WEEK;
const isSelected = currentWeek === week;
return (
<TouchableOpacity <TouchableOpacity
key={week} key={week}
style={[ style={[
styles.weekItem, styles.weekItem,
currentWeek === week && styles.weekItemActive, isSelected && styles.weekItemActive,
isCurrentRealWeek && !isSelected && styles.weekItemCurrentWeek,
]} ]}
onPress={() => setCurrentWeek(week)} onPress={() => setCurrentWeek(week)}
> >
<Text <Text
style={[ style={[
styles.weekItemText, styles.weekItemText,
currentWeek === week && styles.weekItemTextActive, isSelected && styles.weekItemTextActive,
isCurrentRealWeek && !isSelected && styles.weekItemTextCurrentWeek,
]} ]}
> >
{week} {week}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} );
})}
</ScrollView> </ScrollView>
</View> </View>
); );
@@ -805,7 +830,7 @@ export const ScheduleScreen: React.FC = () => {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<StatusBar barStyle="light-content" backgroundColor={colors.primary.main} /> <StatusBar barStyle="dark-content" backgroundColor={colors.background.paper} />
{/* 周选择器 */} {/* 周选择器 */}
{renderWeekSelector()} {renderWeekSelector()}
@@ -1147,20 +1172,23 @@ function createScheduleStyles(colors: AppColors) {
// ── 周选择器 ────────────────────────────────────────── // ── 周选择器 ──────────────────────────────────────────
weekSelector: { weekSelector: {
height: WEEK_SELECTOR_HEIGHT, height: WEEK_SELECTOR_HEIGHT,
backgroundColor: colors.primary.main, backgroundColor: colors.background.paper,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
paddingHorizontal: spacing.sm,
}, },
weekBarIconButton: { weekBarIconButton: {
width: 40, width: 36,
height: 40, height: 36,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginLeft: -8, marginLeft: -4,
}, },
settingsButton: { settingsButton: {
width: 40, width: 36,
height: 40, height: 36,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginLeft: spacing.xs, marginLeft: spacing.xs,
@@ -1170,31 +1198,41 @@ function createScheduleStyles(colors: AppColors) {
alignItems: 'center', alignItems: 'center',
}, },
weekItem: { weekItem: {
paddingHorizontal: spacing.md, width: WEEK_ITEM_WIDTH,
paddingVertical: 5, paddingVertical: 5,
marginHorizontal: 3, marginHorizontal: WEEK_ITEM_MARGIN_HORIZONTAL,
borderRadius: borderRadius.full, borderRadius: borderRadius.md,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.default,
}, },
weekItemActive: { weekItemActive: {
backgroundColor: 'rgba(255,255,255,0.28)', backgroundColor: colors.primary.main,
},
weekItemCurrentWeek: {
backgroundColor: `${colors.primary.main}18`,
}, },
weekItemText: { weekItemText: {
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
fontWeight: '500', fontWeight: '500',
color: 'rgba(255,255,255,0.75)', color: colors.text.secondary,
letterSpacing: 0.2, letterSpacing: 0.2,
}, },
weekItemTextActive: { weekItemTextActive: {
color: colors.text.inverse, color: colors.text.inverse,
fontWeight: '700', fontWeight: '700',
}, },
weekItemTextCurrentWeek: {
color: colors.primary.main,
fontWeight: '600',
},
// ── 星期标题行 ───────────────────────────────────────── // ── 星期标题行 ─────────────────────────────────────────
header: { header: {
flexDirection: 'row', flexDirection: 'row',
height: HEADER_HEIGHT, height: HEADER_HEIGHT,
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderBottomWidth: 1, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider, borderBottomColor: colors.divider,
}, },
timeHeader: { timeHeader: {
@@ -1268,7 +1306,7 @@ function createScheduleStyles(colors: AppColors) {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#EBEBEB', borderBottomColor: colors.divider,
gap: 4, gap: 4,
}, },
timeSectionBadge: { timeSectionBadge: {
@@ -1303,22 +1341,22 @@ function createScheduleStyles(colors: AppColors) {
// ── 单日列 ──────────────────────────────────────────── // ── 单日列 ────────────────────────────────────────────
dayColumn: { dayColumn: {
flex: 1, flex: 1,
backgroundColor: colors.background.default, // 使用主题默认背景色 backgroundColor: colors.background.default,
borderRightWidth: 1, borderRightWidth: StyleSheet.hairlineWidth,
borderRightColor: '#EBEBEB', borderRightColor: colors.divider,
}, },
gridCell: { gridCell: {
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#EBEBEB', borderBottomColor: colors.divider,
}, },
// ── 课程卡片 ────────────────────────────────────────── // ── 课程卡片 ──────────────────────────────────────────
courseCard: { courseCard: {
position: 'absolute', position: 'absolute',
borderRadius: borderRadius.xl, borderRadius: borderRadius.lg,
paddingVertical: spacing.xs + 2, paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.xs + 1, paddingHorizontal: spacing.xs + 1,
...shadows.md, ...shadows.sm,
}, },
courseName: { courseName: {
fontSize: 10, fontSize: 10,

View File

@@ -274,6 +274,12 @@ class MessageService {
}); });
} }
async setConversationNotificationMuted(conversationId: string, notificationMuted: boolean): Promise<void> {
await api.put(`/conversations/${encodeURIComponent(conversationId)}/notification_muted`, {
notification_muted: notificationMuted,
});
}
// ==================== 消息相关 ==================== // ==================== 消息相关 ====================
/** /**

View File

@@ -10,6 +10,8 @@ import type { WSChatMessage, WSNotificationMessage, WSAnnouncementMessage } from
import { extractTextFromSegments } from '@/types/dto'; import { extractTextFromSegments } from '@/types/dto';
import { getNotificationPreferencesSync } from './notificationPreferences'; import { getNotificationPreferencesSync } from './notificationPreferences';
import { vibrateOnMessage } from '../platform/messageVibrationService'; import { vibrateOnMessage } from '../platform/messageVibrationService';
import { useMessageStore } from '@/stores/message/store';
import { normalizeConversationId } from '@/stores/message/store';
// 通知渠道配置 // 通知渠道配置
const CHANNEL_ID = 'default'; const CHANNEL_ID = 'default';
@@ -207,9 +209,12 @@ class SystemNotificationService {
// 判断是否是聊天消息(通过 segments 字段) // 判断是否是聊天消息(通过 segments 字段)
if ('segments' in message) { if ('segments' in message) {
const chatMsg = message as WSChatMessage; 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 chatMsg;
void body;
await this.showChatNotification(chatMsg); await this.showChatNotification(chatMsg);
} else { } else {
const notifMsg = message as WSNotificationMessage | WSAnnouncementMessage; const notifMsg = message as WSNotificationMessage | WSAnnouncementMessage;

View File

@@ -310,6 +310,21 @@ class MessageManager {
return useMessageStore.getState().setMutedStatus(groupId, isMuted); 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<void> {
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 { setActiveConversation(conversationId: string | null): void {

View File

@@ -317,7 +317,8 @@ export class WSMessageHandler implements IWSMessageHandler {
!_isAck; !_isAck;
if (shouldIncrementUnread) { if (shouldIncrementUnread) {
if (!suppressVibration) { const isNotificationMuted = store.isNotificationMuted(normalizedConversationId);
if (!suppressVibration && !isNotificationMuted) {
const vibrationType = message.type === 'group_message' ? 'group_message' : 'message'; const vibrationType = message.type === 'group_message' ? 'group_message' : 'message';
eventBus.emit({ type: 'VIBRATE', payload: { type: vibrationType } }); eventBus.emit({ type: 'VIBRATE', payload: { type: vibrationType } });
} }

View File

@@ -55,6 +55,9 @@ export interface MessageState {
// 当前用户的禁言状态 - 按群组ID存储 // 当前用户的禁言状态 - 按群组ID存储
mutedStatusMap: Map<string, boolean>; mutedStatusMap: Map<string, boolean>;
// 会话免打扰状态 - 按会话ID存储
notificationMutedMap: Map<string, boolean>;
} }
// ==================== Actions 接口 ==================== // ==================== Actions 接口 ====================
@@ -72,6 +75,7 @@ export interface MessageActions {
getTypingUsers: (groupId: string) => string[]; getTypingUsers: (groupId: string) => string[];
isMuted: (groupId: string) => boolean; isMuted: (groupId: string) => boolean;
getActiveConversation: () => string | null; getActiveConversation: () => string | null;
isNotificationMuted: (conversationId: string) => boolean;
// 设置状态 // 设置状态
setConversations: (conversations: Map<string, ConversationResponse>) => void; setConversations: (conversations: Map<string, ConversationResponse>) => void;
@@ -88,6 +92,7 @@ export interface MessageActions {
setLoadingMessages: (conversationId: string, loading: boolean) => void; setLoadingMessages: (conversationId: string, loading: boolean) => void;
setTypingUsers: (groupId: string, users: string[]) => void; setTypingUsers: (groupId: string, users: string[]) => void;
setMutedStatus: (groupId: string, isMuted: boolean) => void; setMutedStatus: (groupId: string, isMuted: boolean) => void;
setNotificationMuted: (conversationId: string, notificationMuted: boolean) => void;
setInitialized: (initialized: boolean) => void; setInitialized: (initialized: boolean) => void;
// 重置 // 重置
@@ -110,6 +115,7 @@ const initialState: MessageState = {
isInitialized: false, isInitialized: false,
typingUsersMap: new Map(), typingUsersMap: new Map(),
mutedStatusMap: new Map(), mutedStatusMap: new Map(),
notificationMutedMap: new Map(),
}; };
// ==================== 工具函数 ==================== // ==================== 工具函数 ====================
@@ -177,6 +183,7 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
isInitialized: state.isInitialized, isInitialized: state.isInitialized,
typingUsersMap: state.typingUsersMap, typingUsersMap: state.typingUsersMap,
mutedStatusMap: state.mutedStatusMap, mutedStatusMap: state.mutedStatusMap,
notificationMutedMap: state.notificationMutedMap,
}; };
}, },
@@ -227,11 +234,25 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
return get().currentConversationId; return get().currentConversationId;
}, },
isNotificationMuted: (conversationId: string) => {
return get().notificationMutedMap.get(conversationId) || false;
},
// ==================== 设置状态 ==================== // ==================== 设置状态 ====================
setConversations: (conversations: Map<string, ConversationResponse>) => { setConversations: (conversations: Map<string, ConversationResponse>) => {
const conversationList = sortConversationList(conversations); const conversationList = sortConversationList(conversations);
set({ conversations, conversationList }); const notificationMutedMap = new Map<string, boolean>();
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) => { updateConversation: (conversation: ConversationResponse) => {
@@ -240,7 +261,13 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
const newConversations = new Map(state.conversations); const newConversations = new Map(state.conversations);
newConversations.set(id, { ...conversation, id }); newConversations.set(id, { ...conversation, id });
const conversationList = sortConversationList(newConversations); 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<MessageStore>((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) => { setInitialized: (initialized: boolean) => {
set({ isInitialized: initialized }); set({ isInitialized: initialized });
}, },
@@ -388,6 +423,7 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
loadingMessagesSet: new Set(), loadingMessagesSet: new Set(),
typingUsersMap: new Map(), typingUsersMap: new Map(),
mutedStatusMap: new Map(), mutedStatusMap: new Map(),
notificationMutedMap: new Map(),
}); });
AsyncStorage.removeItem(LAST_SYSTEM_MESSAGE_AT_KEY).catch(() => {}); AsyncStorage.removeItem(LAST_SYSTEM_MESSAGE_AT_KEY).catch(() => {});
}, },

View File

@@ -266,6 +266,7 @@ export interface ConversationResponse {
id: string; // 雪花算法ID (使用string避免JavaScript精度丢失) id: string; // 雪花算法ID (使用string避免JavaScript精度丢失)
type: ConversationType; type: ConversationType;
is_pinned?: boolean; is_pinned?: boolean;
notification_muted?: boolean;
last_seq: number; last_seq: number;
last_message?: MessageResponse; last_message?: MessageResponse;
last_message_at: string; last_message_at: string;
@@ -283,7 +284,8 @@ export interface ConversationResponse {
export interface ConversationParticipantResponse { export interface ConversationParticipantResponse {
user_id: string; user_id: string;
last_read_seq: number; last_read_seq: number;
muted: boolean; notification_muted: boolean;
is_pinned: boolean;
} }
// 会话详情响应 // 会话详情响应
@@ -291,6 +293,7 @@ export interface ConversationDetailResponse {
id: string; id: string;
type: ConversationType; type: ConversationType;
is_pinned?: boolean; is_pinned?: boolean;
notification_muted?: boolean;
last_seq: number; last_seq: number;
last_message?: MessageResponse; last_message?: MessageResponse;
last_message_at: string; last_message_at: string;