push
This commit is contained in:
410
innungsapp/apps/mobile/app/(app)/chat/[id].tsx
Normal file
410
innungsapp/apps/mobile/app/(app)/chat/[id].tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
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',
|
||||
},
|
||||
})
|
||||
401
innungsapp/apps/mobile/app/(app)/chat/index.tsx
Normal file
401
innungsapp/apps/mobile/app/(app)/chat/index.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import { View, Text, SectionList, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, RefreshControl, TextInput } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { format, isToday, isYesterday } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { EmptyState } from '@/components/ui/EmptyState'
|
||||
import { Avatar } from '@/components/ui/Avatar'
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
|
||||
function SkeletonRow() {
|
||||
const anim = useRef(new Animated.Value(0.4)).current
|
||||
useEffect(() => {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(anim, { toValue: 1, duration: 800, useNativeDriver: true }),
|
||||
Animated.timing(anim, { toValue: 0.4, duration: 800, useNativeDriver: true }),
|
||||
])
|
||||
).start()
|
||||
}, [])
|
||||
return (
|
||||
<Animated.View style={[skeletonStyles.row, { opacity: anim }]}>
|
||||
<View style={skeletonStyles.avatar} />
|
||||
<View style={skeletonStyles.lines}>
|
||||
<View style={skeletonStyles.lineLong} />
|
||||
<View style={skeletonStyles.lineShort} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const skeletonStyles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingVertical: 16, backgroundColor: '#FFFFFF', borderBottomWidth: 1, borderBottomColor: '#F1F5F9' },
|
||||
avatar: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#E2E8F0' },
|
||||
lines: { flex: 1, marginLeft: 12, gap: 8 },
|
||||
lineLong: { height: 14, borderRadius: 7, backgroundColor: '#E2E8F0', width: '60%' },
|
||||
lineShort: { height: 12, borderRadius: 6, backgroundColor: '#F1F5F9', width: '80%' },
|
||||
})
|
||||
|
||||
function formatTime(date: Date) {
|
||||
if (isToday(date)) return format(date, 'HH:mm')
|
||||
if (isYesterday(date)) return 'Gestern'
|
||||
return format(date, 'dd.MM.yy', { locale: de })
|
||||
}
|
||||
|
||||
export default function ChatListScreen() {
|
||||
const router = useRouter()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const [showSkeleton, setShowSkeleton] = useState(true)
|
||||
const { data: chats, isLoading, refetch: refetchChats, isRefetching } = trpc.messages.getConversations.useQuery(undefined, {
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 8_000,
|
||||
})
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setShowSkeleton(true)
|
||||
const t = setTimeout(() => setShowSkeleton(false), 800)
|
||||
return () => clearTimeout(t)
|
||||
}, [])
|
||||
)
|
||||
|
||||
const { data: members, isFetching: isFetchingMembers } = trpc.members.list.useQuery(
|
||||
{ search: searchQuery },
|
||||
{ enabled: searchQuery.length > 0, staleTime: 30_000 }
|
||||
)
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const refetch = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await refetchChats()
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [refetchChats])
|
||||
|
||||
const filteredChats = (chats || []).filter(c => {
|
||||
if (!searchQuery) return true
|
||||
const q = searchQuery.toLowerCase()
|
||||
return (
|
||||
c.other?.name?.toLowerCase().includes(q) ||
|
||||
c.other?.betrieb?.toLowerCase().includes(q) ||
|
||||
c.lastMessage?.body?.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const sections = []
|
||||
|
||||
if (filteredChats.length > 0 || !searchQuery) {
|
||||
sections.push({
|
||||
title: searchQuery ? 'Bestehende Chats' : '',
|
||||
data: filteredChats,
|
||||
type: 'chat'
|
||||
})
|
||||
}
|
||||
|
||||
if (searchQuery.length > 0) {
|
||||
const chatMemberIds = new Set((chats || []).map(c => c.other?.id).filter(Boolean))
|
||||
const freshMembers = (members || []).filter(m => !chatMemberIds.has(m.id))
|
||||
if (freshMembers.length > 0) {
|
||||
sections.push({
|
||||
title: 'Weitere Mitglieder',
|
||||
data: freshMembers,
|
||||
type: 'member'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const renderSectionHeader = ({ section }: { section: any }) => {
|
||||
if (!section.title) return null
|
||||
return (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const renderItem = ({ item, section }: { item: any, section: any }) => {
|
||||
if (section.type === 'chat') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/(app)/chat/[id]',
|
||||
params: {
|
||||
id: item.conversationId,
|
||||
name: item.other?.name ?? 'Unbekannt',
|
||||
},
|
||||
})
|
||||
}
|
||||
activeOpacity={0.7}
|
||||
style={styles.chatRow}
|
||||
>
|
||||
<View style={styles.avatarContainer}>
|
||||
<Avatar name={item.other?.name ?? '?'} size={48} />
|
||||
{item.hasUnread && (
|
||||
<View style={styles.unreadDot} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.chatInfo}>
|
||||
<View style={styles.chatHeader}>
|
||||
<Text
|
||||
style={[styles.chatName, item.hasUnread && styles.chatNameUnread]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.other?.name ?? 'Unbekannt'}
|
||||
</Text>
|
||||
{item.lastMessage && (
|
||||
<Text style={styles.timeText}>
|
||||
{formatTime(new Date(item.lastMessage.createdAt))}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.messageText, item.hasUnread && styles.messageTextUnread]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.lastMessage?.body ?? 'Noch keine Nachrichten'}
|
||||
</Text>
|
||||
{item.other?.betrieb && (
|
||||
<Text style={styles.companyText} numberOfLines={1}>
|
||||
{item.other.betrieb}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: '/(app)/members/[id]',
|
||||
params: { id: item.id },
|
||||
})
|
||||
}
|
||||
activeOpacity={0.7}
|
||||
style={styles.chatRow}
|
||||
>
|
||||
<Avatar name={item.name} size={48} />
|
||||
<View style={styles.chatInfo}>
|
||||
<Text style={styles.chatName} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.betrieb && (
|
||||
<Text style={styles.companyText} numberOfLines={1}>
|
||||
{item.betrieb}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.messageText} numberOfLines={1}>
|
||||
{item.sparte} • {item.ort}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.screenTitle}>Nachrichten</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/(app)/members')}
|
||||
style={styles.newChatBtn}
|
||||
>
|
||||
<Ionicons name="create-outline" size={19} color="#003B7E" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Search Bar */}
|
||||
<View style={styles.searchContainer}>
|
||||
<Ionicons name="search" size={20} color="#94A3B8" />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Suchen nach Namen, Betrieb oder Nachricht..."
|
||||
placeholderTextColor="#94A3B8"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{showSkeleton ? (
|
||||
<View>
|
||||
{[1, 2, 3, 4, 5].map((i) => <SkeletonRow key={i} />)}
|
||||
</View>
|
||||
) : (!chats || chats.length === 0) && !searchQuery ? (
|
||||
<EmptyState
|
||||
icon="chatbubbles-outline"
|
||||
title="Noch keine Nachrichten"
|
||||
subtitle="Öffne ein Mitgliedsprofil und schreib eine Nachricht — datenschutzkonform ohne private Nummern."
|
||||
/>
|
||||
) : searchQuery.length > 0 && isFetchingMembers && sections.length === 0 ? (
|
||||
<View style={{ alignItems: 'center', paddingTop: 40 }}>
|
||||
<ActivityIndicator color="#003B7E" />
|
||||
</View>
|
||||
) : sections.length === 0 && searchQuery ? (
|
||||
<EmptyState
|
||||
icon="search-outline"
|
||||
title="Keine Ergebnisse"
|
||||
subtitle={`Kein Mitglied gefunden für „${searchQuery}".`}
|
||||
/>
|
||||
) : (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item, index) => item.conversationId || item.id || String(index)}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={5}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={refetch} tintColor="#003B7E" progressViewOffset={50} />
|
||||
}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={renderItem}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
header: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
},
|
||||
screenTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
newChatBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#EFF6FF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F1F5F9',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
height: 44,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
marginLeft: 8,
|
||||
fontSize: 15,
|
||||
color: '#0F172A',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#E2E8F0',
|
||||
},
|
||||
list: {
|
||||
paddingBottom: 30,
|
||||
},
|
||||
sectionHeader: {
|
||||
backgroundColor: '#F8FAFC',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F1F5F9',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
color: '#64748B',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
chatRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F1F5F9',
|
||||
},
|
||||
avatarContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
unreadDot: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#2563EB',
|
||||
borderWidth: 2,
|
||||
borderColor: '#FFFFFF',
|
||||
},
|
||||
chatInfo: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
chatHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
chatName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1E293B',
|
||||
flex: 1,
|
||||
},
|
||||
chatNameUnread: {
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
},
|
||||
timeText: {
|
||||
fontSize: 12,
|
||||
color: '#94A3B8',
|
||||
marginLeft: 8,
|
||||
},
|
||||
messageText: {
|
||||
fontSize: 14,
|
||||
color: '#94A3B8',
|
||||
marginTop: 2,
|
||||
},
|
||||
messageTextUnread: {
|
||||
fontWeight: '500',
|
||||
color: '#334155',
|
||||
},
|
||||
companyText: {
|
||||
fontSize: 12,
|
||||
color: '#94A3B8',
|
||||
marginTop: 2,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user