366 lines
11 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from 'react';
import {
View,
Text,
FlatList,
Pressable,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
Image,
StyleSheet,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../lib/api';
import { supabase } from '../lib/supabase';
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
import { useDmRealtime } from '../hooks/useChatRealtime';
import { colors } from '../lib/theme';
type DmHistoryResponse = {
partner: {
id: string;
nickname: string;
username?: string;
avatar?: string | null;
};
messages: Array<{
id: string;
content: string;
createdAt: string;
isOwn: boolean;
readAt: string | null;
senderId?: string;
receiverId?: string;
likesCount?: number;
likedByMe?: boolean;
attachmentUrl?: string | null;
attachmentType?: string | null;
attachmentName?: string | null;
replyTo?: any;
}>;
};
const GROUP_GAP_MS = 5 * 60 * 1000;
export default function DmScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
const flatRef = useRef<FlatList>(null);
const [myUserId, setMyUserId] = useState<string | undefined>(undefined);
const { userId } = useLocalSearchParams<{ userId: string }>();
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
null,
);
const [sending, setSending] = useState(false);
// Lade meine User-ID
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setMyUserId(data.session?.user.id);
});
}, []);
// Lade DM-History
const { isLoading } = useQuery({
queryKey: ['dm-history', userId],
queryFn: async () => {
console.log('[dm] fetching history for partner', userId, 'me', myUserId);
try {
const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length);
setPartner(data.partner);
const msgs: ChatMsg[] = data.messages.map((m: any) => ({
id: m.id,
userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId),
nickname: m.isOwn ? 'Du' : data.partner?.nickname ?? '?',
avatar: m.isOwn ? null : data.partner?.avatar ?? null,
content: m.content,
replyTo: m.replyTo
? {
id: m.replyTo.id,
userId: m.replyTo.senderId,
nickname:
m.replyTo.senderId === myUserId ? 'Du' : data.partner?.nickname ?? '?',
content: m.replyTo.content?.slice(0, 100) ?? '',
attachmentType: m.replyTo.attachmentType ?? null,
}
: null,
attachmentUrl: m.attachmentUrl ?? null,
attachmentType: m.attachmentType ?? null,
attachmentName: m.attachmentName ?? null,
likesCount: m.likesCount ?? 0,
likedByMe: m.likedByMe ?? false,
createdAt: m.createdAt,
isOwn: m.isOwn,
readAt: m.readAt,
}));
setMessages(msgs);
return data;
} catch (err: any) {
console.error('[dm] history fetch failed:', err?.message ?? err);
throw err;
}
},
enabled: !!userId && !!myUserId,
});
// Realtime: neue DMs vom Partner
const onDmInsert = useCallback(
(row: any) => {
if (row.receiver_id !== myUserId) return;
setMessages((prev) => {
if (prev.some((m) => m.id === row.id)) return prev;
return [
...prev,
{
id: row.id,
userId: row.sender_id,
nickname: partner?.nickname ?? '?',
avatar: partner?.avatar ?? null,
content: row.content ?? '',
replyTo: null,
attachmentUrl: row.attachment_url ?? null,
attachmentType: row.attachment_type ?? null,
attachmentName: row.attachment_name ?? null,
likesCount: row.likes_count ?? 0,
likedByMe: false,
createdAt: row.created_at,
isOwn: false,
readAt: null,
},
];
});
},
[myUserId, partner],
);
useDmRealtime(userId, onDmInsert, !!myUserId);
// Auto-Scroll bei neuen Messages
useEffect(() => {
if (messages.length > 0) {
requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true }));
}
}, [messages.length]);
async function handleSend(payload: SendPayload) {
if (sending) return;
setSending(true);
try {
const newMsg = await apiFetch<any>('/api/chat/dm', {
method: 'POST',
body: { receiverId: userId, ...payload },
});
setMessages((prev) => [
...prev,
{
id: newMsg.id,
userId: myUserId ?? '',
nickname: 'Du',
avatar: null,
content: newMsg.content,
replyTo: newMsg.replyTo
? {
id: newMsg.replyTo.id,
userId: newMsg.replyTo.senderId,
nickname:
newMsg.replyTo.senderId === myUserId ? 'Du' : partner?.nickname ?? '?',
content: newMsg.replyTo.content?.slice(0, 100) ?? '',
attachmentType: newMsg.replyTo.attachmentType ?? null,
}
: null,
attachmentUrl: newMsg.attachmentUrl,
attachmentType: newMsg.attachmentType,
attachmentName: newMsg.attachmentName,
likesCount: newMsg.likesCount ?? 0,
likedByMe: false,
createdAt: newMsg.createdAt,
isOwn: true,
readAt: null,
},
]);
setReplyTo(null);
queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
} catch (err) {
console.error('DM send failed:', err);
} finally {
setSending(false);
}
}
async function toggleLike(msg: ChatMsg) {
try {
const { liked } = await apiFetch<{ liked: boolean }>('/api/chat/like', {
method: 'POST',
body: { messageId: msg.id, type: 'dm' },
});
setMessages((prev) =>
prev.map((m) =>
m.id === msg.id
? { ...m, likedByMe: liked, likesCount: m.likesCount + (liked ? 1 : -1) }
: m,
),
);
} catch {}
}
function startReply(msg: ChatMsg) {
setReplyTo({
id: msg.id,
nickname: msg.nickname ?? '?',
content: msg.content?.slice(0, 100) || (msg.attachmentType === 'image' ? 'Bild' : ''),
});
}
function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean {
if (!a || !b) return false;
if (a.userId !== b.userId) return false;
return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS;
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.backBtn} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
</Pressable>
<View style={styles.headerCenter}>
<View style={styles.headerAvatar}>
{partner?.avatar ? (
<Image source={{ uri: partner.avatar }} style={styles.headerAvatarImg} />
) : (
<Text style={styles.headerAvatarInitials}>
{(partner?.nickname ?? '?').slice(0, 2).toUpperCase()}
</Text>
)}
</View>
<Text style={styles.headerName} numberOfLines={1}>
{partner?.nickname ?? '…'}
</Text>
</View>
<View style={{ width: 36 }} />
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={0}
>
{isLoading && messages.length === 0 ? (
<View style={styles.loadingBox}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : messages.length === 0 ? (
<View style={styles.loadingBox}>
<Ionicons name="chatbubble-outline" size={42} color="#d4d4d4" />
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
</View>
) : (
<FlatList
ref={flatRef}
data={messages}
style={{ flex: 1 }}
renderItem={({ item, index }) => (
<ChatBubble
msg={item}
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
isLastInGroup={!sameAuthor(item, messages[index + 1])}
onReply={startReply}
onLike={toggleLike}
onOpenImage={() => {}}
/>
)}
keyExtractor={(m) => m.id}
contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
/>
)}
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
<ChatInput
replyTo={replyTo}
sending={sending}
onSend={handleSend}
onCancelReply={() => setReplyTo(null)}
/>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fafafa' },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e5e5',
},
backBtn: {
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: '#f5f5f5',
alignItems: 'center',
justifyContent: 'center',
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 8,
},
headerAvatar: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#e5e5e5',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 8,
},
headerAvatarImg: { width: 32, height: 32 },
headerAvatarInitials: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
},
headerName: {
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#171717',
flexShrink: 1,
},
loadingBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
emptyText: {
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#a3a3a3',
marginTop: 12,
},
});