refactor(database): migrate to new modular database layer and unify data access
- 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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 系统通知显示状态 - 用于在移动端显示通知页面
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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('错误', '清空聊天记录失败');
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user