402 lines
11 KiB
TypeScript
402 lines
11 KiB
TypeScript
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,
|
|
},
|
|
})
|