stadtwerke/innungsapp/apps/mobile/app/(app)/chat/index.tsx

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