- 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).
124 lines
3.3 KiB
TypeScript
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,
|
|
},
|
|
});
|