411 lines
11 KiB
TypeScript

import { useState, useCallback } from 'react';
import {
View,
Text,
FlatList,
Pressable,
ActivityIndicator,
Image,
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 { RoomCard, type Room } from '../../components/chat/RoomCard';
import { CreateRoomSheet } from '../../components/chat/CreateRoomSheet';
import { colors } from '../../lib/theme';
type DmConversation = {
partnerId: string;
partnerName: string;
partnerAvatar: string | null;
lastMessage: string;
lastMessageAt: string;
unreadCount: number;
isOwn: boolean;
};
function formatTime(ts: string, justNowLabel: string): string {
const diff = Date.now() - new Date(ts).getTime();
if (diff < 60_000) return justNowLabel;
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`;
return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }) {
const { t } = useTranslation();
const hasUnread = conv.unreadCount > 0;
return (
<Pressable onPress={onPress} android_ripple={{ color: '#f5f5f5' }}>
{({ pressed }) => (
<View style={[styles.dmRow, { opacity: pressed ? 0.75 : 1 }]}>
<View style={styles.dmAvatar}>
{conv.partnerAvatar ? (
<Image source={{ uri: conv.partnerAvatar }} style={styles.dmAvatarImg} />
) : (
<Text style={styles.dmAvatarInitials}>
{conv.partnerName.slice(0, 2).toUpperCase()}
</Text>
)}
</View>
<View style={styles.dmInfo}>
<View style={styles.dmHeaderRow}>
<Text style={styles.dmName} numberOfLines={1}>
{conv.partnerName}
</Text>
<Text
style={[styles.dmTime, { color: hasUnread ? '#007AFF' : '#a3a3a3' }]}
>
{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 ? '#171717' : '#a3a3a3',
},
]}
>
{conv.isOwn ? t('chat.you') : ''}
{conv.lastMessage}
</Text>
{hasUnread && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>{conv.unreadCount}</Text>
</View>
)}
</View>
</View>
</View>
)}
</Pressable>
);
}
export default function ChatScreen() {
const { t } = useTranslation();
const router = useRouter();
const [tab, setTab] = useState<'groups' | 'direct'>('groups');
const [createOpen, setCreateOpen] = useState(false);
const {
data: rooms = [],
isLoading: loadingRooms,
isRefetching: refetchingRooms,
refetch: refetchRooms,
} = useQuery<Room[]>({
queryKey: ['chat-rooms'],
queryFn: () => apiFetch('/api/chat/rooms'),
staleTime: 30_000,
});
const {
data: convs = [],
isLoading: loadingDms,
isRefetching: refetchingDms,
refetch: refetchDms,
} = useQuery<DmConversation[]>({
queryKey: ['dm-conversations'],
queryFn: () => apiFetch('/api/chat/dm-conversations'),
staleTime: 30_000,
enabled: tab === 'direct',
});
const unreadDms = convs.reduce((s, c) => s + (c.unreadCount ?? 0), 0);
const openRoom = useCallback(
(roomId: string) => {
router.push(`/room?roomId=${roomId}`);
},
[router],
);
const openDm = useCallback(
(userId: string) => {
router.push(`/dm?userId=${userId}`);
},
[router],
);
return (
<View style={styles.container}>
<AppHeader />
{/* Header */}
<View style={styles.headerSection}>
<View style={styles.titleRow}>
<Text style={styles.title}>{t('chat.title')}</Text>
{tab === 'groups' && (
<Pressable
onPress={() => setCreateOpen(true)}
style={({ pressed }) => [styles.createBtn, { opacity: pressed ? 0.7 : 1 }]}
>
<Ionicons name="add" size={20} color="#fff" />
</Pressable>
)}
</View>
{/* Tabs */}
<View style={styles.tabs}>
<Pressable
onPress={() => setTab('groups')}
style={[styles.tab, tab === 'groups' && styles.tabActive]}
>
<Ionicons
name="people"
size={14}
color={tab === 'groups' ? '#007AFF' : '#737373'}
/>
<Text style={[styles.tabText, tab === 'groups' && styles.tabTextActive]}>
{t('chat.groups')}
</Text>
</Pressable>
<Pressable
onPress={() => setTab('direct')}
style={[styles.tab, tab === 'direct' && styles.tabActive]}
>
<Ionicons
name="chatbubbles"
size={14}
color={tab === 'direct' ? '#007AFF' : '#737373'}
/>
<Text style={[styles.tabText, tab === 'direct' && styles.tabTextActive]}>
{t('chat.direct')}
</Text>
{unreadDms > 0 && (
<View style={styles.tabBadge}>
<Text style={styles.tabBadgeText}>{unreadDms}</Text>
</View>
)}
</Pressable>
</View>
</View>
{tab === 'groups' ? (
<FlatList
data={loadingRooms ? [] : rooms}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl
refreshing={refetchingRooms}
onRefresh={refetchRooms}
tintColor={colors.brandOrange}
/>
}
ListEmptyComponent={
loadingRooms ? (
<View style={styles.emptyBox}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : (
<View style={styles.emptyBox}>
<Ionicons name="people-outline" size={42} color="#d4d4d4" />
<Text style={styles.emptyText}>{t('chat.no_rooms')}</Text>
</View>
)
}
renderItem={({ item }) => <RoomCard room={item} onPress={() => openRoom(item.id)} />}
contentContainerStyle={{ paddingBottom: 100 }}
/>
) : (
<FlatList
data={loadingDms ? [] : convs}
keyExtractor={(item) => item.partnerId}
refreshControl={
<RefreshControl
refreshing={refetchingDms}
onRefresh={refetchDms}
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)} />}
contentContainerStyle={{ paddingBottom: 100 }}
/>
)}
<CreateRoomSheet
visible={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={(room) => {
refetchRooms();
openRoom(room.id);
}}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fafafa' },
headerSection: {
paddingHorizontal: 16,
paddingTop: 14,
paddingBottom: 10,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e5e5',
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
fontSize: 22,
fontFamily: 'Nunito_800ExtraBold',
color: '#171717',
},
createBtn: {
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: '#007AFF',
alignItems: 'center',
justifyContent: 'center',
},
tabs: {
flexDirection: 'row',
marginTop: 12,
backgroundColor: '#f5f5f5',
borderRadius: 10,
padding: 3,
},
tab: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 8,
},
tabActive: {
backgroundColor: '#fff',
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 2,
shadowOffset: { width: 0, height: 1 },
},
tabText: {
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#737373',
marginLeft: 5,
},
tabTextActive: {
color: '#007AFF',
fontFamily: 'Nunito_700Bold',
},
tabBadge: {
minWidth: 16,
height: 16,
borderRadius: 8,
backgroundColor: '#007AFF',
paddingHorizontal: 4,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 5,
},
tabBadgeText: {
fontSize: 9,
fontFamily: 'Nunito_700Bold',
color: '#fff',
},
emptyBox: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
paddingHorizontal: 32,
},
emptyText: {
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#a3a3a3',
marginTop: 12,
},
// DM row styles
dmRow: {
width: '100%',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 11,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#f5f5f5',
},
dmAvatar: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: '#e5e5e5',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 10,
},
dmAvatarImg: { width: 42, height: 42 },
dmAvatarInitials: {
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: '#525252',
},
dmInfo: { flex: 1, minWidth: 0 },
dmHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
dmName: {
fontSize: 14,
fontFamily: 'Nunito_700Bold',
color: '#171717',
flexShrink: 1,
marginRight: 6,
},
dmTime: { fontSize: 11, fontFamily: 'Nunito_600SemiBold' },
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: '#007AFF',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 8,
},
unreadBadgeText: {
fontSize: 10,
fontFamily: 'Nunito_700Bold',
color: '#fff',
},
});