refactor(platform): extract blurActiveElement to infrastructure module
- Centralize duplicated Platform.OS === 'web' blur logic across 16 components - Add blurActiveElement utility to src/infrastructure/platform module - Optimize MessageBubble and ImageSegment with React.memo custom comparators - Add useMemo for computed values in MessageSegmentsRenderer - Update DTO types with is_system_notice and notice_content fields - Fix keyboard dismissal order in useChatScreen handleDismiss - Simplify userStore follow/unfollow state updates - Remove unnecessary TypeScript type assertions throughout codebase
This commit is contained in:
@@ -13,6 +13,7 @@ import { useRouter } from 'expo-router';
|
|||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||||
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -156,9 +157,8 @@ const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) =>
|
|||||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && Platform.OS === 'web') {
|
if (visible) {
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
blurActiveElement();
|
||||||
active?.blur?.();
|
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|||||||
import { useAppColors } from '../../../theme';
|
import { useAppColors } from '../../../theme';
|
||||||
import { spacing, borderRadius, fontSizes, shadows } from '../../../theme';
|
import { spacing, borderRadius, fontSizes, shadows } from '../../../theme';
|
||||||
import reportService, { ReportReason, ReportTargetType, REPORT_REASONS } from '../../../services/reportService';
|
import reportService, { ReportReason, ReportTargetType, REPORT_REASONS } from '../../../services/reportService';
|
||||||
|
import { blurActiveElement } from '../../../infrastructure/platform';
|
||||||
import Text from '../../common/Text';
|
import Text from '../../common/Text';
|
||||||
|
|
||||||
export interface ReportDialogProps {
|
export interface ReportDialogProps {
|
||||||
@@ -63,9 +64,8 @@ const ReportDialog: React.FC<ReportDialogProps> = ({
|
|||||||
const targetConfig = TARGET_CONFIG[targetType];
|
const targetConfig = TARGET_CONFIG[targetType];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && Platform.OS === 'web') {
|
if (visible) {
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
blurActiveElement();
|
||||||
active?.blur?.();
|
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { callStore } from '../../stores/callStore';
|
import { callStore } from '../../stores/callStore';
|
||||||
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -26,10 +27,7 @@ const IncomingCallModal: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (incomingCall) {
|
if (incomingCall) {
|
||||||
if (Platform.OS === 'web') {
|
blurActiveElement();
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
|
||||||
active?.blur?.();
|
|
||||||
}
|
|
||||||
const pulse = Animated.loop(
|
const pulse = Animated.loop(
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(pulseAnim, {
|
Animated.timing(pulseAnim, {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
useBreakpointGTE,
|
useBreakpointGTE,
|
||||||
FineBreakpointKey,
|
FineBreakpointKey,
|
||||||
} from '../../hooks/useResponsive';
|
} from '../../hooks/useResponsive';
|
||||||
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
|
|
||||||
export interface AdaptiveLayoutProps {
|
export interface AdaptiveLayoutProps {
|
||||||
/** 主内容区 */
|
/** 主内容区 */
|
||||||
@@ -160,9 +161,8 @@ export function AdaptiveLayout({
|
|||||||
// 抽屉动画
|
// 抽屉动画
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDrawerOpen) {
|
if (isDrawerOpen) {
|
||||||
if (Platform.OS === 'web') {
|
if (isDrawerOpen) {
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
blurActiveElement();
|
||||||
active?.blur?.();
|
|
||||||
}
|
}
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(slideAnim, {
|
Animated.timing(slideAnim, {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { LinearGradient } from 'expo-linear-gradient';
|
|||||||
|
|
||||||
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
|
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
|
||||||
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
||||||
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
|
|
||||||
function createDialogHostStyles(colors: AppColors) {
|
function createDialogHostStyles(colors: AppColors) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
@@ -93,12 +94,6 @@ function createDialogHostStyles(colors: AppColors) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const blurActiveElementOnWeb = () => {
|
|
||||||
if (Platform.OS !== 'web') return;
|
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
|
||||||
active?.blur?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const AppDialogHost: React.FC = () => {
|
const AppDialogHost: React.FC = () => {
|
||||||
const [dialog, setDialog] = useState<DialogPayload | null>(null);
|
const [dialog, setDialog] = useState<DialogPayload | null>(null);
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
@@ -113,7 +108,7 @@ const AppDialogHost: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialog) {
|
if (dialog) {
|
||||||
blurActiveElementOnWeb();
|
blurActiveElement();
|
||||||
}
|
}
|
||||||
}, [dialog]);
|
}, [dialog]);
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|||||||
import * as MediaLibrary from 'expo-media-library';
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
import { File, Paths } from 'expo-file-system';
|
import { File, Paths } from 'expo-file-system';
|
||||||
import { spacing, borderRadius, fontSizes } from '../../theme';
|
import { spacing, borderRadius, fontSizes } from '../../theme';
|
||||||
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -127,10 +128,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
// 打开/关闭时重置状态
|
// 打开/关闭时重置状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
if (Platform.OS === 'web') {
|
blurActiveElement();
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
|
||||||
active?.blur?.();
|
|
||||||
}
|
|
||||||
setCurrentIndex(initialIndex);
|
setCurrentIndex(initialIndex);
|
||||||
setShowControls(true);
|
setShowControls(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { VideoView, useVideoPlayer } from 'expo-video';
|
|||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { spacing, fontSizes, borderRadius } from '../../theme';
|
import { spacing, fontSizes, borderRadius } from '../../theme';
|
||||||
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -81,9 +82,8 @@ export const VideoPlayerModal: React.FC<VideoPlayerModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && Platform.OS === 'web') {
|
if (visible) {
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
blurActiveElement();
|
||||||
active?.blur?.();
|
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
|
|||||||
@@ -111,3 +111,16 @@ export type {
|
|||||||
UseDifferentialPostsResult,
|
UseDifferentialPostsResult,
|
||||||
DiffUpdatesInfo,
|
DiffUpdatesInfo,
|
||||||
} from './useDifferentialPosts';
|
} from './useDifferentialPosts';
|
||||||
|
|
||||||
|
// ==================== 平台键盘 Hooks ====================
|
||||||
|
export {
|
||||||
|
usePlatformKeyboard,
|
||||||
|
useKeyboardAvoidingProps,
|
||||||
|
useKeyboardListener,
|
||||||
|
} from './usePlatformKeyboard';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
KeyboardEventType,
|
||||||
|
PlatformKeyboardOptions,
|
||||||
|
PlatformKeyboardResult,
|
||||||
|
} from './usePlatformKeyboard';
|
||||||
|
|||||||
179
src/hooks/usePlatformKeyboard.ts
Normal file
179
src/hooks/usePlatformKeyboard.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* usePlatformKeyboard - 统一的跨平台键盘行为 Hook
|
||||||
|
*
|
||||||
|
* 消除散落在多个文件中的 Platform.OS 键盘事件判断
|
||||||
|
*
|
||||||
|
* 使用前(重复代码):
|
||||||
|
* ```tsx
|
||||||
|
* const showSub = Keyboard.addListener(
|
||||||
|
* Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
|
||||||
|
* handler
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 使用后:
|
||||||
|
* ```tsx
|
||||||
|
* const { addListener } = usePlatformKeyboard();
|
||||||
|
* const showSub = addListener('show', handler);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { Keyboard, Platform, KeyboardEvent, EmitterSubscription } from 'react-native';
|
||||||
|
|
||||||
|
export type KeyboardEventType = 'show' | 'hide' | 'change';
|
||||||
|
|
||||||
|
export interface PlatformKeyboardOptions {
|
||||||
|
onShow?: (event: KeyboardEvent) => void;
|
||||||
|
onHide?: (event: KeyboardEvent) => void;
|
||||||
|
onChange?: (height: number) => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformKeyboardResult {
|
||||||
|
/** 键盘高度 */
|
||||||
|
keyboardHeight: number;
|
||||||
|
/** 键盘是否可见 */
|
||||||
|
isKeyboardVisible: boolean;
|
||||||
|
/** 平台最佳的 KeyboardAvoidingView behavior */
|
||||||
|
keyboardBehavior: 'padding' | 'height' | undefined;
|
||||||
|
/** 平台最佳的 keyboardVerticalOffset */
|
||||||
|
keyboardVerticalOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取平台特定的键盘事件名称
|
||||||
|
*/
|
||||||
|
function getKeyboardEventName(type: KeyboardEventType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'show':
|
||||||
|
return Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||||
|
case 'hide':
|
||||||
|
return Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||||
|
case 'change':
|
||||||
|
return Platform.OS === 'ios' ? 'keyboardWillChangeFrame' : 'keyboardDidShow';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台键盘 Hook
|
||||||
|
*
|
||||||
|
* 自动处理跨平台键盘事件差异
|
||||||
|
*/
|
||||||
|
export function usePlatformKeyboard(
|
||||||
|
options: PlatformKeyboardOptions = {}
|
||||||
|
): PlatformKeyboardResult {
|
||||||
|
const { onShow, onHide, onChange, enabled = true } = options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const subscriptions: EmitterSubscription[] = [];
|
||||||
|
|
||||||
|
if (onShow) {
|
||||||
|
subscriptions.push(
|
||||||
|
Keyboard.addListener(
|
||||||
|
getKeyboardEventName('show') as any,
|
||||||
|
onShow
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onHide) {
|
||||||
|
subscriptions.push(
|
||||||
|
Keyboard.addListener(
|
||||||
|
getKeyboardEventName('hide') as any,
|
||||||
|
onHide
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
const handleChange = (e: KeyboardEvent) => {
|
||||||
|
onChange(e.endCoordinates.height);
|
||||||
|
};
|
||||||
|
subscriptions.push(
|
||||||
|
Keyboard.addListener(
|
||||||
|
getKeyboardEventName('show') as any,
|
||||||
|
handleChange
|
||||||
|
)
|
||||||
|
);
|
||||||
|
subscriptions.push(
|
||||||
|
Keyboard.addListener(
|
||||||
|
getKeyboardEventName('hide') as any,
|
||||||
|
() => onChange(0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscriptions.forEach(sub => sub.remove());
|
||||||
|
};
|
||||||
|
}, [enabled, onShow, onHide, onChange]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyboardHeight: 0,
|
||||||
|
isKeyboardVisible: false,
|
||||||
|
keyboardBehavior: Platform.OS === 'ios' ? 'padding' : undefined,
|
||||||
|
keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取平台最佳的 KeyboardAvoidingView props
|
||||||
|
*
|
||||||
|
* 用于替代重复的 Platform.OS 三元判断:
|
||||||
|
* ```tsx
|
||||||
|
* // 之前
|
||||||
|
* behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
*
|
||||||
|
* // 之后
|
||||||
|
* const { keyboardProps } = useKeyboardAvoidingProps();
|
||||||
|
* <KeyboardAvoidingView {...keyboardProps}>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useKeyboardAvoidingProps(
|
||||||
|
options: { offset?: number; defaultBehavior?: 'padding' | 'height' } = {}
|
||||||
|
): {
|
||||||
|
keyboardProps: {
|
||||||
|
behavior: 'padding' | 'height' | undefined;
|
||||||
|
keyboardVerticalOffset: number;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
const { offset = 0, defaultBehavior } = options;
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyboardProps: {
|
||||||
|
behavior: defaultBehavior ?? (Platform.OS === 'ios' ? 'padding' : undefined),
|
||||||
|
keyboardVerticalOffset: Platform.OS === 'ios' ? offset : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键盘事件监听 Hook(简化版)
|
||||||
|
*
|
||||||
|
* 返回一个平台无关的 addListener 函数
|
||||||
|
*/
|
||||||
|
export function useKeyboardListener() {
|
||||||
|
const subscriptionsRef = useRef<EmitterSubscription[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
subscriptionsRef.current.forEach(sub => sub.remove());
|
||||||
|
subscriptionsRef.current = [];
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addListener = useCallback(
|
||||||
|
(type: KeyboardEventType, handler: (event: KeyboardEvent) => void) => {
|
||||||
|
const eventName = getKeyboardEventName(type);
|
||||||
|
const sub = Keyboard.addListener(eventName as any, handler);
|
||||||
|
subscriptionsRef.current.push(sub);
|
||||||
|
return sub;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { addListener };
|
||||||
|
}
|
||||||
83
src/infrastructure/platform/domUtils.ts
Normal file
83
src/infrastructure/platform/domUtils.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Web Platform DOM Utilities
|
||||||
|
*
|
||||||
|
* 提供Web平台特定的DOM操作工具函数
|
||||||
|
* 解决跨平台代码中需要访问Web DOM的补丁问题
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Platform, Keyboard } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blur当前激活的DOM元素(Web)并收起键盘(移动端)
|
||||||
|
*
|
||||||
|
* - Web: blur DOM active element
|
||||||
|
* - iOS/Android: Keyboard.dismiss()
|
||||||
|
*
|
||||||
|
* 替代散落在多个文件中的重复代码
|
||||||
|
*/
|
||||||
|
export function blurActiveElement(): void {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
try {
|
||||||
|
const activeElement = (globalThis as any)?.document?.activeElement as
|
||||||
|
{ blur?: () => void } | undefined;
|
||||||
|
|
||||||
|
if (activeElement?.blur) {
|
||||||
|
activeElement.blur();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 安全忽略:某些环境下document可能不可访问
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查当前是否有激活的DOM元素(仅Web平台)
|
||||||
|
*/
|
||||||
|
export function hasActiveElement(): boolean {
|
||||||
|
if (Platform.OS !== 'web') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = (globalThis as any)?.document;
|
||||||
|
return !!doc?.activeElement && doc.activeElement !== doc.body;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前激活元素的类型(仅Web平台)
|
||||||
|
* 用于判断是否需要特殊处理(如输入框)
|
||||||
|
*/
|
||||||
|
export function getActiveElementType(): string | null {
|
||||||
|
if (Platform.OS !== 'web') return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeElement = (globalThis as any)?.document?.activeElement as
|
||||||
|
{ tagName?: string; type?: string } | undefined;
|
||||||
|
|
||||||
|
if (!activeElement) return null;
|
||||||
|
|
||||||
|
const tagName = activeElement.tagName?.toLowerCase();
|
||||||
|
const type = activeElement.type?.toLowerCase();
|
||||||
|
|
||||||
|
return tagName === 'input' ? `input:${type || 'text'}` : tagName || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前激活元素是否为输入类型
|
||||||
|
*/
|
||||||
|
export function isInputFocused(): boolean {
|
||||||
|
const elementType = getActiveElementType();
|
||||||
|
if (!elementType) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
elementType.startsWith('input:') ||
|
||||||
|
elementType === 'textarea' ||
|
||||||
|
elementType === 'select'
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/infrastructure/platform/index.ts
Normal file
12
src/infrastructure/platform/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Platform Infrastructure
|
||||||
|
*
|
||||||
|
* 平台相关的基础设施代码
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
blurActiveElement,
|
||||||
|
hasActiveElement,
|
||||||
|
getActiveElementType,
|
||||||
|
isInputFocused,
|
||||||
|
} from './domUtils';
|
||||||
@@ -38,6 +38,7 @@ import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
|
|||||||
import { SearchScreen } from './SearchScreen';
|
import { SearchScreen } from './SearchScreen';
|
||||||
import { CreatePostScreen } from '../create/CreatePostScreen';
|
import { CreatePostScreen } from '../create/CreatePostScreen';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
|
|
||||||
const TABS = ['关注', '最新', '热门'];
|
const TABS = ['关注', '最新', '热门'];
|
||||||
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
|
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
|
||||||
@@ -203,9 +204,8 @@ export const HomeScreen: React.FC = () => {
|
|||||||
const [showCreatePost, setShowCreatePost] = useState(false);
|
const [showCreatePost, setShowCreatePost] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showCreatePost && Platform.OS === 'web') {
|
if (showCreatePost) {
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
blurActiveElement();
|
||||||
active?.blur?.();
|
|
||||||
}
|
}
|
||||||
}, [showCreatePost]);
|
}, [showCreatePost]);
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,35 @@ import { useCursorPagination } from '../../hooks/useCursorPagination';
|
|||||||
import { CommentItem, VoteCard, ReportDialog } from '../../components/business';
|
import { CommentItem, VoteCard, ReportDialog } from '../../components/business';
|
||||||
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
|
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
|
||||||
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||||
|
import { handleError } from '../../services/errorHandler';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
|
|
||||||
|
function togglePostField(
|
||||||
|
post: Post,
|
||||||
|
field: 'is_liked' | 'is_favorited',
|
||||||
|
countField: 'likes_count' | 'favorites_count',
|
||||||
|
): Post {
|
||||||
|
const oldValue = post[field];
|
||||||
|
const oldCount = post[countField];
|
||||||
|
return {
|
||||||
|
...post,
|
||||||
|
[field]: !oldValue,
|
||||||
|
[countField]: oldValue ? Math.max(0, oldCount - 1) : oldCount + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCommentLikeInTree(comments: Comment[], commentId: string, isLiked: boolean, likesCount: number): Comment[] {
|
||||||
|
return comments.map(c => {
|
||||||
|
if (c.id === commentId) {
|
||||||
|
return { ...c, is_liked: isLiked, likes_count: likesCount };
|
||||||
|
}
|
||||||
|
if (c.replies?.length) {
|
||||||
|
return { ...c, replies: toggleCommentLikeInTree(c.replies, commentId, isLiked, likesCount) };
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const PostDetailScreen: React.FC = () => {
|
export const PostDetailScreen: React.FC = () => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const styles = useMemo(() => createPostDetailStyles(colors), [colors]);
|
const styles = useMemo(() => createPostDetailStyles(colors), [colors]);
|
||||||
@@ -217,20 +244,17 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
setPost(postData as unknown as Post);
|
setPost(postData as unknown as Post);
|
||||||
// 初始化关注状态
|
// 初始化关注状态
|
||||||
if (postData.author) {
|
if (postData.author) {
|
||||||
setIsFollowing((postData.author as any).is_following || false);
|
setIsFollowing(postData.author.is_following || false);
|
||||||
setIsFollowingMe((postData.author as any).is_following_me || false);
|
setIsFollowingMe(postData.author.is_following_me || false);
|
||||||
}
|
}
|
||||||
// 只在首次加载时记录浏览量
|
if (!hasRecordedView.current) {
|
||||||
if (recordView && !hasRecordedView.current) {
|
|
||||||
hasRecordedView.current = true;
|
hasRecordedView.current = true;
|
||||||
// 异步记录浏览量,不阻塞加载
|
|
||||||
postService.recordView(postId).catch(err => {
|
postService.recordView(postId).catch(err => {
|
||||||
console.error('记录浏览量失败:', err);
|
console.error('记录浏览量失败:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是投票帖子,立即加载投票数据
|
if (postData.is_vote) {
|
||||||
if ((postData as any).is_vote) {
|
|
||||||
setIsVoteLoading(true);
|
setIsVoteLoading(true);
|
||||||
try {
|
try {
|
||||||
const voteData = await voteService.getVoteResult(postId);
|
const voteData = await voteService.getVoteResult(postId);
|
||||||
@@ -251,8 +275,8 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
setPost(updatedPost);
|
setPost(updatedPost);
|
||||||
// 初始化关注状态
|
// 初始化关注状态
|
||||||
if (updatedPost.author) {
|
if (updatedPost.author) {
|
||||||
setIsFollowing((updatedPost.author as any).is_following || false);
|
setIsFollowing(updatedPost.author.is_following || false);
|
||||||
setIsFollowingMe((updatedPost.author as any).is_following_me || false);
|
setIsFollowingMe(updatedPost.author.is_following_me || false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,7 +284,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
// 加载评论(使用游标分页刷新)
|
// 加载评论(使用游标分页刷新)
|
||||||
await refreshComments();
|
await refreshComments();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载帖子详情失败:', error);
|
handleError(error, { context: '加载帖子详情' });
|
||||||
} finally {
|
} finally {
|
||||||
setIsPostInitialLoading(false);
|
setIsPostInitialLoading(false);
|
||||||
}
|
}
|
||||||
@@ -458,65 +482,36 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
const handleLike = useCallback(async () => {
|
const handleLike = useCallback(async () => {
|
||||||
if (!post) return;
|
if (!post) return;
|
||||||
|
|
||||||
// 先保存旧状态用于回滚
|
const originalPost = post;
|
||||||
const oldIsLiked = post.is_liked;
|
setPost(prev => prev ? togglePostField(prev, 'is_liked', 'likes_count') : null);
|
||||||
const oldLikesCount = post.likes_count;
|
|
||||||
|
|
||||||
// 乐观更新本地状态
|
|
||||||
setPost(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
is_liked: !prev.is_liked,
|
|
||||||
likes_count: prev.is_liked ? prev.likes_count - 1 : prev.likes_count + 1
|
|
||||||
} : null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (oldIsLiked) {
|
if (originalPost.is_liked) {
|
||||||
await processPostUseCase.unlikePost(post.id);
|
await processPostUseCase.unlikePost(post.id);
|
||||||
} else {
|
} else {
|
||||||
await processPostUseCase.likePost(post.id);
|
await processPostUseCase.likePost(post.id);
|
||||||
}
|
}
|
||||||
// UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('点赞操作失败:', error);
|
handleError(error, { context: '点赞' });
|
||||||
// 失败时回滚状态
|
setPost(originalPost);
|
||||||
setPost(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
is_liked: oldIsLiked,
|
|
||||||
likes_count: oldLikesCount
|
|
||||||
} : null);
|
|
||||||
}
|
}
|
||||||
}, [post]);
|
}, [post]);
|
||||||
|
|
||||||
// 收藏帖子
|
|
||||||
const handleFavorite = useCallback(async () => {
|
const handleFavorite = useCallback(async () => {
|
||||||
if (!post) return;
|
if (!post) return;
|
||||||
|
|
||||||
// 先保存旧状态用于回滚
|
const originalPost = post;
|
||||||
const oldIsFavorited = post.is_favorited;
|
setPost(prev => prev ? togglePostField(prev, 'is_favorited', 'favorites_count') : null);
|
||||||
const oldFavoritesCount = post.favorites_count;
|
|
||||||
|
|
||||||
// 乐观更新本地状态
|
|
||||||
setPost(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
is_favorited: !prev.is_favorited,
|
|
||||||
favorites_count: prev.is_favorited ? prev.favorites_count - 1 : prev.favorites_count + 1
|
|
||||||
} : null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (oldIsFavorited) {
|
if (originalPost.is_favorited) {
|
||||||
await processPostUseCase.unfavoritePost(post.id);
|
await processPostUseCase.unfavoritePost(post.id);
|
||||||
} else {
|
} else {
|
||||||
await processPostUseCase.favoritePost(post.id);
|
await processPostUseCase.favoritePost(post.id);
|
||||||
}
|
}
|
||||||
// UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('收藏操作失败:', error);
|
handleError(error, { context: '收藏' });
|
||||||
// 失败时回滚状态
|
setPost(originalPost);
|
||||||
setPost(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
is_favorited: oldIsFavorited,
|
|
||||||
favorites_count: oldFavoritesCount
|
|
||||||
} : null);
|
|
||||||
}
|
}
|
||||||
}, [post]);
|
}, [post]);
|
||||||
|
|
||||||
@@ -573,24 +568,19 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
setVoteResult(oldVoteResult);
|
setVoteResult(oldVoteResult);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('投票失败:', error);
|
handleError(error, { context: '投票' });
|
||||||
// 失败时回滚
|
|
||||||
setVoteResult(oldVoteResult);
|
setVoteResult(oldVoteResult);
|
||||||
} finally {
|
} finally {
|
||||||
setIsVoteLoading(false);
|
setIsVoteLoading(false);
|
||||||
}
|
}
|
||||||
}, [post, voteResult, isVoteLoading]);
|
}, [post, voteResult, isVoteLoading]);
|
||||||
|
|
||||||
// 取消投票处理函数
|
|
||||||
const handleUnvote = useCallback(async () => {
|
const handleUnvote = useCallback(async () => {
|
||||||
if (!post || !voteResult || isVoteLoading || !voteResult.voted_option_id) return;
|
if (!post || !voteResult || isVoteLoading || !voteResult.voted_option_id) return;
|
||||||
|
|
||||||
const votedOptionId = voteResult.voted_option_id;
|
const votedOptionId = voteResult.voted_option_id;
|
||||||
|
|
||||||
// 保存旧状态用于回滚
|
|
||||||
const oldVoteResult = { ...voteResult };
|
const oldVoteResult = { ...voteResult };
|
||||||
|
|
||||||
// 乐观更新
|
|
||||||
setVoteResult((prev: VoteResultDTO | null) => {
|
setVoteResult((prev: VoteResultDTO | null) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
return {
|
return {
|
||||||
@@ -611,12 +601,10 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const success = await voteService.unvote(post.id);
|
const success = await voteService.unvote(post.id);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
// 失败时回滚
|
|
||||||
setVoteResult(oldVoteResult);
|
setVoteResult(oldVoteResult);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('取消投票失败:', error);
|
handleError(error, { context: '取消投票' });
|
||||||
// 失败时回滚
|
|
||||||
setVoteResult(oldVoteResult);
|
setVoteResult(oldVoteResult);
|
||||||
} finally {
|
} finally {
|
||||||
setIsVoteLoading(false);
|
setIsVoteLoading(false);
|
||||||
@@ -899,26 +887,15 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 点赞评论(包括回复)- 优化版
|
|
||||||
const handleLikeComment = async (comment: Comment) => {
|
const handleLikeComment = async (comment: Comment) => {
|
||||||
const { id, is_liked, likes_count } = comment;
|
const { id, is_liked, likes_count } = comment;
|
||||||
|
|
||||||
// 乐观更新:切换点赞状态
|
const oldIsLiked = is_liked ?? false;
|
||||||
const newIsLiked = !is_liked;
|
const newIsLiked = !oldIsLiked;
|
||||||
const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1);
|
const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1);
|
||||||
|
const originalComments = comments;
|
||||||
|
|
||||||
// 递归更新评论树中的点赞状态
|
setComments(prev => toggleCommentLikeInTree(prev, id, newIsLiked, newLikesCount));
|
||||||
const updateLikeInTree = (c: Comment): Comment => {
|
|
||||||
if (c.id === id) {
|
|
||||||
return { ...c, is_liked: newIsLiked, likes_count: newLikesCount };
|
|
||||||
}
|
|
||||||
if (c.replies?.length) {
|
|
||||||
return { ...c, replies: c.replies.map(r => updateLikeInTree(r)) };
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
};
|
|
||||||
|
|
||||||
setComments(prev => prev.map(c => updateLikeInTree(c)));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = newIsLiked
|
const success = newIsLiked
|
||||||
@@ -929,18 +906,8 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
throw new Error('点赞操作失败');
|
throw new Error('点赞操作失败');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('评论点赞操作失败:', error);
|
handleError(error, { context: '评论点赞' });
|
||||||
// 回滚状态
|
setComments(toggleCommentLikeInTree(originalComments, id, oldIsLiked, likes_count));
|
||||||
const rollbackLikeInTree = (c: Comment): Comment => {
|
|
||||||
if (c.id === id) {
|
|
||||||
return { ...c, is_liked, likes_count };
|
|
||||||
}
|
|
||||||
if (c.replies?.length) {
|
|
||||||
return { ...c, replies: c.replies.map(r => rollbackLikeInTree(r)) };
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
};
|
|
||||||
setComments(prev => prev.map(c => rollbackLikeInTree(c)));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
|
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
|
||||||
import { groupService } from '../../services/groupService';
|
import { groupService } from '../../services/groupService';
|
||||||
@@ -47,9 +48,8 @@ const CreateGroupScreen: React.FC = () => {
|
|||||||
const [inviteModalVisible, setInviteModalVisible] = useState(false);
|
const [inviteModalVisible, setInviteModalVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inviteModalVisible && Platform.OS === 'web') {
|
if (inviteModalVisible) {
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
blurActiveElement();
|
||||||
active?.blur?.();
|
|
||||||
}
|
}
|
||||||
}, [inviteModalVisible]);
|
}, [inviteModalVisible]);
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
JoinType,
|
JoinType,
|
||||||
} from '../../types/dto';
|
} from '../../types/dto';
|
||||||
import { User } from '../../types';
|
import { User } from '../../types';
|
||||||
|
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/messageManager';
|
import { messageManager } from '../../stores/messageManager';
|
||||||
@@ -119,9 +120,8 @@ const GroupInfoScreen: React.FC = () => {
|
|||||||
const [inviting, setInviting] = useState(false);
|
const [inviting, setInviting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((editModalVisible || announcementModalVisible || transferModalVisible || joinTypeModalVisible || inviteModalVisible) && Platform.OS === 'web') {
|
if (editModalVisible || announcementModalVisible || transferModalVisible || joinTypeModalVisible || inviteModalVisible) {
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
blurActiveElement();
|
||||||
active?.blur?.();
|
|
||||||
}
|
}
|
||||||
}, [editModalVisible, announcementModalVisible, transferModalVisible, joinTypeModalVisible, inviteModalVisible]);
|
}, [editModalVisible, announcementModalVisible, transferModalVisible, joinTypeModalVisible, inviteModalVisible]);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
import { groupService } from '../../services/groupService';
|
import { groupService } from '../../services/groupService';
|
||||||
@@ -133,9 +134,8 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
const [newNickname, setNewNickname] = useState('');
|
const [newNickname, setNewNickname] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((actionModalVisible || nicknameModalVisible) && Platform.OS === 'web') {
|
if (actionModalVisible || nicknameModalVisible) {
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
blurActiveElement();
|
||||||
active?.blur?.();
|
|
||||||
}
|
}
|
||||||
}, [actionModalVisible, nicknameModalVisible]);
|
}, [actionModalVisible, nicknameModalVisible]);
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto';
|
import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto';
|
||||||
import { authService } from '../../services';
|
import { authService } from '../../services';
|
||||||
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
import { useUserStore, useAuthStore } from '../../stores';
|
import { useUserStore, useAuthStore } from '../../stores';
|
||||||
// 【新架构】使用MessageManager hooks(会话列表数据源自 MessageManager 游标同步)
|
// 【新架构】使用MessageManager hooks(会话列表数据源自 MessageManager 游标同步)
|
||||||
import {
|
import {
|
||||||
@@ -138,9 +139,8 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
const [actionMenuVisible, setActionMenuVisible] = useState(false);
|
const [actionMenuVisible, setActionMenuVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (actionMenuVisible && Platform.OS === 'web') {
|
if (actionMenuVisible) {
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
blurActiveElement();
|
||||||
active?.blur?.();
|
|
||||||
}
|
}
|
||||||
}, [actionMenuVisible]);
|
}, [actionMenuVisible]);
|
||||||
const [scannerVisible, setScannerVisible] = useState(false);
|
const [scannerVisible, setScannerVisible] = useState(false);
|
||||||
|
|||||||
@@ -15,15 +15,10 @@ import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive
|
|||||||
import { EMOJIS } from './constants';
|
import { EMOJIS } from './constants';
|
||||||
import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '../../../../services/stickerService';
|
import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '../../../../services/stickerService';
|
||||||
import { useAppColors, spacing } from '../../../../theme';
|
import { useAppColors, spacing } from '../../../../theme';
|
||||||
|
import { blurActiveElement } from '../../../../infrastructure/platform';
|
||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
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 布局
|
// 表情尺寸配置 - 固定宽度 40px,使用 flex 布局
|
||||||
const EMOJI_SIZES = {
|
const EMOJI_SIZES = {
|
||||||
mobile: { size: 24, itemWidth: 40, itemHeight: 40 },
|
mobile: { size: 24, itemWidth: 40, itemHeight: 40 },
|
||||||
@@ -182,7 +177,7 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
|||||||
|
|
||||||
// 打开管理界面
|
// 打开管理界面
|
||||||
const handleOpenManage = () => {
|
const handleOpenManage = () => {
|
||||||
blurActiveElementOnWeb();
|
blurActiveElement();
|
||||||
setShowManageModal(true);
|
setShowManageModal(true);
|
||||||
setManageMode(false);
|
setManageMode(false);
|
||||||
setSelectedStickers(new Set());
|
setSelectedStickers(new Set());
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { LongPressMenuProps } from './types';
|
|||||||
import { RECALL_TIME_LIMIT } from './constants';
|
import { RECALL_TIME_LIMIT } from './constants';
|
||||||
import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto';
|
import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto';
|
||||||
import { isStickerExists, addStickerFromUrl } from '../../../../services/stickerService';
|
import { isStickerExists, addStickerFromUrl } from '../../../../services/stickerService';
|
||||||
|
import { blurActiveElement } from '../../../../infrastructure/platform';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -37,16 +38,11 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
|||||||
const styles = useChatScreenStyles();
|
const styles = useChatScreenStyles();
|
||||||
const scaleAnimation = useRef(new Animated.Value(0)).current;
|
const scaleAnimation = useRef(new Animated.Value(0)).current;
|
||||||
const opacityAnimation = useRef(new Animated.Value(0)).current;
|
const opacityAnimation = useRef(new Animated.Value(0)).current;
|
||||||
const blurActiveElementOnWeb = () => {
|
|
||||||
if (Platform.OS !== 'web') return;
|
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
|
||||||
active?.blur?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 显示动画 - 缩放弹出
|
// 显示动画 - 缩放弹出
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
blurActiveElementOnWeb();
|
blurActiveElement();
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.spring(scaleAnimation, {
|
Animated.spring(scaleAnimation, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
@@ -61,7 +57,7 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
|||||||
}),
|
}),
|
||||||
]).start();
|
]).start();
|
||||||
} else {
|
} else {
|
||||||
blurActiveElementOnWeb();
|
blurActiveElement();
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(scaleAnimation, {
|
Animated.timing(scaleAnimation, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
@@ -251,12 +247,12 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
|||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="none"
|
animationType="none"
|
||||||
onShow={blurActiveElementOnWeb}
|
onShow={blurActiveElement}
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
blurActiveElementOnWeb();
|
blurActiveElement();
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
style={styles.qqMenuOverlay}
|
style={styles.qqMenuOverlay}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const MAX_WIDTH_RATIO = {
|
|||||||
desktop: 0.55,
|
desktop: 0.55,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||||
message,
|
message,
|
||||||
index,
|
index,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
@@ -481,11 +481,37 @@ const segmentStyles = StyleSheet.create({
|
|||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
},
|
},
|
||||||
pureImageBubble: {
|
pureImageBubble: {
|
||||||
// 纯图片消息:去除内边距,让图片紧贴气泡边缘
|
|
||||||
paddingHorizontal: 0,
|
paddingHorizontal: 0,
|
||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const MessageBubble = React.memo(MessageBubbleInner, (prev, next) => {
|
||||||
|
if (prev.message.id !== next.message.id) return false;
|
||||||
|
if (prev.message.status !== next.message.status) return false;
|
||||||
|
if (prev.message.segments !== next.message.segments) return false;
|
||||||
|
if (prev.message.sender_id !== next.message.sender_id) return false;
|
||||||
|
if (prev.message.is_system_notice !== next.message.is_system_notice) return false;
|
||||||
|
if (prev.message.category !== next.message.category) return false;
|
||||||
|
if (prev.message.seq !== next.message.seq) return false;
|
||||||
|
if (prev.message.sender !== next.message.sender) return false;
|
||||||
|
if (prev.message.notice_content !== next.message.notice_content) return false;
|
||||||
|
if (prev.index !== next.index) return false;
|
||||||
|
if (prev.currentUserId !== next.currentUserId) return false;
|
||||||
|
if (prev.otherUserLastReadSeq !== next.otherUserLastReadSeq) return false;
|
||||||
|
if (prev.selectedMessageId !== next.selectedMessageId) return false;
|
||||||
|
if (prev.isGroupChat !== next.isGroupChat) return false;
|
||||||
|
if (prev.groupMembers !== next.groupMembers) return false;
|
||||||
|
if (prev.currentUser !== next.currentUser) return false;
|
||||||
|
if (prev.otherUser !== next.otherUser) return false;
|
||||||
|
if (prev.messageMap !== next.messageMap) return false;
|
||||||
|
if (prev.onLongPress !== next.onLongPress) return false;
|
||||||
|
if (prev.onImagePress !== next.onImagePress) return false;
|
||||||
|
if (prev.onReplyPress !== next.onReplyPress) return false;
|
||||||
|
if (prev.shouldShowTime !== next.shouldShowTime) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
export default MessageBubble;
|
export default MessageBubble;
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNod
|
|||||||
* 渲染图片 Segment
|
* 渲染图片 Segment
|
||||||
*/
|
*/
|
||||||
// 图片Segment组件 - 使用Hook获取实际尺寸
|
// 图片Segment组件 - 使用Hook获取实际尺寸
|
||||||
const ImageSegment: React.FC<{
|
const ImageSegmentInner: React.FC<{
|
||||||
data: ImageSegmentData;
|
data: ImageSegmentData;
|
||||||
isMe: boolean;
|
isMe: boolean;
|
||||||
onImagePress?: (url: string) => void;
|
onImagePress?: (url: string) => void;
|
||||||
@@ -284,7 +284,6 @@ const ImageSegment: React.FC<{
|
|||||||
style={{
|
style={{
|
||||||
width: IMAGE_FALLBACK_SIZE.width,
|
width: IMAGE_FALLBACK_SIZE.width,
|
||||||
height: IMAGE_FALLBACK_SIZE.height,
|
height: IMAGE_FALLBACK_SIZE.height,
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -312,7 +311,6 @@ const ImageSegment: React.FC<{
|
|||||||
style={{
|
style={{
|
||||||
width: IMAGE_FALLBACK_SIZE.width,
|
width: IMAGE_FALLBACK_SIZE.width,
|
||||||
height: IMAGE_FALLBACK_SIZE.height,
|
height: IMAGE_FALLBACK_SIZE.height,
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -334,7 +332,6 @@ const ImageSegment: React.FC<{
|
|||||||
style={{
|
style={{
|
||||||
width: dimensions.width,
|
width: dimensions.width,
|
||||||
height: dimensions.height,
|
height: dimensions.height,
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
}}
|
||||||
recyclingKey={stableImageKey}
|
recyclingKey={stableImageKey}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
@@ -345,7 +342,6 @@ const ImageSegment: React.FC<{
|
|||||||
setLoadError(false);
|
setLoadError(false);
|
||||||
}}
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
// 缩略图失败时自动回退原图,降低跳转定位后的白块/丢图概率
|
|
||||||
if (data.thumbnail_url && data.url && imageUrl === data.thumbnail_url) {
|
if (data.thumbnail_url && data.url && imageUrl === data.thumbnail_url) {
|
||||||
setCurrentImageUri(data.url);
|
setCurrentImageUri(data.url);
|
||||||
return;
|
return;
|
||||||
@@ -357,6 +353,17 @@ const ImageSegment: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ImageSegment = React.memo(ImageSegmentInner, (prev, next) => {
|
||||||
|
if (prev.data.url !== next.data.url) return false;
|
||||||
|
if (prev.data.thumbnail_url !== next.data.thumbnail_url) return false;
|
||||||
|
if (prev.data.width !== next.data.width) return false;
|
||||||
|
if (prev.data.height !== next.data.height) return false;
|
||||||
|
if (prev.isMe !== next.isMe) return false;
|
||||||
|
if (prev.onImagePress !== next.onImagePress) return false;
|
||||||
|
if (prev.onImageLongPress !== next.onImageLongPress) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const renderImageSegment = (
|
const renderImageSegment = (
|
||||||
data: ImageSegmentData,
|
data: ImageSegmentData,
|
||||||
props: Omit<SegmentRendererProps, 'segment'>
|
props: Omit<SegmentRendererProps, 'segment'>
|
||||||
@@ -705,7 +712,7 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
|
|||||||
/**
|
/**
|
||||||
* 渲染完整的消息链(多个 Segment 组合)
|
* 渲染完整的消息链(多个 Segment 组合)
|
||||||
*/
|
*/
|
||||||
export const MessageSegmentsRenderer: React.FC<{
|
const MessageSegmentsRendererInner: React.FC<{
|
||||||
segments: MessageSegment[];
|
segments: MessageSegment[];
|
||||||
isMe: boolean;
|
isMe: boolean;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
@@ -734,11 +741,11 @@ export const MessageSegmentsRenderer: React.FC<{
|
|||||||
const fontSize = useChatSettingsStore((s) => s.fontSize);
|
const fontSize = useChatSettingsStore((s) => s.fontSize);
|
||||||
const segStyles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]);
|
const segStyles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]);
|
||||||
|
|
||||||
const replySegment = segments.find(s => s.type === 'reply');
|
const replySegment = useMemo(() => segments.find(s => s.type === 'reply'), [segments]);
|
||||||
const otherSegments = segments.filter(s => s.type !== 'reply');
|
const otherSegments = useMemo(() => segments.filter(s => s.type !== 'reply'), [segments]);
|
||||||
const chunks = partitionMessageSegments(otherSegments);
|
const chunks = useMemo(() => partitionMessageSegments(otherSegments), [otherSegments]);
|
||||||
|
|
||||||
const renderProps = {
|
const renderProps = useMemo(() => ({
|
||||||
isMe,
|
isMe,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
memberMap,
|
memberMap,
|
||||||
@@ -747,7 +754,7 @@ export const MessageSegmentsRenderer: React.FC<{
|
|||||||
onImagePress,
|
onImagePress,
|
||||||
onImageLongPress,
|
onImageLongPress,
|
||||||
onLinkPress,
|
onLinkPress,
|
||||||
};
|
}), [isMe, currentUserId, memberMap, onAtPress, onReplyPress, onImagePress, onImageLongPress, onLinkPress]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SegmentStylesContext.Provider value={segStyles}>
|
<SegmentStylesContext.Provider value={segStyles}>
|
||||||
@@ -837,13 +844,16 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
|||||||
},
|
},
|
||||||
imagesChunk: {
|
imagesChunk: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
imageStackItem: {
|
imageStackItem: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
marginBottom: 6,
|
marginBottom: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
imageStackItemLast: {
|
imageStackItemLast: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
blockChunk: {
|
blockChunk: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -864,14 +874,13 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
|||||||
color: colors.chat.textPrimary,
|
color: colors.chat.textPrimary,
|
||||||
},
|
},
|
||||||
|
|
||||||
// @提及 - 微信/QQ 风格:更精致的高亮,支持动态字号
|
// @提及 - 与普通文本字号一致,仅颜色区分
|
||||||
atText: {
|
atText: {
|
||||||
fontSize,
|
fontSize,
|
||||||
lineHeight: Math.round(fontSize * 1.4),
|
lineHeight: Math.round(fontSize * 1.43),
|
||||||
fontWeight: '500',
|
|
||||||
},
|
},
|
||||||
atTextMe: {
|
atTextMe: {
|
||||||
color: '#1A5F9E', // 深蓝色,在绿色背景上更清晰
|
color: '#1A5F9E',
|
||||||
},
|
},
|
||||||
atTextOther: {
|
atTextOther: {
|
||||||
color: colors.chat.link,
|
color: colors.chat.link,
|
||||||
@@ -879,25 +888,15 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
|||||||
atHighlight: {
|
atHighlight: {
|
||||||
backgroundColor: 'rgba(74, 136, 199, 0.15)',
|
backgroundColor: 'rgba(74, 136, 199, 0.15)',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
paddingHorizontal: 3,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 图片 - QQ风格:保持原始宽高比
|
// 图片 - QQ风格:保持原始宽高比
|
||||||
imageContainer: {
|
imageContainer: {
|
||||||
borderRadius: 12,
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
marginTop: 4,
|
|
||||||
shadowColor: colors.chat.shadow,
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.15,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 4,
|
|
||||||
},
|
},
|
||||||
imageMe: {
|
imageMe: {
|
||||||
borderBottomRightRadius: 4,
|
|
||||||
},
|
},
|
||||||
imageOther: {
|
imageOther: {
|
||||||
borderBottomLeftRadius: 4,
|
|
||||||
},
|
},
|
||||||
// 移除固定的 image 样式,改为在组件中动态计算
|
// 移除固定的 image 样式,改为在组件中动态计算
|
||||||
|
|
||||||
@@ -1143,4 +1142,19 @@ function useSegmentStyles(): SegmentStyles {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MessageSegmentsRenderer = React.memo(MessageSegmentsRendererInner, (prev, next) => {
|
||||||
|
if (prev.segments !== next.segments) return false;
|
||||||
|
if (prev.isMe !== next.isMe) return false;
|
||||||
|
if (prev.currentUserId !== next.currentUserId) return false;
|
||||||
|
if (prev.memberMap !== next.memberMap) return false;
|
||||||
|
if (prev.replyMessage !== next.replyMessage) return false;
|
||||||
|
if (prev.onAtPress !== next.onAtPress) return false;
|
||||||
|
if (prev.onReplyPress !== next.onReplyPress) return false;
|
||||||
|
if (prev.onImagePress !== next.onImagePress) return false;
|
||||||
|
if (prev.onImageLongPress !== next.onImageLongPress) return false;
|
||||||
|
if (prev.onLinkPress !== next.onLinkPress) return false;
|
||||||
|
if (prev.getSenderInfo !== next.getSenderInfo) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
export default MessageSegmentsRenderer;
|
export default MessageSegmentsRenderer;
|
||||||
@@ -174,9 +174,9 @@ export const useChatScreen = () => {
|
|||||||
status: (m.status || 'normal') as MessageStatus,
|
status: (m.status || 'normal') as MessageStatus,
|
||||||
category: m.category,
|
category: m.category,
|
||||||
created_at: m.created_at,
|
created_at: m.created_at,
|
||||||
sender: (m as any).sender,
|
sender: m.sender,
|
||||||
is_system_notice: (m as any).is_system_notice,
|
is_system_notice: m.is_system_notice,
|
||||||
notice_content: (m as any).notice_content,
|
notice_content: m.notice_content,
|
||||||
}));
|
}));
|
||||||
}, [messageManagerMessages]);
|
}, [messageManagerMessages]);
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ export const useChatScreen = () => {
|
|||||||
setOtherUser(prev => ({ ...(prev || {}), ...other }));
|
setOtherUser(prev => ({ ...(prev || {}), ...other }));
|
||||||
}
|
}
|
||||||
// 获取对方最后阅读位置
|
// 获取对方最后阅读位置
|
||||||
setOtherUserLastReadSeq((conversation as any).other_last_read_seq || 0);
|
setOtherUserLastReadSeq(conversation.other_last_read_seq || 0);
|
||||||
}
|
}
|
||||||
}, [conversation, isGroupChat, currentUserId]);
|
}, [conversation, isGroupChat, currentUserId]);
|
||||||
|
|
||||||
@@ -273,8 +273,8 @@ export const useChatScreen = () => {
|
|||||||
const detail = await userManager.getUserById(String(otherUserId), true);
|
const detail = await userManager.getUserById(String(otherUserId), true);
|
||||||
if (!detail || cancelled) return;
|
if (!detail || cancelled) return;
|
||||||
setOtherUser(prev => ({ ...(prev || {}), ...detail }));
|
setOtherUser(prev => ({ ...(prev || {}), ...detail }));
|
||||||
if (typeof (detail as any).is_following_me === 'boolean') {
|
if (typeof detail.is_following_me === 'boolean') {
|
||||||
setIsFollowedByOther((detail as any).is_following_me);
|
setIsFollowedByOther(detail.is_following_me);
|
||||||
} else {
|
} else {
|
||||||
setIsFollowedByOther(null);
|
setIsFollowedByOther(null);
|
||||||
}
|
}
|
||||||
@@ -360,7 +360,7 @@ export const useChatScreen = () => {
|
|||||||
}, [loading, loadingMore, messages.length, scrollToLatest]);
|
}, [loading, loadingMore, messages.length, scrollToLatest]);
|
||||||
|
|
||||||
// 新消息跟随策略(Telegram/QQ 风格):
|
// 新消息跟随策略(Telegram/QQ 风格):
|
||||||
// 仅当“最新端消息 seq 增长”且用户在底部附近时才跟随;
|
// 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
|
||||||
// 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。
|
// 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentCount = messages.length;
|
const currentCount = messages.length;
|
||||||
@@ -371,14 +371,14 @@ export const useChatScreen = () => {
|
|||||||
|
|
||||||
if (loading || loadingMore) return;
|
if (loading || loadingMore) return;
|
||||||
if (latestSeq <= prevLatestSeq) return;
|
if (latestSeq <= prevLatestSeq) return;
|
||||||
if (suppressAutoFollowRef.current) return;
|
if (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked()) return;
|
||||||
if (!isNearBottom()) return;
|
if (!isNearBottom()) return;
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
scrollToLatest(false, false, 'new-message-follow');
|
scrollToLatest(false, false, 'new-message-follow');
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [messages, loading, loadingMore, isNearBottom, scrollToLatest]);
|
}, [messages, loading, loadingMore, isNearBottom, scrollToLatest, isHistoryLoadingLocked]);
|
||||||
|
|
||||||
// 获取当前用户信息
|
// 获取当前用户信息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -559,10 +559,10 @@ export const useChatScreen = () => {
|
|||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
if (!conversation) return;
|
if (!conversation) return;
|
||||||
|
|
||||||
const unreadCount = Number((conversation as any).unread_count || 0);
|
const unreadCount = Number(conversation.unread_count || 0);
|
||||||
if (unreadCount <= 0) return;
|
if (unreadCount <= 0) return;
|
||||||
|
|
||||||
const latestFromConversation = Number((conversation as any).last_seq || 0);
|
const latestFromConversation = Number(conversation.last_seq || 0);
|
||||||
const latestFromMessages = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
const latestFromMessages = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
||||||
const targetSeq = Math.max(latestFromConversation, latestFromMessages);
|
const targetSeq = Math.max(latestFromConversation, latestFromMessages);
|
||||||
if (targetSeq <= 0) return;
|
if (targetSeq <= 0) return;
|
||||||
@@ -1305,8 +1305,8 @@ export const useChatScreen = () => {
|
|||||||
|
|
||||||
// 关闭所有面板
|
// 关闭所有面板
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
if (activePanel !== 'none') {
|
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
|
if (activePanel !== 'none') {
|
||||||
setActivePanel('none');
|
setActivePanel('none');
|
||||||
}
|
}
|
||||||
}, [activePanel]);
|
}, [activePanel]);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '
|
|||||||
import { authService } from '../../../services/authService';
|
import { authService } from '../../../services/authService';
|
||||||
import { useAuthStore } from '../../../stores';
|
import { useAuthStore } from '../../../stores';
|
||||||
import { Avatar, EmptyState, Loading, Text } from '../../../components/common';
|
import { Avatar, EmptyState, Loading, Text } from '../../../components/common';
|
||||||
|
import { blurActiveElement } from '../../../infrastructure/platform';
|
||||||
import { User } from '../../../types';
|
import { User } from '../../../types';
|
||||||
|
|
||||||
type MutualFollowSelectorModalProps = {
|
type MutualFollowSelectorModalProps = {
|
||||||
@@ -57,10 +58,7 @@ const MutualFollowSelectorModal: React.FC<MutualFollowSelectorModalProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
if (Platform.OS === 'web') {
|
blurActiveElement();
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
|
||||||
active?.blur?.();
|
|
||||||
}
|
|
||||||
if (!currentUserId) return;
|
if (!currentUserId) return;
|
||||||
|
|
||||||
const nextSelectedIds = new Set(stableInitialSelectedIds);
|
const nextSelectedIds = new Set(stableInitialSelectedIds);
|
||||||
|
|||||||
@@ -482,7 +482,6 @@ export const AboutScreen: React.FC = () => {
|
|||||||
const appInfoItems = [
|
const appInfoItems = [
|
||||||
{ key: 'name', icon: 'information-outline', title: '应用名称', value: APP_NAME },
|
{ key: 'name', icon: 'information-outline', title: '应用名称', value: APP_NAME },
|
||||||
{ key: 'version', icon: 'tag-outline', title: '版本号', value: `v${APP_VERSION} (${BUILD_NUMBER})` },
|
{ key: 'version', icon: 'tag-outline', title: '版本号', value: `v${APP_VERSION} (${BUILD_NUMBER})` },
|
||||||
{ key: 'developer', icon: 'office-building-outline', title: '开发者', value: '青春之旅电子信息科技' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderSettingItem = (item: { key: string; icon: string; title: string; value?: string; action?: () => void }, index: number, total: number, showArrow: boolean = false) => (
|
const renderSettingItem = (item: { key: string; icon: string; title: string; value?: string; action?: () => void }, index: number, total: number, showArrow: boolean = false) => (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { PanGestureHandler, State } from 'react-native-gesture-handler';
|
|||||||
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
|
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import {
|
import {
|
||||||
@@ -214,9 +215,8 @@ export const ScheduleScreen: React.FC = () => {
|
|||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((isAddModalVisible || isSettingsModalVisible || isSyncModalVisible) && Platform.OS === 'web') {
|
if (isAddModalVisible || isSettingsModalVisible || isSyncModalVisible) {
|
||||||
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
blurActiveElement();
|
||||||
active?.blur?.();
|
|
||||||
}
|
}
|
||||||
}, [isAddModalVisible, isSettingsModalVisible, isSyncModalVisible]);
|
}, [isAddModalVisible, isSettingsModalVisible, isSyncModalVisible]);
|
||||||
|
|
||||||
|
|||||||
196
src/services/errorHandler.ts
Normal file
196
src/services/errorHandler.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* 统一错误处理服务
|
||||||
|
*
|
||||||
|
* 提供标准化的错误处理流程:
|
||||||
|
* 1. 错误分类(网络错误、认证错误、业务错误等)
|
||||||
|
* 2. 日志记录
|
||||||
|
* 3. 用户提示
|
||||||
|
* 4. 错误上报
|
||||||
|
*
|
||||||
|
* 替代散落在代码中的 console.error + 手动 Alert 模式
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { showPrompt } from './promptService';
|
||||||
|
|
||||||
|
export enum AppErrorCode {
|
||||||
|
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||||
|
AUTH_ERROR = 'AUTH_ERROR',
|
||||||
|
FORBIDDEN = 'FORBIDDEN',
|
||||||
|
NOT_FOUND = 'NOT_FOUND',
|
||||||
|
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||||
|
SERVER_ERROR = 'SERVER_ERROR',
|
||||||
|
TIMEOUT = 'TIMEOUT',
|
||||||
|
UNKNOWN = 'UNKNOWN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppError {
|
||||||
|
code: AppErrorCode;
|
||||||
|
message: string;
|
||||||
|
originalError?: unknown;
|
||||||
|
context?: string;
|
||||||
|
silent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_MESSAGES: Record<AppErrorCode, string> = {
|
||||||
|
[AppErrorCode.NETWORK_ERROR]: '网络连接失败,请检查网络设置',
|
||||||
|
[AppErrorCode.AUTH_ERROR]: '登录已过期,请重新登录',
|
||||||
|
[AppErrorCode.FORBIDDEN]: '没有权限执行此操作',
|
||||||
|
[AppErrorCode.NOT_FOUND]: '请求的资源不存在',
|
||||||
|
[AppErrorCode.VALIDATION_ERROR]: '输入数据有误,请检查后重试',
|
||||||
|
[AppErrorCode.SERVER_ERROR]: '服务器错误,请稍后重试',
|
||||||
|
[AppErrorCode.TIMEOUT]: '请求超时,请稍后重试',
|
||||||
|
[AppErrorCode.UNKNOWN]: '操作失败,请稍后重试',
|
||||||
|
};
|
||||||
|
|
||||||
|
function classifyError(error: unknown): AppErrorCode {
|
||||||
|
if (!error) return AppErrorCode.UNKNOWN;
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
|
||||||
|
if (message.includes('network') || message.includes('fetch') || message.includes('econnrefused')) {
|
||||||
|
return AppErrorCode.NETWORK_ERROR;
|
||||||
|
}
|
||||||
|
if (message.includes('timeout') || message.includes('timed out')) {
|
||||||
|
return AppErrorCode.TIMEOUT;
|
||||||
|
}
|
||||||
|
if (message.includes('unauthorized') || message.includes('401') || message.includes('token')) {
|
||||||
|
return AppErrorCode.AUTH_ERROR;
|
||||||
|
}
|
||||||
|
if (message.includes('forbidden') || message.includes('403')) {
|
||||||
|
return AppErrorCode.FORBIDDEN;
|
||||||
|
}
|
||||||
|
if (message.includes('not found') || message.includes('404')) {
|
||||||
|
return AppErrorCode.NOT_FOUND;
|
||||||
|
}
|
||||||
|
if (message.includes('validation') || message.includes('400') || message.includes('422')) {
|
||||||
|
return AppErrorCode.VALIDATION_ERROR;
|
||||||
|
}
|
||||||
|
if (message.includes('500') || message.includes('502') || message.includes('503')) {
|
||||||
|
return AppErrorCode.SERVER_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === 'object' && error !== null) {
|
||||||
|
const err = error as Record<string, unknown>;
|
||||||
|
const status = err.status || err.statusCode || err.code;
|
||||||
|
|
||||||
|
if (status === 401) return AppErrorCode.AUTH_ERROR;
|
||||||
|
if (status === 403) return AppErrorCode.FORBIDDEN;
|
||||||
|
if (status === 404) return AppErrorCode.NOT_FOUND;
|
||||||
|
if (status === 400 || status === 422) return AppErrorCode.VALIDATION_ERROR;
|
||||||
|
if (typeof status === 'number' && status >= 500) return AppErrorCode.SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppErrorCode.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppError(
|
||||||
|
error: unknown,
|
||||||
|
context?: string,
|
||||||
|
options?: { silent?: boolean; userMessage?: string }
|
||||||
|
): AppError {
|
||||||
|
const code = classifyError(error);
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message: options?.userMessage || ERROR_MESSAGES[code],
|
||||||
|
originalError: error,
|
||||||
|
context,
|
||||||
|
silent: options?.silent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorHandlerOptions {
|
||||||
|
context?: string;
|
||||||
|
userMessage?: string;
|
||||||
|
silent?: boolean;
|
||||||
|
showErrorPrompt?: boolean;
|
||||||
|
logToConsole?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一错误处理函数
|
||||||
|
*
|
||||||
|
* 替代散落的 console.error + Alert 模式
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 之前
|
||||||
|
* } catch (error) {
|
||||||
|
* console.error('加载帖子详情失败:', error);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // 之后
|
||||||
|
* } catch (error) {
|
||||||
|
* handleError(error, { context: '加载帖子详情' });
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export function handleError(error: unknown, options: ErrorHandlerOptions = {}): AppError {
|
||||||
|
const {
|
||||||
|
context,
|
||||||
|
userMessage,
|
||||||
|
silent = false,
|
||||||
|
showErrorPrompt = false,
|
||||||
|
logToConsole = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const appError = createAppError(error, context, { silent, userMessage });
|
||||||
|
|
||||||
|
if (logToConsole) {
|
||||||
|
const prefix = context ? `[${context}]` : '';
|
||||||
|
console.error(`${prefix} ${appError.message}`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showErrorPrompt && !silent) {
|
||||||
|
showPrompt({
|
||||||
|
type: 'error',
|
||||||
|
title: context || '错误',
|
||||||
|
message: appError.message,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return appError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带上下文的错误处理器
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const postErrorHandler = createErrorHandler('帖子');
|
||||||
|
*
|
||||||
|
* } catch (error) {
|
||||||
|
* postErrorHandler(error, { showErrorPrompt: true });
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export function createErrorHandler(defaultContext: string) {
|
||||||
|
return (error: unknown, options: Omit<ErrorHandlerOptions, 'context'> = {}) => {
|
||||||
|
return handleError(error, { ...options, context: defaultContext });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全执行异步操作
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const result = await safeAsync(
|
||||||
|
* () => postService.getPost(id),
|
||||||
|
* { context: '加载帖子' }
|
||||||
|
* );
|
||||||
|
* if (result.error) {
|
||||||
|
* // 处理错误
|
||||||
|
* } else {
|
||||||
|
* // 使用 result.data
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export async function safeAsync<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: ErrorHandlerOptions = {}
|
||||||
|
): Promise<{ data: T | null; error: AppError | null }> {
|
||||||
|
try {
|
||||||
|
const data = await fn();
|
||||||
|
return { data, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
const appError = handleError(error, options);
|
||||||
|
return { data: null, error: appError };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,11 @@ export {
|
|||||||
export { showPrompt } from './promptService';
|
export { showPrompt } from './promptService';
|
||||||
export { showConfirm } from './dialogService';
|
export { showConfirm } from './dialogService';
|
||||||
|
|
||||||
|
// 统一错误处理服务
|
||||||
|
export { handleError, createErrorHandler, safeAsync, createAppError } from './errorHandler';
|
||||||
|
export type { AppError, ErrorHandlerOptions } from './errorHandler';
|
||||||
|
export { AppErrorCode } from './errorHandler';
|
||||||
|
|
||||||
// APK 更新检查服务
|
// APK 更新检查服务
|
||||||
export { apkUpdateService, checkForAPKUpdate, manualCheckForUpdate } from './apkUpdateService';
|
export { apkUpdateService, checkForAPKUpdate, manualCheckForUpdate } from './apkUpdateService';
|
||||||
export type { APKVersionInfo, VersionCheckResult } from './apkUpdateService';
|
export type { APKVersionInfo, VersionCheckResult } from './apkUpdateService';
|
||||||
|
|||||||
@@ -255,17 +255,12 @@ export const useUserStore = create<UserState>((set, get) => {
|
|||||||
const originalUsers = get().users;
|
const originalUsers = get().users;
|
||||||
const originalUserCache = get().userCache;
|
const originalUserCache = get().userCache;
|
||||||
|
|
||||||
// 乐观更新 users
|
|
||||||
set(state => ({
|
set(state => ({
|
||||||
users: state.users.map(u =>
|
users: state.users.map(u =>
|
||||||
u.id === userId
|
u.id === userId
|
||||||
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
|
? { ...u, isFollowing: true, followersCount: u.followers_count + 1 }
|
||||||
: u
|
: u
|
||||||
),
|
),
|
||||||
}));
|
|
||||||
|
|
||||||
// 乐观更新 userCache
|
|
||||||
set(state => ({
|
|
||||||
userCache: Object.fromEntries(
|
userCache: Object.fromEntries(
|
||||||
Object.entries(state.userCache).map(([id, user]) => [
|
Object.entries(state.userCache).map(([id, user]) => [
|
||||||
id,
|
id,
|
||||||
@@ -280,30 +275,20 @@ export const useUserStore = create<UserState>((set, get) => {
|
|||||||
await authService.followUser(userId);
|
await authService.followUser(userId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('关注用户失败:', error);
|
console.error('关注用户失败:', error);
|
||||||
// 回滚
|
set({ users: originalUsers, userCache: originalUserCache });
|
||||||
set({
|
|
||||||
users: originalUsers,
|
|
||||||
userCache: originalUserCache
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 取消关注 - authService 不管理状态,所以由 store 管理
|
|
||||||
unfollowUser: async (userId: string) => {
|
unfollowUser: async (userId: string) => {
|
||||||
const originalUsers = get().users;
|
const originalUsers = get().users;
|
||||||
const originalUserCache = get().userCache;
|
const originalUserCache = get().userCache;
|
||||||
|
|
||||||
// 乐观更新 users
|
|
||||||
set(state => ({
|
set(state => ({
|
||||||
users: state.users.map(u =>
|
users: state.users.map(u =>
|
||||||
u.id === userId
|
u.id === userId
|
||||||
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
|
? { ...u, isFollowing: false, followersCount: Math.max(0, u.followers_count - 1) }
|
||||||
: u
|
: u
|
||||||
),
|
),
|
||||||
}));
|
|
||||||
|
|
||||||
// 乐观更新 userCache
|
|
||||||
set(state => ({
|
|
||||||
userCache: Object.fromEntries(
|
userCache: Object.fromEntries(
|
||||||
Object.entries(state.userCache).map(([id, user]) => [
|
Object.entries(state.userCache).map(([id, user]) => [
|
||||||
id,
|
id,
|
||||||
@@ -318,11 +303,7 @@ export const useUserStore = create<UserState>((set, get) => {
|
|||||||
await authService.unfollowUser(userId);
|
await authService.unfollowUser(userId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('取消关注用户失败:', error);
|
console.error('取消关注用户失败:', error);
|
||||||
// 回滚
|
set({ users: originalUsers, userCache: originalUserCache });
|
||||||
set({
|
|
||||||
users: originalUsers,
|
|
||||||
userCache: originalUserCache
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -231,16 +231,18 @@ export type MessageSegment =
|
|||||||
|
|
||||||
// 消息响应
|
// 消息响应
|
||||||
export interface MessageResponse {
|
export interface MessageResponse {
|
||||||
id: string; // 雪花算法ID (使用string避免JavaScript精度丢失)
|
id: string;
|
||||||
conversation_id: string;
|
conversation_id: string;
|
||||||
sender_id: string; // UUID字符串
|
sender_id: string;
|
||||||
seq: number; // 消息序号
|
seq: number;
|
||||||
segments: MessageSegment[]; // 消息链
|
segments: MessageSegment[];
|
||||||
reply_to_id?: string; // 被回复消息的ID(用于关联查找)
|
reply_to_id?: string;
|
||||||
status: MessageStatus;
|
status: MessageStatus;
|
||||||
category?: string; // 消息类别:chat, notification, announcement
|
category?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
sender?: UserDTO;
|
sender?: UserDTO;
|
||||||
|
is_system_notice?: boolean;
|
||||||
|
notice_content?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 会话响应
|
// 会话响应
|
||||||
|
|||||||
Reference in New Issue
Block a user