chore: remove IDE config files and improve web platform compatibility
- Remove `.idea/` IntelliJ configuration files from version control - Add web-specific touch handling for swipeable message bubbles and schedule screen - Fix CSS touch-action rules for better web scrolling behavior - Add nestedScrollEnabled to ScrollViews for proper gesture handling - Improve null safety checks in profile screens - Add horizontal ScrollView wrapper for notification filter tags - Add hasHeader prop support for embedded profile screens
This commit is contained in:
@@ -415,7 +415,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
return (
|
||||
<SafeAreaView style={containerStyle} edges={props.isEmbedded ? [] : ['top']}>
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.container, { overflow: 'hidden' }]}
|
||||
style={[styles.container, Platform.OS !== 'web' && { overflow: 'hidden' }]}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||
>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
BackHandler,
|
||||
Animated,
|
||||
StatusBar,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
@@ -444,6 +445,11 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
{renderHeader()}
|
||||
{/* 分类筛选 */}
|
||||
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWideWeb]}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filterList}
|
||||
>
|
||||
{MESSAGE_TYPES.map(type => {
|
||||
const count = type.key === 'all'
|
||||
? displayMessages.length
|
||||
@@ -469,9 +475,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={isActive ? colors.text.inverse : colors.text.secondary}
|
||||
style={isActive ? styles.filterTagTextActive : undefined}
|
||||
style={isActive ? { ...styles.filterTagText, ...styles.filterTagTextActive } : styles.filterTagText}
|
||||
>
|
||||
{type.title}
|
||||
</Text>
|
||||
@@ -479,7 +483,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={isActive ? colors.text.inverse : colors.text.hint}
|
||||
color={isActive ? colors.primary.main : colors.text.hint}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
@@ -488,6 +492,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 消息列表 */}
|
||||
@@ -526,6 +531,11 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
{renderHeader()}
|
||||
{/* 分类筛选 */}
|
||||
<View style={styles.filterContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filterList}
|
||||
>
|
||||
{MESSAGE_TYPES.map(type => {
|
||||
const count = type.key === 'all'
|
||||
? displayMessages.length
|
||||
@@ -550,9 +560,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={isActive ? colors.text.inverse : colors.text.secondary}
|
||||
style={isActive ? styles.filterTagTextActive : undefined}
|
||||
style={isActive ? { ...styles.filterTagText, ...styles.filterTagTextActive } : styles.filterTagText}
|
||||
>
|
||||
{type.title}
|
||||
</Text>
|
||||
@@ -560,7 +568,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={isActive ? colors.text.inverse : colors.text.hint}
|
||||
color={isActive ? colors.primary.main : colors.text.hint}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
@@ -569,6 +577,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 消息列表 */}
|
||||
@@ -677,52 +686,51 @@ function createNotificationsStyles(colors: AppColors) {
|
||||
},
|
||||
// 分类筛选 - 扁平化风格(分段式设计)
|
||||
filterContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider || '#E5E5EA',
|
||||
},
|
||||
filterContainerWideWeb: {
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
filterList: {
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.xs,
|
||||
paddingLeft: spacing.lg,
|
||||
paddingRight: spacing.lg,
|
||||
},
|
||||
filterTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
marginRight: 8,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.background.default,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
filterTagWide: {
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 10,
|
||||
marginRight: 10,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
filterTagActive: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
filterTagText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
filterTagTextActive: {
|
||||
fontWeight: '600',
|
||||
color: colors.primary.main,
|
||||
},
|
||||
filterCount: {
|
||||
marginLeft: 6,
|
||||
backgroundColor: colors.primary.light + '40',
|
||||
paddingHorizontal: 6,
|
||||
marginLeft: spacing.xs,
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 8,
|
||||
borderRadius: borderRadius.full,
|
||||
minWidth: 18,
|
||||
alignItems: 'center',
|
||||
},
|
||||
filterCountActive: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
*/
|
||||
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { View, StyleSheet, Animated } from 'react-native';
|
||||
import { View, StyleSheet, Animated, Platform } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { PanGestureHandler, State } from 'react-native-gesture-handler';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useAppColors } from '../../../../theme';
|
||||
|
||||
// 滑动阈值
|
||||
const SWIPE_THRESHOLD = 30;
|
||||
const MAX_SWIPE_DISTANCE = 50;
|
||||
|
||||
@@ -31,45 +30,39 @@ export const SwipeableMessageBubble: React.FC<SwipeableMessageBubbleProps> = ({
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const hasTriggeredRef = useRef(false);
|
||||
|
||||
// 处理手势事件 - 使用原生驱动,不干预值
|
||||
const onGestureEvent = Animated.event(
|
||||
[{ nativeEvent: { translationX: translateX } }],
|
||||
{ useNativeDriver: true }
|
||||
);
|
||||
|
||||
// 处理手势状态变化
|
||||
const onHandlerStateChange = useCallback((event: any) => {
|
||||
const { nativeEvent } = event;
|
||||
|
||||
if (nativeEvent.state === State.END) {
|
||||
const translationX = nativeEvent.translationX;
|
||||
|
||||
// 只允许特定方向的滑动
|
||||
const shouldTrigger = isMe
|
||||
? translationX < -SWIPE_THRESHOLD // 自己消息只能向左滑
|
||||
: translationX > SWIPE_THRESHOLD; // 对方消息只能向右滑
|
||||
? translationX < -SWIPE_THRESHOLD
|
||||
: translationX > SWIPE_THRESHOLD;
|
||||
|
||||
if (shouldTrigger && !hasTriggeredRef.current) {
|
||||
hasTriggeredRef.current = true;
|
||||
|
||||
// 触觉反馈
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
|
||||
// 立即触发回复
|
||||
onReply();
|
||||
|
||||
// 重置标记
|
||||
setTimeout(() => {
|
||||
hasTriggeredRef.current = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 立即回到原位
|
||||
translateX.setValue(0);
|
||||
}
|
||||
}, [isMe, onReply]);
|
||||
|
||||
// 计算回复图标的透明度 - 只在允许的方向显示
|
||||
const iconOpacity = translateX.interpolate({
|
||||
inputRange: isMe
|
||||
? [-MAX_SWIPE_DISTANCE, -SWIPE_THRESHOLD, 0, 10]
|
||||
@@ -78,7 +71,6 @@ export const SwipeableMessageBubble: React.FC<SwipeableMessageBubbleProps> = ({
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
// 计算消息的位移 - 限制在允许的方向
|
||||
const clampedTranslateX = translateX.interpolate({
|
||||
inputRange: isMe
|
||||
? [-MAX_SWIPE_DISTANCE - 10, -MAX_SWIPE_DISTANCE, 0, 10]
|
||||
@@ -89,21 +81,117 @@ export const SwipeableMessageBubble: React.FC<SwipeableMessageBubbleProps> = ({
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
// 如果禁用手势,直接返回子元素
|
||||
const webTouchStartRef = useRef<{ x: number; time: number } | null>(null);
|
||||
const webTranslateXRef = useRef(new Animated.Value(0)).current;
|
||||
const webIconOpacity = webTranslateXRef.interpolate({
|
||||
inputRange: isMe
|
||||
? [-MAX_SWIPE_DISTANCE, -SWIPE_THRESHOLD, 0, 10]
|
||||
: [-10, 0, SWIPE_THRESHOLD, MAX_SWIPE_DISTANCE],
|
||||
outputRange: isMe ? [1, 0.8, 0, 0] : [0, 0, 0.8, 1],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
const webClampedTranslateX = webTranslateXRef.interpolate({
|
||||
inputRange: isMe
|
||||
? [-MAX_SWIPE_DISTANCE - 10, -MAX_SWIPE_DISTANCE, 0, 10]
|
||||
: [-10, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE + 10],
|
||||
outputRange: isMe
|
||||
? [-MAX_SWIPE_DISTANCE, -MAX_SWIPE_DISTANCE, 0, 0]
|
||||
: [0, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
const webLastTouchRef = useRef<{ x: number } | null>(null);
|
||||
|
||||
const handleWebTouchStart = useCallback((e: any) => {
|
||||
const touch = e.nativeEvent?.touches?.[0] || e.nativeEvent;
|
||||
if (touch && touch.pageX !== undefined) {
|
||||
webTouchStartRef.current = { x: touch.pageX, time: Date.now() };
|
||||
webLastTouchRef.current = { x: touch.pageX };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWebTouchMove = useCallback((e: any) => {
|
||||
const start = webTouchStartRef.current;
|
||||
if (!start) return;
|
||||
const touch = e.nativeEvent?.touches?.[0] || e.nativeEvent;
|
||||
if (!touch || touch.pageX === undefined) return;
|
||||
const dx = touch.pageX - start.x;
|
||||
const allowed = isMe ? dx < 5 : dx > -5;
|
||||
if (allowed) {
|
||||
webTranslateXRef.setValue(dx);
|
||||
webLastTouchRef.current = { x: touch.pageX };
|
||||
}
|
||||
}, [isMe]);
|
||||
|
||||
const handleWebTouchEnd = useCallback((e: any) => {
|
||||
const start = webTouchStartRef.current;
|
||||
if (!start) return;
|
||||
webTouchStartRef.current = null;
|
||||
const touch = e.nativeEvent?.changedTouches?.[0] || e.nativeEvent;
|
||||
if (!touch || touch.pageX === undefined) {
|
||||
Animated.spring(webTranslateXRef, { toValue: 0, useNativeDriver: true }).start();
|
||||
return;
|
||||
}
|
||||
const dx = touch.pageX - start.x;
|
||||
const shouldTrigger = isMe
|
||||
? dx < -SWIPE_THRESHOLD
|
||||
: dx > SWIPE_THRESHOLD;
|
||||
|
||||
if (shouldTrigger && !hasTriggeredRef.current) {
|
||||
hasTriggeredRef.current = true;
|
||||
onReply();
|
||||
setTimeout(() => {
|
||||
hasTriggeredRef.current = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
Animated.spring(webTranslateXRef, { toValue: 0, useNativeDriver: true }).start();
|
||||
}, [isMe, onReply]);
|
||||
|
||||
if (!enabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.replyIconContainer,
|
||||
isMe ? styles.replyIconLeft : styles.replyIconRight,
|
||||
{ opacity: webIconOpacity },
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="reply"
|
||||
size={20}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
</Animated.View>
|
||||
<View
|
||||
data-gesture-area={true}
|
||||
onTouchStart={handleWebTouchStart}
|
||||
onTouchMove={handleWebTouchMove}
|
||||
onTouchEnd={handleWebTouchEnd}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ translateX: webClampedTranslateX }],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 回复图标 - 在消息后面 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.replyIconContainer,
|
||||
isMe ? styles.replyIconLeft : styles.replyIconRight,
|
||||
{
|
||||
opacity: iconOpacity,
|
||||
},
|
||||
{ opacity: iconOpacity },
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
@@ -112,8 +200,6 @@ export const SwipeableMessageBubble: React.FC<SwipeableMessageBubbleProps> = ({
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* 可滑动的消息内容 */}
|
||||
<PanGestureHandler
|
||||
onGestureEvent={onGestureEvent}
|
||||
onHandlerStateChange={onHandlerStateChange}
|
||||
|
||||
Reference in New Issue
Block a user