Voice Notes (DM): - WhatsApp-style voice recording bar (shared VoiceRecordingBar component) - Audio bubbles: 80 fixed-2dp bars (Instagram-style thin), space-between layout, deterministic waveform, moving blue position dot, WA gray bar colors - Cancel flash fix: setIsVoiceRecording delayed 350ms so trash flash is visible - Mic button 44pt (Apple min), hitSlop on all recording controls - startReply shows 🎤/📷 label for voice/image instead of empty Chat list: - lastAttachmentType from backend (getDmConversations now selects attachmentType) - Shows '🎤 Sprachnachricht' / '📷 Foto' / '📎 Medien' as fallback per type - User search second stage: GET /api/users/search?q= + debounced frontend section - Push preview: audio → '🎤 Sprachnachricht', image → '📷 Foto' (was '📎 Anhang') Blocker iOS Layer 3 (Screen Time): - ScreentimePasscodeCard visible in locked-in state (was hidden once both layers active) - Confirmed status loaded from backend on mount - Numbered step instructions (iOS has no deep link to passcode dialog) - Guard: only for unsupervised VPN+FC path (!mdmManaged && !nefilterActive) - URL fallback: App-Prefs:SCREEN_TIME → App-Prefs:root=SCREEN_TIME → openSettings DiGA Milestone Modal: - Day 3/7/10 celebratory bottom sheet with soft demographic data ask - Per-user/milestone AsyncStorage tracking, never shows if demographics filled - Opens DemographicsAccordion in profile via ?openDemo=1 param Lyra coach: contextual DiGA demographic nudge (optional, positive moments only) i18n: DE/EN/FR/AR for voice_message, photo, media_sent, mic_access, diga_milestone, screentime steps, chat search strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
130 lines
4.0 KiB
TypeScript
130 lines
4.0 KiB
TypeScript
import { useRef, useEffect } from 'react';
|
|
import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useColors } from '../../lib/theme';
|
|
|
|
export function formatVoiceDuration(s: number): string {
|
|
const m = Math.floor(s / 60);
|
|
const sec = (s % 60).toString().padStart(2, '0');
|
|
return `${m}:${sec}`;
|
|
}
|
|
|
|
export function VoiceBars({ count, baseColor, active }: { count: number; baseColor: string; active: boolean }) {
|
|
const anims = useRef(Array.from({ length: count }, () => new Animated.Value(3))).current;
|
|
const runningRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (active && !runningRef.current) {
|
|
runningRef.current = true;
|
|
const animations = anims.map((a, i) =>
|
|
Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(a, { toValue: 3 + Math.random() * 14, duration: 400 + (i % 5) * 90, useNativeDriver: false }),
|
|
Animated.timing(a, { toValue: 3, duration: 400 + (i % 5) * 90, useNativeDriver: false }),
|
|
])
|
|
)
|
|
);
|
|
animations.forEach((a) => a.start());
|
|
return () => { animations.forEach((a) => a.stop()); runningRef.current = false; };
|
|
} else if (!active) {
|
|
anims.forEach((a) => Animated.timing(a, { toValue: 3, duration: 150, useNativeDriver: false }).start());
|
|
runningRef.current = false;
|
|
}
|
|
}, [active]);
|
|
|
|
return (
|
|
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-evenly', height: 20 }}>
|
|
{anims.map((a, i) => (
|
|
<Animated.View
|
|
key={i}
|
|
style={{ width: active ? 2.5 : 2, height: active ? a : 2, borderRadius: 2, backgroundColor: baseColor, opacity: active ? 0.75 : 0.4 }}
|
|
/>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
type Props = {
|
|
duration: number;
|
|
level: number;
|
|
trashFlash: boolean;
|
|
onCancel: () => void;
|
|
onSend: () => void;
|
|
/** Icon for the send button. Default: 'arrow-up' (coach). DM uses 'checkmark'. */
|
|
sendIcon?: 'arrow-up' | 'checkmark';
|
|
/** Accent color for the send button. Defaults to brandOrange. */
|
|
accentColor?: string;
|
|
};
|
|
|
|
export function VoiceRecordingBar({ duration, level, trashFlash, onCancel, onSend, sendIcon = 'arrow-up', accentColor }: Props) {
|
|
const colors = useColors();
|
|
const accent = accentColor ?? colors.brandOrange;
|
|
|
|
return (
|
|
<View style={[styles.bar, { backgroundColor: colors.surfaceElevated, borderColor: colors.border }]}>
|
|
<TouchableOpacity
|
|
style={[styles.sideBtn, { backgroundColor: trashFlash ? 'rgba(220,38,38,0.15)' : colors.bg }]}
|
|
onPress={onCancel}
|
|
activeOpacity={0.7}
|
|
hitSlop={8}
|
|
>
|
|
<Ionicons name="trash-outline" size={17} color={trashFlash ? '#ef4444' : colors.textMuted} />
|
|
</TouchableOpacity>
|
|
|
|
<View style={styles.center}>
|
|
<View style={[styles.liveDot, { backgroundColor: '#ef4444' }]} />
|
|
<VoiceBars count={22} baseColor={colors.text} active={level > 0.1} />
|
|
<Text style={[styles.timer, { color: colors.textMuted }]}>
|
|
{formatVoiceDuration(duration)}
|
|
</Text>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={[styles.sideBtn, { backgroundColor: accent }]}
|
|
onPress={onSend}
|
|
activeOpacity={0.8}
|
|
hitSlop={6}
|
|
>
|
|
<Ionicons name={sendIcon} size={sendIcon === 'checkmark' ? 20 : 18} color="#fff" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
bar: {
|
|
flex: 1,
|
|
height: 44,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderRadius: 22,
|
|
paddingHorizontal: 6,
|
|
},
|
|
sideBtn: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
center: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
},
|
|
liveDot: {
|
|
width: 7,
|
|
height: 7,
|
|
borderRadius: 3.5,
|
|
},
|
|
timer: {
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
fontVariant: ['tabular-nums'],
|
|
minWidth: 32,
|
|
},
|
|
});
|