chahinebrini fb2d90b947 fix(calls): no duplicate incoming-call notifications
- backend: skip Expo alert push to iOS devices that already received VoIP push
  (CallKit + banner = double ring)
- native: receiveIncoming no longer triggers InCallManager.startRingtone —
  CallKit/ConnectionService play their own ring. Dedup if same callId
  arrives twice (Realtime + VoIP-Push race).
2026-06-04 18:28:00 +02:00

124 lines
3.3 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { View, Animated, Easing, StyleSheet } from 'react-native';
import { UserAvatar } from '../UserAvatar';
import { useColors } from '../../lib/theme';
import { useThemeStore } from '../../stores/theme';
/**
* In-Thread Typing-Indicator (Instagram-Style): Partner-Avatar links + graue
* Bubble mit drei wellenartig auf-/abspringenden Punkten. Erscheint als
* ListFooter unter der letzten Nachricht, solange der Partner tippt.
*
* Bubble-Styling spiegelt die eingehende ChatBubble (cleanBg): hellgrau im
* Light-Mode (#EFEFF1), dunkel im Dark-Mode (#2c2c2e).
*/
function WaveDots({ color }: { color: string }) {
const dots = useRef([new Animated.Value(0), new Animated.Value(0), new Animated.Value(0)]).current;
useEffect(() => {
const loops = dots.map((d, i) =>
Animated.loop(
Animated.sequence([
Animated.delay(i * 140),
Animated.timing(d, { toValue: 1, duration: 300, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
Animated.timing(d, { toValue: 0, duration: 300, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
Animated.delay((dots.length - 1 - i) * 140),
]),
),
);
loops.forEach((l) => l.start());
return () => loops.forEach((l) => l.stop());
}, [dots]);
return (
<View style={styles.dotsRow}>
{dots.map((d, i) => (
<Animated.View
key={i}
style={[
styles.dot,
{
backgroundColor: color,
opacity: d.interpolate({ inputRange: [0, 1], outputRange: [0.35, 1] }),
transform: [{ translateY: d.interpolate({ inputRange: [0, 1], outputRange: [0, -4] }) }],
},
]}
/>
))}
</View>
);
}
export function TypingBubble({
userId,
avatar,
nickname,
}: {
userId: string | null;
avatar: string | null;
nickname: string;
}) {
const colors = useColors();
const colorScheme = useThemeStore((s) => s.colorScheme);
const bubbleBg = colorScheme === 'dark' ? '#2c2c2e' : '#EFEFF1';
return (
<View style={styles.row}>
<View style={styles.avatarSlot}>
<UserAvatar userId={userId} avatar={avatar} nickname={nickname} size="sm" showOnlineIndicator={false} />
</View>
<View
style={[
styles.bubble,
{ backgroundColor: bubbleBg },
colorScheme !== 'dark' && styles.bubbleBorder,
]}
>
<WaveDots color={colors.textMuted} />
</View>
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
paddingHorizontal: 10,
marginTop: 8,
alignItems: 'flex-end',
},
avatarSlot: {
width: 32,
marginRight: 6,
justifyContent: 'flex-end',
},
bubble: {
paddingHorizontal: 14,
height: 34,
justifyContent: 'center',
borderTopLeftRadius: 14,
borderTopRightRadius: 14,
borderBottomLeftRadius: 4,
borderBottomRightRadius: 14,
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 3,
shadowOffset: { width: 0, height: 1 },
elevation: 1,
},
bubbleBorder: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(0,0,0,0.06)',
},
dotsRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
dot: {
width: 7,
height: 7,
borderRadius: 3.5,
},
});