import { View, Text, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, StyleSheet, RefreshControl, } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' import { Ionicons } from '@expo/vector-icons' import { useState, useRef, useMemo, useCallback } from 'react' import { format } from 'date-fns' import { de } from 'date-fns/locale' import { trpc } from '@/lib/trpc' import { useAuthStore } from '@/store/auth.store' type Message = { id: string body: string createdAt: Date sender: { id: string; name: string; avatarUrl: string | null } } function MessageBubble({ msg, isMe }: { msg: Message; isMe: boolean }) { return ( {msg.body} {format(new Date(msg.createdAt), 'HH:mm', { locale: de })} ) } export default function ConversationScreen() { const { id, name } = useLocalSearchParams<{ id: string; name: string }>() const router = useRouter() const session = useAuthStore((s) => s.session) const [text, setText] = useState('') const listRef = useRef(null) // Hide list until inverted FlatList has settled at correct position (prevents initial jump) const [listVisible, setListVisible] = useState(false) const onListLayout = useCallback(() => setListVisible(true), []) const [refreshing, setRefreshing] = useState(false) const onRefresh = useCallback(async () => { setRefreshing(true) try { await refetch() } finally { setRefreshing(false) } }, []) const { data, isLoading, refetch, isRefetching } = trpc.messages.getMessages.useQuery( { conversationId: id }, { staleTime: 5_000, refetchInterval: 8_000, // Only re-render when message count or last message ID changes — not on every refetch select: (d) => d, } ) // Stable deps: only recalculate when actual message content changes, not on every refetch const msgCount = data?.messages?.length ?? 0 const lastId = data?.messages?.[msgCount - 1]?.id ?? '' const messages = useMemo(() => [...(data?.messages ?? [])].reverse(), [msgCount, lastId]) // Stable "me" id: derived from real (non-optimistic) messages only const myMemberId = useMemo( () => messages.find((m: Message) => !m.id.startsWith('opt-') && m.sender.name === session?.user?.name)?.sender.id, [messages, session?.user?.name] ) const utils = trpc.useUtils() const sendMutation = trpc.messages.sendMessage.useMutation({ onMutate: ({ conversationId, body }) => { // Optimistically insert message immediately — no waiting for server const optimisticMsg: Message = { id: `opt-${Date.now()}`, body, createdAt: new Date(), sender: { id: myMemberId ?? `opt-sender`, name: session?.user?.name ?? '', avatarUrl: null, }, } utils.messages.getMessages.setData( { conversationId }, (old) => { if (!old) return old // Append at end — messages are stored ascending, reversed only in useMemo for display return { ...old, messages: [...old.messages, optimisticMsg] } } ) setText('') return { optimisticId: optimisticMsg.id } }, onSuccess: (newMsg, { conversationId }, context) => { // Replace optimistic placeholder with real server message utils.messages.getMessages.setData( { conversationId }, (old) => { if (!old) return old return { ...old, messages: old.messages.map((m) => m.id === context?.optimisticId ? newMsg : m ), } } ) utils.messages.getConversations.invalidate() }, onError: (_, { conversationId }, context) => { // Roll back optimistic message on error utils.messages.getMessages.setData( { conversationId }, (old) => { if (!old) return old return { ...old, messages: old.messages.filter((m) => m.id !== context?.optimisticId), } } ) }, }) function handleSend() { const body = text.trim() if (!body) return sendMutation.mutate({ conversationId: id, body }) } return ( {/* Header */} router.back()} style={styles.backButton}> {name ?? 'Chat'} Mitglied {/* Messages */} {isLoading ? ( ) : ( m.id} inverted maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 10 }} onLayout={onListLayout} style={{ opacity: listVisible ? 1 : 0 }} contentContainerStyle={styles.messageList} refreshControl={ } ListEmptyComponent={ Noch keine Nachrichten. Schreib die erste! Nachrichten sind nur für euch beide sichtbar. Keine Weitergabe an Dritte (DSGVO-konform). } renderItem={({ item }) => ( )} /> )} {/* Input */} {sendMutation.isPending ? ( ) : ( )} ) } const styles = StyleSheet.create({ safeArea: { flex: 1, backgroundColor: '#FFFFFF', }, keyboardAvoid: { flex: 1, backgroundColor: '#F8FAFC', }, header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, backgroundColor: '#FFFFFF', }, backButton: { padding: 4, marginRight: 8, }, headerInfo: { flex: 1, }, headerName: { fontSize: 18, fontWeight: '700', color: '#0F172A', }, headerStatus: { fontSize: 12, color: '#64748B', marginTop: 2, }, divider: { height: 1, backgroundColor: '#E2E8F0', }, content: { flex: 1, }, centerCont: { flex: 1, alignItems: 'center', justifyContent: 'center', }, messageList: { paddingTop: 16, paddingBottom: 8, }, emptyState: { flex: 1, alignItems: 'flex-start', justifyContent: 'flex-start', paddingVertical: 16, paddingHorizontal: 16, }, emptyText: { color: '#0F172A', marginTop: 12, textAlign: 'left', fontSize: 16, }, privacyNoteBoxCentered: { flexDirection: 'row', alignItems: 'flex-start', padding: 0, marginTop: 16, gap: 8, }, privacyNoteText: { fontSize: 14, color: '#0F172A', lineHeight: 20, flex: 1, }, messageBubbleContainer: { flexDirection: 'row', marginBottom: 8, paddingHorizontal: 16, }, messageBubbleRight: { justifyContent: 'flex-end', }, messageBubbleLeft: { justifyContent: 'flex-start', }, messageBubble: { maxWidth: '78%', paddingHorizontal: 16, paddingVertical: 10, }, messageBubbleMe: { backgroundColor: '#2563EB', borderTopLeftRadius: 16, borderTopRightRadius: 16, borderBottomLeftRadius: 16, borderBottomRightRadius: 4, }, messageBubbleOther: { backgroundColor: '#FFFFFF', borderTopLeftRadius: 16, borderTopRightRadius: 16, borderBottomRightRadius: 16, borderBottomLeftRadius: 4, borderWidth: 1, borderColor: '#E2E8F0', }, messageText: { fontSize: 15, lineHeight: 20, }, messageTextMe: { color: '#FFFFFF', }, messageTextOther: { color: '#0F172A', }, messageTime: { fontSize: 10, marginTop: 4, }, messageTimeMe: { color: '#BFDBFE', textAlign: 'right', }, messageTimeOther: { color: '#94A3B8', textAlign: 'left', }, inputContainer: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 16, paddingVertical: 12, borderTopWidth: 1, borderTopColor: '#E2E8F0', backgroundColor: '#FFFFFF', gap: 8, }, inputField: { flex: 1, backgroundColor: '#F1F5F9', borderRadius: 20, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 12, fontSize: 15, color: '#0F172A', maxHeight: 120, minHeight: 44, }, sendButton: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', }, sendButtonActive: { backgroundColor: '#2563EB', }, sendButtonInactive: { backgroundColor: '#E2E8F0', }, })