411 lines
11 KiB
TypeScript
411 lines
11 KiB
TypeScript
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 (
|
|
<View style={[styles.messageBubbleContainer, isMe ? styles.messageBubbleRight : styles.messageBubbleLeft]}>
|
|
<View
|
|
style={[
|
|
styles.messageBubble,
|
|
isMe ? styles.messageBubbleMe : styles.messageBubbleOther
|
|
]}
|
|
>
|
|
<Text style={[styles.messageText, isMe ? styles.messageTextMe : styles.messageTextOther]}>
|
|
{msg.body}
|
|
</Text>
|
|
<Text
|
|
style={[styles.messageTime, isMe ? styles.messageTimeMe : styles.messageTimeOther]}
|
|
>
|
|
{format(new Date(msg.createdAt), 'HH:mm', { locale: de })}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
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<FlatList>(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 (
|
|
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
|
|
<KeyboardAvoidingView
|
|
style={styles.keyboardAvoid}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
|
<Ionicons name="chevron-back" size={26} color="#003B7E" />
|
|
</TouchableOpacity>
|
|
<View style={styles.headerInfo}>
|
|
<Text style={styles.headerName} numberOfLines={1}>
|
|
{name ?? 'Chat'}
|
|
</Text>
|
|
<Text style={styles.headerStatus}>Mitglied</Text>
|
|
</View>
|
|
</View>
|
|
<View style={styles.divider} />
|
|
|
|
{/* Messages */}
|
|
<View style={styles.content}>
|
|
{isLoading ? (
|
|
<View style={styles.centerCont}>
|
|
<ActivityIndicator size="large" color="#003B7E" />
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
ref={listRef}
|
|
data={messages}
|
|
keyExtractor={(m) => m.id}
|
|
inverted
|
|
maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 10 }}
|
|
onLayout={onListLayout}
|
|
style={{ opacity: listVisible ? 1 : 0 }}
|
|
contentContainerStyle={styles.messageList}
|
|
refreshControl={
|
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#003B7E" />
|
|
}
|
|
ListEmptyComponent={
|
|
<View style={[styles.emptyState, { transform: [{ scaleY: -1 }] }]}>
|
|
<Ionicons name="chatbubble-ellipses-outline" size={48} color="#CBD5E1" />
|
|
<Text style={styles.emptyText}>
|
|
Noch keine Nachrichten. Schreib die erste!
|
|
</Text>
|
|
<View style={styles.privacyNoteBoxCentered}>
|
|
<Ionicons name="shield-checkmark-outline" size={16} color="#2563EB" />
|
|
<Text style={styles.privacyNoteText}>
|
|
Nachrichten sind nur für euch beide sichtbar. Keine Weitergabe an Dritte (DSGVO-konform).
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
}
|
|
renderItem={({ item }) => (
|
|
<MessageBubble
|
|
msg={item as Message}
|
|
isMe={item.sender.id === myMemberId}
|
|
/>
|
|
)}
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
{/* Input */}
|
|
<View style={styles.inputContainer}>
|
|
<TextInput
|
|
style={styles.inputField}
|
|
placeholder="Nachricht …"
|
|
placeholderTextColor="#94A3B8"
|
|
value={text}
|
|
onChangeText={setText}
|
|
multiline
|
|
returnKeyType="default"
|
|
blurOnSubmit={false}
|
|
/>
|
|
<TouchableOpacity
|
|
onPress={handleSend}
|
|
disabled={!text.trim() || sendMutation.isPending}
|
|
style={[styles.sendButton, text.trim() ? styles.sendButtonActive : styles.sendButtonInactive]}
|
|
>
|
|
{sendMutation.isPending ? (
|
|
<ActivityIndicator size="small" color="#fff" />
|
|
) : (
|
|
<Ionicons name="send" size={18} color={text.trim() ? '#fff' : '#94A3B8'} />
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
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',
|
|
},
|
|
})
|