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>
360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
FlatList,
|
|
TouchableOpacity,
|
|
TextInput,
|
|
ActivityIndicator,
|
|
RefreshControl,
|
|
StyleSheet,
|
|
} from 'react-native';
|
|
import { useRouter } from 'expo-router';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { apiFetch } from '../../lib/api';
|
|
import { AppHeader } from '../../components/AppHeader';
|
|
import { UserAvatar } from '../../components/UserAvatar';
|
|
import { useColors } from '../../lib/theme';
|
|
|
|
type DmConversation = {
|
|
partnerId: string;
|
|
partnerName: string;
|
|
partnerAvatar: string | null;
|
|
lastMessage: string;
|
|
lastAttachmentType?: string | null;
|
|
lastMessageAt: string;
|
|
unreadCount: number;
|
|
isOwn: boolean;
|
|
};
|
|
|
|
function formatTime(ts: string, justNowLabel: string): string {
|
|
const diff = Date.now() - new Date(ts).getTime();
|
|
const day = 86_400_000;
|
|
if (diff < 60_000) return justNowLabel;
|
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
|
|
if (diff < day) return `${Math.floor(diff / 3_600_000)}h`;
|
|
if (diff < 7 * day) return new Date(ts).toLocaleDateString('de-DE', { weekday: 'short' });
|
|
if (diff < 30 * day) return `${Math.floor(diff / day)}d`;
|
|
if (diff < 60 * day) return `${Math.floor(diff / (7 * day))}w`;
|
|
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}mo`;
|
|
return `${Math.floor(diff / (365 * day))}y`;
|
|
}
|
|
|
|
function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const styles = makeStyles(colors);
|
|
const hasUnread = conv.unreadCount > 0;
|
|
|
|
return (
|
|
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
|
<View style={styles.dmRow}>
|
|
<UserAvatar
|
|
userId={conv.partnerId}
|
|
avatar={conv.partnerAvatar}
|
|
nickname={conv.partnerName}
|
|
size="md"
|
|
/>
|
|
<View style={styles.dmInfo}>
|
|
<View style={styles.dmHeaderRow}>
|
|
<Text style={styles.dmName} numberOfLines={1}>
|
|
{conv.partnerName}
|
|
</Text>
|
|
<Text style={[styles.dmTime, { color: hasUnread ? colors.brandOrange : colors.textMuted }]}>
|
|
{formatTime(conv.lastMessageAt, t('chat.just_now'))}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.dmBottomRow}>
|
|
<Text
|
|
numberOfLines={1}
|
|
style={[
|
|
styles.dmLast,
|
|
{
|
|
fontFamily: hasUnread ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
|
|
color: hasUnread ? colors.text : colors.textMuted,
|
|
},
|
|
]}
|
|
>
|
|
{conv.isOwn ? `${t('chat.you')} ` : ''}
|
|
{conv.lastMessage ||
|
|
(conv.lastAttachmentType === 'audio' ? `🎤 ${t('chat.voice_message')}` :
|
|
conv.lastAttachmentType === 'image' ? `📷 ${t('chat.photo')}` :
|
|
`📎 ${t('chat.media_sent')}`)}
|
|
</Text>
|
|
{hasUnread && (
|
|
<View style={styles.unreadBadge}>
|
|
<Text style={styles.unreadBadgeText}>
|
|
{conv.unreadCount > 99 ? '99+' : conv.unreadCount}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
export default function ChatScreen() {
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const colors = useColors();
|
|
const styles = makeStyles(colors);
|
|
const [search, setSearch] = useState('');
|
|
const [userRefreshing, setUserRefreshing] = useState(false);
|
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
|
|
// 300ms debounce für User-Suche
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
|
return () => clearTimeout(t);
|
|
}, [search]);
|
|
|
|
const {
|
|
data: convs = [],
|
|
isLoading: loadingDms,
|
|
refetch: refetchDms,
|
|
} = useQuery<DmConversation[]>({
|
|
queryKey: ['dm-conversations'],
|
|
queryFn: () => apiFetch('/api/chat/dm-conversations'),
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const handleRefresh = useCallback(async () => {
|
|
setUserRefreshing(true);
|
|
try {
|
|
await refetchDms();
|
|
} finally {
|
|
setUserRefreshing(false);
|
|
}
|
|
}, [refetchDms]);
|
|
|
|
const filtered = search.trim()
|
|
? convs.filter((c) =>
|
|
c.partnerName.toLowerCase().includes(search.toLowerCase()) ||
|
|
c.lastMessage.toLowerCase().includes(search.toLowerCase()),
|
|
)
|
|
: convs;
|
|
|
|
// Zweite Stufe: User-Suche (nur wenn Suchbegriff ≥ 2 Zeichen)
|
|
const {
|
|
data: userResults = [],
|
|
isFetching: searchingUsers,
|
|
} = useQuery<{ id: string; nickname: string; avatar: string | null }[]>({
|
|
queryKey: ['user-search', debouncedSearch],
|
|
queryFn: () => apiFetch(`/api/users/search?q=${encodeURIComponent(debouncedSearch)}`),
|
|
enabled: debouncedSearch.length >= 2,
|
|
staleTime: 10_000,
|
|
});
|
|
|
|
// Bereits gechattet? User-Resultate ohne aktive Conversations zeigen
|
|
const existingPartnerIds = new Set(convs.map((c) => c.partnerId));
|
|
const newUsers = userResults.filter((u) => !existingPartnerIds.has(u.id));
|
|
|
|
const openDm = useCallback(
|
|
(userId: string) => {
|
|
router.push(`/dm?userId=${userId}`);
|
|
},
|
|
[router],
|
|
);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<AppHeader />
|
|
|
|
<View style={styles.headerSection}>
|
|
<View style={styles.searchRow}>
|
|
<Ionicons name="search-outline" size={16} color={colors.textMuted} style={styles.searchIcon} />
|
|
<TextInput
|
|
style={styles.searchInput}
|
|
value={search}
|
|
onChangeText={setSearch}
|
|
placeholder={t('chat.search_placeholder')}
|
|
placeholderTextColor={colors.textMuted}
|
|
returnKeyType="search"
|
|
autoCorrect={false}
|
|
autoCapitalize="none"
|
|
/>
|
|
{search.length > 0 && (
|
|
<TouchableOpacity onPress={() => setSearch('')} activeOpacity={0.7} hitSlop={8}>
|
|
<Ionicons name="close-circle" size={16} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
<FlatList
|
|
data={loadingDms ? [] : filtered}
|
|
keyExtractor={(item) => item.partnerId}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={userRefreshing}
|
|
onRefresh={handleRefresh}
|
|
tintColor={colors.brandOrange}
|
|
/>
|
|
}
|
|
ListEmptyComponent={
|
|
loadingDms ? (
|
|
<View style={styles.emptyBox}>
|
|
<ActivityIndicator color={colors.brandOrange} />
|
|
</View>
|
|
) : (
|
|
<View style={styles.emptyBox}>
|
|
<Ionicons name="chatbubble-ellipses-outline" size={42} color="#d4d4d4" />
|
|
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
|
|
</View>
|
|
)
|
|
}
|
|
renderItem={({ item }) => <DmItem conv={item} onPress={() => openDm(item.partnerId)} />}
|
|
ListFooterComponent={
|
|
debouncedSearch.length >= 2 ? (
|
|
<View style={{ paddingBottom: 100 }}>
|
|
<View style={styles.newConvHeader}>
|
|
<Text style={[styles.newConvLabel, { color: colors.textMuted }]}>
|
|
{t('chat.new_conversation')}
|
|
</Text>
|
|
{searchingUsers && <ActivityIndicator size="small" color={colors.brandOrange} />}
|
|
</View>
|
|
{newUsers.length === 0 && !searchingUsers ? (
|
|
<View style={styles.emptyBox}>
|
|
<Text style={styles.emptyText}>{t('chat.no_users_found')}</Text>
|
|
</View>
|
|
) : (
|
|
newUsers.map((u) => (
|
|
<TouchableOpacity
|
|
key={u.id}
|
|
onPress={() => openDm(u.id)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View style={styles.dmRow}>
|
|
<UserAvatar userId={u.id} avatar={u.avatar} nickname={u.nickname} size="md" />
|
|
<View style={styles.dmInfo}>
|
|
<Text style={styles.dmName} numberOfLines={1}>{u.nickname}</Text>
|
|
<Text style={[styles.dmLast, { fontFamily: 'Nunito_400Regular', color: colors.textMuted }]}>
|
|
{t('chat.start_conversation')}
|
|
</Text>
|
|
</View>
|
|
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
|
</View>
|
|
</TouchableOpacity>
|
|
))
|
|
)}
|
|
</View>
|
|
) : (
|
|
<View style={{ paddingBottom: 100 }} />
|
|
)
|
|
}
|
|
contentContainerStyle={{ flexGrow: 1 }}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
|
return StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: colors.bg },
|
|
headerSection: {
|
|
paddingHorizontal: 16,
|
|
paddingTop: 12,
|
|
paddingBottom: 10,
|
|
backgroundColor: colors.bg,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: colors.border,
|
|
},
|
|
searchRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderRadius: 999,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 8,
|
|
},
|
|
searchIcon: { marginRight: 8 },
|
|
searchInput: {
|
|
flex: 1,
|
|
fontSize: 14,
|
|
fontFamily: 'Nunito_500Medium',
|
|
color: colors.text,
|
|
paddingVertical: 0,
|
|
},
|
|
emptyBox: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: 40,
|
|
paddingHorizontal: 32,
|
|
},
|
|
emptyText: {
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: colors.textMuted,
|
|
marginTop: 12,
|
|
},
|
|
newConvHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
paddingHorizontal: 16,
|
|
paddingTop: 20,
|
|
paddingBottom: 8,
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
borderTopColor: colors.border,
|
|
},
|
|
newConvLabel: {
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_700Bold',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.5,
|
|
flex: 1,
|
|
},
|
|
dmRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
backgroundColor: colors.bg,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: colors.border,
|
|
minHeight: 68,
|
|
},
|
|
dmInfo: { flex: 1, minWidth: 0 },
|
|
dmHeaderRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
},
|
|
dmName: {
|
|
fontSize: 15,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: colors.text,
|
|
flexShrink: 1,
|
|
marginRight: 6,
|
|
},
|
|
dmTime: { fontSize: 11, fontFamily: 'Nunito_500Medium' },
|
|
dmBottomRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginTop: 2,
|
|
},
|
|
dmLast: { fontSize: 12, flex: 1 },
|
|
unreadBadge: {
|
|
minWidth: 20,
|
|
height: 20,
|
|
paddingHorizontal: 6,
|
|
borderRadius: 10,
|
|
backgroundColor: colors.brandOrange,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginLeft: 8,
|
|
},
|
|
unreadBadgeText: {
|
|
fontSize: 10,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: '#fff',
|
|
},
|
|
});
|
|
}
|