Files
frontend/src/screens/message/components/ChatScreen/SwipeableMessageBubble.tsx
lafay c00b915e5f
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m31s
Frontend CI / ota-android (push) Successful in 10m26s
Frontend CI / build-android-apk (push) Successful in 40m43s
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
2026-04-14 02:12:53 +08:00

244 lines
6.9 KiB
TypeScript

/**
* 可滑动的消息气泡容器
* 实现滑动回复手势功能 - 严格单向滑动,无抖动
*/
import React, { useRef, useCallback } from 'react';
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;
interface SwipeableMessageBubbleProps {
children: React.ReactNode;
isMe: boolean;
onReply: () => void;
enabled?: boolean;
}
export const SwipeableMessageBubble: React.FC<SwipeableMessageBubbleProps> = ({
children,
isMe,
onReply,
enabled = true,
}) => {
const colors = useAppColors();
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;
if (shouldTrigger && !hasTriggeredRef.current) {
hasTriggeredRef.current = true;
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]
: [-10, 0, SWIPE_THRESHOLD, MAX_SWIPE_DISTANCE],
outputRange: isMe ? [1, 0.8, 0, 0] : [0, 0, 0.8, 1],
extrapolate: 'clamp',
});
const clampedTranslateX = translateX.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 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 },
]}
>
<MaterialCommunityIcons
name="reply"
size={20}
color={colors.primary.main}
/>
</Animated.View>
<PanGestureHandler
onGestureEvent={onGestureEvent}
onHandlerStateChange={onHandlerStateChange}
activeOffsetX={isMe ? [-10, 9999] : [-9999, 10]}
failOffsetY={[-20, 20]}
>
<Animated.View
style={{
transform: [{ translateX: clampedTranslateX }],
}}
>
{children}
</Animated.View>
</PanGestureHandler>
</View>
);
};
const styles = StyleSheet.create({
container: {
position: 'relative',
},
replyIconContainer: {
position: 'absolute',
top: '50%',
marginTop: -15,
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: 'rgba(255, 107, 53, 0.1)',
alignItems: 'center',
justifyContent: 'center',
},
replyIconLeft: {
left: 8,
},
replyIconRight: {
right: 8,
},
});
export default SwipeableMessageBubble;