refactor(database): migrate to new modular database layer and unify data access
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m44s
Frontend CI / ota-android (push) Successful in 12m51s
Frontend CI / build-android-apk (push) Successful in 1h1m26s

- Remove legacy database.ts, LocalDataSource.ts, and MessageRepository.ts
- Create new src/database/ module with messageRepository, userCacheRepository, conversationRepository, and groupCacheRepository
- Update all consumers to import from @/database instead of services/database
- Add web platform blur handling for modal components to fix focus issues
- Flatten SystemMessageItem and NotificationsScreen styles for consistent design
- Add draggable slider in ChatSettingsScreen and dynamic font size support
- Introduce 9 new chat color themes
- Add profile screens for about, terms, and privacy policy with navigation routes
- Add policy links to login and registration screens
- Fix post share URL format from /posts/ to /post/
This commit is contained in:
lafay
2026-04-04 08:01:45 +08:00
parent 189b977fac
commit 82c2970a85
76 changed files with 3382 additions and 2000 deletions

View File

@@ -4,7 +4,7 @@
* 支持响应式布局
*/
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import {
View,
StyleSheet,
@@ -14,6 +14,7 @@ import {
TextInput,
FlatList,
Image,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
@@ -45,6 +46,13 @@ const CreateGroupScreen: React.FC = () => {
// 邀请成员模态框状态
const [inviteModalVisible, setInviteModalVisible] = useState(false);
useEffect(() => {
if (inviteModalVisible && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [inviteModalVisible]);
// 移除已选成员
const removeSelectedMember = (userId: string) => {
const newSelectedIds = new Set(selectedMemberIds);

View File

@@ -118,6 +118,13 @@ const GroupInfoScreen: React.FC = () => {
const [inviteModalVisible, setInviteModalVisible] = useState(false);
const [inviting, setInviting] = useState(false);
useEffect(() => {
if ((editModalVisible || announcementModalVisible || transferModalVisible || joinTypeModalVisible || inviteModalVisible) && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [editModalVisible, announcementModalVisible, transferModalVisible, joinTypeModalVisible, inviteModalVisible]);
// 计算当前用户的角色
const isOwner = currentMember?.role === 'owner';
const isAdmin = currentMember?.role === 'admin' || isOwner;

View File

@@ -17,6 +17,7 @@ import {
Modal,
TextInput,
Dimensions,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
@@ -131,6 +132,13 @@ const GroupMembersScreen: React.FC = () => {
const [nicknameModalVisible, setNicknameModalVisible] = useState(false);
const [newNickname, setNewNickname] = useState('');
useEffect(() => {
if ((actionModalVisible || nicknameModalVisible) && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [actionModalVisible, nicknameModalVisible]);
// 计算当前用户的角色
const isOwner = currentMember?.role === 'owner';
const isAdmin = currentMember?.role === 'admin' || isOwner;

View File

@@ -136,6 +136,13 @@ export const MessageListScreen: React.FC = () => {
const [isSearching, setIsSearching] = useState(false);
const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat');
const [actionMenuVisible, setActionMenuVisible] = useState(false);
useEffect(() => {
if (actionMenuVisible && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [actionMenuVisible]);
const [scannerVisible, setScannerVisible] = useState(false);
// 系统通知显示状态 - 用于在移动端显示通知页面

View File

@@ -1,8 +1,14 @@
/**
* 通知页 NotificationsScreen
* 通知页 NotificationsScreen(扁平化风格)
* 胡萝卜BBS - 系统消息列表
* 【游标分页】使用 messageService.getSystemMessagesCursor
* 支持响应式布局
*
* 设计风格:
* - 纯白背景,扁平化设计
* - 简洁的标题区域
* - 分段式筛选标签
* - 与登录、注册、设置页面风格一致
*/
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
@@ -15,17 +21,19 @@ import {
ActivityIndicator,
Dimensions,
BackHandler,
Animated,
StatusBar,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useIsFocused } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { spacing, borderRadius, useAppColors, type AppColors } from '../../theme';
import { SystemMessageResponse } from '../../types/dto';
import { messageService } from '../../services/messageService';
import { commentService } from '../../services/commentService';
import { SystemMessageItem } from '../../components/business';
import { Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common';
import { Text, ResponsiveContainer } from '../../components/common';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import * as hrefs from '../../navigation/hrefs';
import { useMessageManagerSystemUnreadCount } from '../../stores';
@@ -41,6 +49,13 @@ const MESSAGE_TYPES = [
{ key: 'announcement', title: '公告' },
];
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const LIKE_SYSTEM_TYPES = new Set(['like_post', 'like_comment', 'like_reply', 'favorite_post']);
const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected']);
@@ -51,6 +66,10 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
const router = useRouter();
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
// 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });
const [isHydrated, setIsHydrated] = useState(false);
@@ -177,6 +196,22 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
}
}, [isFocused]); // 只依赖 isFocused函数使用 ref 稳定引用
// 启动入场动画
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start();
}, []);
// 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
useEffect(() => {
if (!isFocused && onBack) {
@@ -343,7 +378,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
);
};
// 渲染头部(包含返回按钮)
// 渲染头部(包含返回按钮)- 扁平化风格
const renderHeader = () => {
// 大屏模式(>= lg 断点)隐藏返回按钮
const shouldShowBackButton = onBack && !isWideScreen;
@@ -351,7 +386,11 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
return (
<View style={[styles.header, isWideScreen ? styles.headerWide : null]}>
{shouldShowBackButton ? (
<AppBackButton style={styles.backButton} onPress={onBack} />
<TouchableOpacity style={styles.backButton} onPress={onBack} activeOpacity={0.8}>
<View style={styles.backIconButton}>
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.text.primary} />
</View>
</TouchableOpacity>
) : (
<View style={styles.backButtonPlaceholder} />
)}
@@ -370,14 +409,14 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
);
};
// 渲染空状态
// 渲染空状态 - 扁平化风格
const renderEmpty = () => (
<View style={[styles.emptyContainer, isWideScreen && styles.emptyContainerWeb]}>
<EmptyState
title="暂无通知"
description="关注一些用户来获取通知吧"
icon="bell-outline"
/>
<View style={styles.emptyIconContainer}>
<MaterialCommunityIcons name="bell-outline" size={64} color={colors.text.hint} />
</View>
<Text style={styles.emptyTitle}></Text>
<Text style={styles.emptyDescription}></Text>
</View>
);
@@ -385,8 +424,9 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
if (!isHydrated) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
</View>
</SafeAreaView>
);
@@ -394,164 +434,175 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={containerMaxWidth}>
{/* 头部 - 大屏模式下隐藏返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWideWeb]}>
{MESSAGE_TYPES.map(type => {
const count = type.key === 'all'
? displayMessages.length
: displayMessages.filter((m) => {
if (type.key === 'like_post') {
return LIKE_SYSTEM_TYPES.has(m.system_type);
}
if (type.key === 'group') {
return GROUP_SYSTEM_TYPES.has(m.system_type);
}
return m.system_type === type.key;
}).length;
const isActive = activeType === type.key;
return (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
isActive && styles.filterTagActive,
isWideScreen && styles.filterTagWide,
]}
onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
>
<Text
variant="body"
color={isActive ? colors.text.inverse : colors.text.secondary}
style={isActive ? styles.filterTagTextActive : undefined}
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<Animated.View
style={[
styles.content,
{
opacity: fadeAnim,
transform: [{ translateY: slideAnim }],
},
]}
>
{isWideScreen ? (
<ResponsiveContainer maxWidth={containerMaxWidth}>
{/* 头部 - 大屏模式下隐藏返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWideWeb]}>
{MESSAGE_TYPES.map(type => {
const count = type.key === 'all'
? displayMessages.length
: displayMessages.filter((m) => {
if (type.key === 'like_post') {
return LIKE_SYSTEM_TYPES.has(m.system_type);
}
if (type.key === 'group') {
return GROUP_SYSTEM_TYPES.has(m.system_type);
}
return m.system_type === type.key;
}).length;
const isActive = activeType === type.key;
return (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
isActive && styles.filterTagActive,
isWideScreen && styles.filterTagWide,
]}
onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
>
{type.title}
</Text>
{count > 0 && (
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
<Text
variant="caption"
color={isActive ? colors.text.inverse : colors.text.hint}
>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
<Text
variant="body"
color={isActive ? colors.text.inverse : colors.text.secondary}
style={isActive ? styles.filterTagTextActive : undefined}
>
{type.title}
</Text>
{count > 0 && (
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
<Text
variant="caption"
color={isActive ? colors.text.inverse : colors.text.hint}
>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
) : (
<FlatList
data={filteredMessages}
renderItem={renderMessage}
keyExtractor={item => item.id.toString()}
contentContainerStyle={[styles.listContent, isWideScreen && styles.listContentWide]}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
)}
</ResponsiveContainer>
) : (
<>
{/* 头部 - 小屏模式下显示返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
<View style={styles.filterContainer}>
{MESSAGE_TYPES.map(type => {
const count = type.key === 'all'
? displayMessages.length
: displayMessages.filter((m) => {
if (type.key === 'like_post') {
return LIKE_SYSTEM_TYPES.has(m.system_type);
}
if (type.key === 'group') {
return GROUP_SYSTEM_TYPES.has(m.system_type);
}
return m.system_type === type.key;
}).length;
const isActive = activeType === type.key;
return (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
isActive && styles.filterTagActive,
]}
onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
>
<Text
variant="body"
color={isActive ? colors.text.inverse : colors.text.secondary}
style={isActive ? styles.filterTagTextActive : undefined}
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
</View>
) : (
<FlatList
data={filteredMessages}
renderItem={renderMessage}
keyExtractor={item => item.id.toString()}
contentContainerStyle={[styles.listContent, isWideScreen && styles.listContentWide]}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[THEME_COLORS.primary]}
tintColor={THEME_COLORS.primary}
/>
}
/>
)}
</ResponsiveContainer>
) : (
<>
{/* 头部 - 小屏模式下显示返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
<View style={styles.filterContainer}>
{MESSAGE_TYPES.map(type => {
const count = type.key === 'all'
? displayMessages.length
: displayMessages.filter((m) => {
if (type.key === 'like_post') {
return LIKE_SYSTEM_TYPES.has(m.system_type);
}
if (type.key === 'group') {
return GROUP_SYSTEM_TYPES.has(m.system_type);
}
return m.system_type === type.key;
}).length;
const isActive = activeType === type.key;
return (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
isActive && styles.filterTagActive,
]}
onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
>
{type.title}
</Text>
{count > 0 && (
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
<Text
variant="caption"
color={isActive ? colors.text.inverse : colors.text.hint}
>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
<Text
variant="body"
color={isActive ? colors.text.inverse : colors.text.secondary}
style={isActive ? styles.filterTagTextActive : undefined}
>
{type.title}
</Text>
{count > 0 && (
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
<Text
variant="caption"
color={isActive ? colors.text.inverse : colors.text.hint}
>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
) : (
<FlatList
data={filteredMessages}
renderItem={renderMessage}
keyExtractor={item => item.id.toString()}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
)}
</>
)}
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
</View>
) : (
<FlatList
data={filteredMessages}
renderItem={renderMessage}
keyExtractor={item => item.id.toString()}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[THEME_COLORS.primary]}
tintColor={THEME_COLORS.primary}
/>
}
/>
)}
</>
)}
</Animated.View>
</SafeAreaView>
);
};
@@ -560,21 +611,23 @@ function createNotificationsStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
backgroundColor: '#fff',
},
// Header 样式 - 美化版
content: {
flex: 1,
},
// Header 样式 - 扁平化风格
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.background.paper,
...shadows.sm,
paddingHorizontal: 20,
paddingVertical: 16,
backgroundColor: '#fff',
},
headerWide: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.lg,
paddingHorizontal: 32,
paddingVertical: 20,
},
headerTitleContainer: {
flexDirection: 'row',
@@ -591,7 +644,7 @@ function createNotificationsStyles(colors: AppColors) {
fontSize: 20,
},
unreadBadge: {
backgroundColor: colors.primary.main,
backgroundColor: THEME_COLORS.primary,
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
@@ -601,7 +654,7 @@ function createNotificationsStyles(colors: AppColors) {
justifyContent: 'center',
},
unreadBadgeText: {
color: colors.text.inverse,
color: '#fff',
fontSize: 11,
fontWeight: '600',
},
@@ -609,50 +662,59 @@ function createNotificationsStyles(colors: AppColors) {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'flex-start',
alignItems: 'center',
},
backIconButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#F5F5F7',
justifyContent: 'center',
alignItems: 'center',
},
backButtonPlaceholder: {
width: 40,
},
// 分类筛选 - 美化版(使用分段式设计)
// 分类筛选 - 扁平化风格(分段式设计)
filterContainer: {
flexDirection: 'row',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.background.paper,
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: colors.divider || '#E5E5EA',
},
filterContainerWideWeb: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
paddingHorizontal: 32,
paddingVertical: 16,
justifyContent: 'center',
backgroundColor: colors.background.paper,
},
filterTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
marginRight: spacing.xs,
borderRadius: borderRadius.lg,
backgroundColor: colors.background.default,
paddingHorizontal: 14,
paddingVertical: 8,
marginRight: 8,
borderRadius: 14,
backgroundColor: '#F5F5F7',
borderWidth: 1,
borderColor: 'transparent',
},
filterTagWide: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
marginRight: spacing.sm,
paddingHorizontal: 18,
paddingVertical: 10,
marginRight: 10,
},
filterTagActive: {
backgroundColor: colors.primary.main,
borderColor: colors.primary.main,
backgroundColor: THEME_COLORS.primary,
borderColor: THEME_COLORS.primary,
},
filterTagTextActive: {
fontWeight: '600',
},
filterCount: {
marginLeft: spacing.xs,
backgroundColor: colors.primary.light + '40',
marginLeft: 6,
backgroundColor: THEME_COLORS.primaryLight + '40',
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 8,
@@ -664,37 +726,59 @@ function createNotificationsStyles(colors: AppColors) {
},
listContent: {
flexGrow: 1,
paddingTop: spacing.md,
paddingBottom: spacing.lg,
paddingTop: 8,
paddingBottom: 24,
},
listContentWide: {
paddingHorizontal: spacing.xl,
paddingHorizontal: 32,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: spacing.xl * 2,
paddingVertical: 80,
},
loadingMore: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: spacing.lg,
paddingVertical: 20,
},
loadingMoreText: {
marginLeft: spacing.sm,
marginLeft: 8,
},
// 空状态 - 扁平化风格
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingTop: spacing['3xl'],
paddingTop: 80,
paddingHorizontal: 40,
},
emptyContainerWeb: {
minHeight: 500,
justifyContent: 'center',
paddingTop: 100,
},
emptyIconContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: '#F5F5F7',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
emptyTitle: {
fontSize: 20,
fontWeight: '600',
color: colors.text.primary,
marginBottom: 8,
},
emptyDescription: {
fontSize: 15,
color: colors.text.secondary,
textAlign: 'center',
},
});
}

View File

@@ -21,10 +21,7 @@ import { useAuthStore } from '../../stores';
import { authService } from '../../services/authService';
import { messageService } from '../../services/messageService';
import { ApiError } from '../../services/api';
import {
clearConversationMessages,
getConversationCache,
} from '../../services/database';
import { messageRepository, conversationRepository } from '@/database';
import { messageManager } from '../../stores/messageManager';
import { userManager } from '../../stores/userManager';
import { Avatar, Text, Button, Loading, Divider } from '../../components/common';
@@ -81,7 +78,7 @@ const PrivateChatInfoScreen: React.FC = () => {
return;
}
const cached = await getConversationCache(conversationId);
const cached = await conversationRepository.getCache(conversationId);
if (cached) {
setIsPinned(Boolean((cached as any).is_pinned));
return;
@@ -137,7 +134,7 @@ const PrivateChatInfoScreen: React.FC = () => {
style: 'destructive',
onPress: async () => {
try {
await clearConversationMessages(conversationId);
await messageRepository.clearConversation(conversationId);
Alert.alert('成功', '聊天记录已清空');
} catch (error) {
Alert.alert('错误', '清空聊天记录失败');

View File

@@ -5,7 +5,7 @@
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem, ScrollView } from 'react-native';
import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem, ScrollView, Platform } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
@@ -18,6 +18,12 @@ import { useAppColors, spacing } from '../../../../theme';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const blurActiveElementOnWeb = () => {
if (Platform.OS !== 'web') return;
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
};
// 表情尺寸配置 - 固定宽度 40px使用 flex 布局
const EMOJI_SIZES = {
mobile: { size: 24, itemWidth: 40, itemHeight: 40 },
@@ -176,6 +182,7 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
// 打开管理界面
const handleOpenManage = () => {
blurActiveElementOnWeb();
setShowManageModal(true);
setManageMode(false);
setSelectedStickers(new Set());

View File

@@ -33,6 +33,7 @@ import {
} from '../../../../types/dto';
import { spacing, useAppColors, type AppColors } from '../../../../theme';
import { SenderInfo } from './types';
import { useChatSettingsStore } from '../../../../stores/chatSettingsStore';
const IMAGE_MAX_WIDTH = 200;
const IMAGE_MAX_HEIGHT = 260;
@@ -617,7 +618,8 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
getSenderInfo,
}) => {
const themeColors = useAppColors();
const styles = useMemo(() => createSegmentStyles(themeColors), [themeColors]);
const fontSize = useChatSettingsStore((s) => s.fontSize);
const styles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]);
if (!replyMessage) {
// 如果没有引用消息详情只显示引用ID
@@ -729,7 +731,8 @@ export const MessageSegmentsRenderer: React.FC<{
getSenderInfo,
}) => {
const themeColors = useAppColors();
const segStyles = useMemo(() => createSegmentStyles(themeColors), [themeColors]);
const fontSize = useChatSettingsStore((s) => s.fontSize);
const segStyles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]);
const replySegment = segments.find(s => s.type === 'reply');
const otherSegments = segments.filter(s => s.type !== 'reply');
@@ -815,7 +818,7 @@ const formatDuration = (seconds: number): string => {
return mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `0:${secs.toString().padStart(2, '0')}`;
};
function createSegmentStyles(colors: AppColors) {
function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
return StyleSheet.create({
// 容器
segmentsContainer: {
@@ -847,10 +850,10 @@ function createSegmentStyles(colors: AppColors) {
marginTop: 2,
},
// 文本 - 适配暗色模式
// 文本 - 适配暗色模式,支持动态字号
textContent: {
fontSize: 17.5,
lineHeight: 25,
fontSize,
lineHeight: Math.round(fontSize * 1.43),
letterSpacing: 0.1,
fontWeight: '400',
},
@@ -861,10 +864,10 @@ function createSegmentStyles(colors: AppColors) {
color: colors.chat.textPrimary,
},
// @提及 - 微信/QQ 风格:更精致的高亮
// @提及 - 微信/QQ 风格:更精致的高亮,支持动态字号
atText: {
fontSize: 16,
lineHeight: 22,
fontSize,
lineHeight: Math.round(fontSize * 1.4),
fontWeight: '500',
},
atTextMe: {
@@ -904,7 +907,7 @@ function createSegmentStyles(colors: AppColors) {
height: 28,
},
faceText: {
fontSize: 16,
fontSize,
color: '#666',
},
@@ -930,7 +933,7 @@ function createSegmentStyles(colors: AppColors) {
},
voiceDuration: {
marginLeft: spacing.sm,
fontSize: 15,
fontSize: fontSize - 1,
fontWeight: '500',
},
@@ -1113,14 +1116,14 @@ function createSegmentStyles(colors: AppColors) {
minWidth: 0,
},
replySender: {
fontSize: 13,
fontSize: fontSize - 3,
fontWeight: '600',
color: colors.chat.link,
marginRight: 6,
flexShrink: 0,
},
replyText: {
fontSize: 13,
fontSize: fontSize - 3,
color: colors.chat.textTertiary,
flex: 1,
flexShrink: 1,

View File

@@ -42,10 +42,7 @@ import {
PendingChatAttachment,
} from './types';
import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE, MAX_PENDING_CHAT_IMAGES } from './constants';
import {
deleteMessage as deleteMessageFromDb,
clearConversationMessages,
} from '../../../../services/database';
import { messageRepository } from '@/database';
export const useChatScreen = () => {
const getSendErrorMessage = useCallback((error: unknown, fallback: string) => {
@@ -1209,7 +1206,7 @@ export const useChatScreen = () => {
const updatedMessages = messages.filter(m => m.id !== messageId);
// 注意:这里我们依赖 MessageManager 的数据,所以需要通过其他方式刷新
// 暂时只删除本地数据库
await deleteMessageFromDb(messageId);
await messageRepository.delete(messageId);
} catch (error) {
console.error('删除消息失败:', error);
Alert.alert('删除失败', '无法删除消息');
@@ -1224,7 +1221,7 @@ export const useChatScreen = () => {
setLastSeq(0);
setFirstSeq(0);
setHasMoreHistory(true);
await clearConversationMessages(conversationId);
await messageRepository.clearConversation(conversationId);
// 刷新消息列表
await refreshMessages();
} catch (error) {

View File

@@ -14,7 +14,7 @@ import {
MessageSegment,
} from '../../../types/dto';
import { authService } from '../../../services';
import { getUserCache } from '../../../services/database';
import { userCacheRepository } from '@/database';
import { Avatar, Text } from '../../../components/common';
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
@@ -62,7 +62,7 @@ const AsyncMessagePreview: React.FC<{
try {
const asyncText = await extractTextFromSegmentsAsync(
segments,
getUserCache,
userCacheRepository.get.bind(userCacheRepository),
authService.getUserById.bind(authService)
);
if (isMountedRef.current && asyncText !== initialText) {

View File

@@ -57,6 +57,10 @@ const MutualFollowSelectorModal: React.FC<MutualFollowSelectorModalProps> = ({
useEffect(() => {
if (!visible) return;
if (Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
if (!currentUserId) return;
const nextSelectedIds = new Set(stableInitialSelectedIds);