| import React, { useState, useEffect, useCallback, useRef } from 'react'; |
| import { View, StyleSheet, FlatList, KeyboardAvoidingView, Platform } from 'react-native'; |
| import { |
| Appbar, |
| Text, |
| Surface, |
| IconButton, |
| Avatar, |
| Menu, |
| Divider, |
| TextInput, |
| ProgressBar, |
| useTheme, |
| } from 'react-native-paper'; |
| import { SafeAreaView } from 'react-native-safe-area-context'; |
| import { useRoute, useNavigation } from '@react-navigation/native'; |
| import type { RouteProp } from '@react-navigation/native'; |
| import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; |
| import type { ChatStackParamList } from '../../navigation/types'; |
| import { useConversations } from '../../hooks/useConversations'; |
| import { useAuthStore } from '../../store/useAuthStore'; |
| import { formatChatTimestamp } from '../../utils/dateFormat'; |
| import { customTheme } from '../../theme'; |
|
|
| type RouteProps = RouteProp<ChatStackParamList, 'ChatDetail'>; |
| type NavigationProp = NativeStackNavigationProp<ChatStackParamList, 'ChatDetail'>; |
|
|
| |
|
|
| interface MessageBubbleProps { |
| message: { |
| id: string; |
| conversationId: string; |
| senderId: string; |
| content: string; |
| type: string; |
| status: string; |
| createdAt: string | null; |
| }; |
| currentUserId: string; |
| otherName: string; |
| } |
|
|
| function MessageBubble({ message, currentUserId, otherName }: MessageBubbleProps) { |
| const theme = useTheme(); |
| const isMe = message.senderId === currentUserId; |
| const isSystem = message.type === 'system'; |
|
|
| if (isSystem) { |
| return ( |
| <View style={styles.systemMessageContainer}> |
| <Text variant="labelSmall" style={{ color: theme.colors.outline }}> |
| {message.content} |
| </Text> |
| </View> |
| ); |
| } |
|
|
| return ( |
| <View |
| style={[ |
| styles.bubbleRow, |
| isMe ? styles.bubbleRowRight : styles.bubbleRowLeft, |
| ]} |
| > |
| {!isMe && ( |
| <Avatar.Text |
| size={32} |
| label={otherName.charAt(0).toUpperCase()} |
| style={[styles.bubbleAvatar, { backgroundColor: customTheme.colors.brand.primary }]} |
| /> |
| )} |
| <Surface |
| style={[ |
| styles.bubble, |
| isMe |
| ? { backgroundColor: customTheme.colors.brand.primary } |
| : { backgroundColor: theme.colors.surfaceVariant }, |
| ]} |
| elevation={0} |
| > |
| <Text |
| variant="bodyMedium" |
| style={[ |
| styles.bubbleText, |
| isMe && { color: '#FFFFFF' }, |
| ]} |
| > |
| {message.content} |
| </Text> |
| <View style={[styles.bubbleFooter, isMe && styles.bubbleFooterRight]}> |
| <Text |
| variant="labelSmall" |
| style={[ |
| styles.bubbleTime, |
| isMe && { color: 'rgba(255,255,255,0.7)' }, |
| ]} |
| > |
| {message.createdAt ? formatChatTimestamp(message.createdAt) : ''} |
| </Text> |
| {isMe && ( |
| <Text |
| variant="labelSmall" |
| style={{ color: 'rgba(255,255,255,0.7)' }} |
| > |
| {message.status === 'read' ? '\u2713\u2713' : message.status === 'delivered' ? '\u2713\u2713' : '\u2713'} |
| </Text> |
| )} |
| </View> |
| </Surface> |
| </View> |
| ); |
| } |
|
|
| |
|
|
| interface ChatInputProps { |
| onSend: (text: string) => void; |
| } |
|
|
| function ChatInput({ onSend }: ChatInputProps) { |
| const [text, setText] = useState(''); |
|
|
| const handleSend = useCallback(() => { |
| const trimmed = text.trim(); |
| if (trimmed) { |
| onSend(trimmed); |
| setText(''); |
| } |
| }, [text, onSend]); |
|
|
| return ( |
| <Surface style={styles.inputContainer} elevation={2}> |
| <TextInput |
| mode="flat" |
| placeholder="Type a message..." |
| value={text} |
| onChangeText={setText} |
| multiline |
| maxLength={1000} |
| style={styles.input} |
| contentStyle={styles.inputContent} |
| underlineColor="transparent" |
| activeUnderlineColor="transparent" |
| /> |
| <IconButton |
| icon="send" |
| size={24} |
| onPress={handleSend} |
| disabled={!text.trim()} |
| iconColor={ |
| text.trim() |
| ? customTheme.colors.brand.primary |
| : customTheme.colors.brand.surface |
| } |
| style={styles.sendButton} |
| /> |
| </Surface> |
| ); |
| } |
|
|
| |
|
|
| export default function ChatDetailScreen() { |
| const route = useRoute<RouteProps>(); |
| const navigation = useNavigation<NavigationProp>(); |
| const theme = useTheme(); |
| const { conversationId } = route.params; |
| const userId = useAuthStore((s) => s.user?.id ?? ''); |
|
|
| const { |
| messages, |
| loadingMessages, |
| loadMessages, |
| sendMessage, |
| markAsRead, |
| } = useConversations(userId); |
|
|
| const [menuVisible, setMenuVisible] = useState(false); |
| const flatListRef = useRef<FlatList>(null); |
|
|
| |
|
|
| useEffect(() => { |
| loadMessages(conversationId); |
| markAsRead(conversationId); |
| }, [conversationId, loadMessages, markAsRead]); |
|
|
| |
|
|
| useEffect(() => { |
| if (messages.length > 0) { |
| setTimeout(() => { |
| flatListRef.current?.scrollToEnd({ animated: true }); |
| }, 100); |
| } |
| }, [messages.length]); |
|
|
| |
|
|
| const handleSend = useCallback( |
| async (text: string) => { |
| await sendMessage(conversationId, userId, text, 'text'); |
| }, |
| [conversationId, userId, sendMessage], |
| ); |
|
|
| |
|
|
| const otherId = `User`; |
|
|
| |
|
|
| const renderItem = ({ item }: { item: typeof messages[number] }) => ( |
| <MessageBubble message={item} currentUserId={userId} otherName={otherId} /> |
| ); |
|
|
| return ( |
| <SafeAreaView style={styles.container} edges={['top']} mode="padding"> |
| {/* Appbar */} |
| <Appbar.Header> |
| <Appbar.BackAction onPress={navigation.goBack} /> |
| <Avatar.Text |
| size={32} |
| label={otherId.charAt(0)} |
| style={{ backgroundColor: customTheme.colors.brand.primary, marginRight: 8 }} |
| /> |
| <Appbar.Content title={otherId} subtitle="Online" /> |
| <Appbar.Action icon="phone-outline" onPress={() => {}} /> |
| <Menu |
| visible={menuVisible} |
| onDismiss={() => setMenuVisible(false)} |
| anchor={ |
| <Appbar.Action |
| icon="dots-vertical" |
| onPress={() => setMenuVisible(true)} |
| /> |
| } |
| > |
| <Menu.Item onPress={() => { setMenuVisible(false); }} title="View Profile" leadingIcon="account" /> |
| <Menu.Item onPress={() => { setMenuVisible(false); }} title="Share Location" leadingIcon="map-marker" /> |
| <Menu.Item onPress={() => { setMenuVisible(false); }} title="Block" leadingIcon="block-helper" /> |
| </Menu> |
| </Appbar.Header> |
| |
| {/* Messages */} |
| {loadingMessages && messages.length === 0 ? ( |
| <View style={styles.center}> |
| <ProgressBar indeterminate visible /> |
| </View> |
| ) : ( |
| <FlatList |
| ref={flatListRef} |
| data={messages} |
| keyExtractor={(item) => item.id} |
| renderItem={renderItem} |
| contentContainerStyle={styles.messagesList} |
| showsVerticalScrollIndicator={false} |
| keyboardShouldPersistTaps="handled" |
| /> |
| )} |
| |
| {/* Input */} |
| <KeyboardAvoidingView |
| behavior={Platform.OS === 'ios' ? 'padding' : undefined} |
| keyboardVerticalOffset={0} |
| > |
| <ChatInput onSend={handleSend} /> |
| </KeyboardAvoidingView> |
| </SafeAreaView> |
| ); |
| } |
|
|
| |
|
|
| const styles = StyleSheet.create({ |
| container: { |
| flex: 1, |
| backgroundColor: customTheme.colors.brand.background, |
| }, |
| center: { |
| flex: 1, |
| justifyContent: 'center', |
| alignItems: 'center', |
| }, |
| messagesList: { |
| paddingHorizontal: 12, |
| paddingVertical: 8, |
| paddingBottom: 8, |
| }, |
| bubbleRow: { |
| flexDirection: 'row', |
| alignItems: 'flex-end', |
| marginBottom: 8, |
| maxWidth: '80%', |
| }, |
| bubbleRowLeft: { |
| alignSelf: 'flex-start', |
| }, |
| bubbleRowRight: { |
| alignSelf: 'flex-end', |
| }, |
| bubbleAvatar: { |
| marginRight: 8, |
| marginBottom: 4, |
| }, |
| bubble: { |
| borderRadius: 16, |
| paddingHorizontal: 14, |
| paddingVertical: 10, |
| maxWidth: '100%', |
| }, |
| bubbleText: { |
| lineHeight: 20, |
| }, |
| bubbleFooter: { |
| flexDirection: 'row', |
| alignItems: 'center', |
| justifyContent: 'flex-start', |
| gap: 4, |
| marginTop: 4, |
| }, |
| bubbleFooterRight: { |
| justifyContent: 'flex-end', |
| }, |
| bubbleTime: { |
| fontSize: 10, |
| opacity: 0.7, |
| }, |
| systemMessageContainer: { |
| alignSelf: 'center', |
| marginVertical: 8, |
| paddingHorizontal: 12, |
| paddingVertical: 4, |
| backgroundColor: customTheme.colors.brand.surface, |
| borderRadius: 12, |
| }, |
| inputContainer: { |
| flexDirection: 'row', |
| alignItems: 'flex-end', |
| paddingHorizontal: 8, |
| paddingVertical: 4, |
| backgroundColor: customTheme.colors.brand.surface, |
| borderTopColor: customTheme.colors.brand.primaryLight, |
| borderTopWidth: StyleSheet.hairlineWidth, |
| }, |
| input: { |
| flex: 1, |
| backgroundColor: 'transparent', |
| maxHeight: 100, |
| fontSize: 15, |
| }, |
| inputContent: { |
| paddingBottom: 8, |
| }, |
| sendButton: { |
| margin: 0, |
| }, |
| }); |
|
|