stadtwerke/innungsapp/apps/mobile/app/(app)/chat/[id].tsx

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',
},
})