Last active
September 10, 2025 13:08
-
-
Save arnaudambro/76da78bdd7953e46f644bc1af918d1cb to your computer and use it in GitHub Desktop.
Here is a full example of a working ChatRoom, the intention is to be kind of a clone of WhatsApp - it's not a lib, you can just copy past it and adapt to your own style
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { | |
| useState, | |
| useEffect, | |
| useCallback, | |
| useRef, | |
| useMemo, | |
| } from "react"; | |
| import { | |
| View, | |
| Text, | |
| TextInput, | |
| KeyboardAvoidingView, | |
| Platform, | |
| Pressable, | |
| Alert, | |
| StyleSheet, | |
| ScrollView, | |
| TouchableOpacity, | |
| Dimensions, | |
| ImageBackground, | |
| ActivityIndicator, | |
| } from "react-native"; | |
| import { | |
| SafeAreaView, | |
| useSafeAreaInsets, | |
| } from "react-native-safe-area-context"; | |
| import { Image } from "expo-image"; | |
| import { | |
| NativeStackNavigationProp, | |
| NativeStackScreenProps, | |
| } from "@react-navigation/native-stack"; | |
| import { LegendList, LegendListRef } from "@legendapp/list"; | |
| import dayjs from "dayjs"; | |
| import relativeTime from "dayjs/plugin/relativeTime"; | |
| import "dayjs/locale/fr"; | |
| import Ionicons from "@react-native-vector-icons/ionicons"; | |
| import * as ImagePicker from "expo-image-picker"; | |
| import * as DocumentPicker from "expo-document-picker"; | |
| import { useMMKVObject } from "react-native-mmkv"; | |
| import ContextMenu, { | |
| type ContextMenuAction, | |
| } from "react-native-context-menu-view"; | |
| dayjs.extend(relativeTime); | |
| dayjs.locale("fr"); | |
| // Types | |
| interface User { | |
| id: string; | |
| firstName: string; | |
| lastName: string; | |
| avatar?: string; | |
| blocked: string[]; | |
| deletedAt: Date | null; | |
| } | |
| interface MessageWithUsefulFields { | |
| id: string; | |
| content: string; | |
| author: User; | |
| createdAt: Date; | |
| likes: number; | |
| isLiked: boolean; | |
| postReferenceId?: string | null; | |
| attachments?: string[]; | |
| imageUrl?: string; | |
| } | |
| interface DirectMessagesStackParamList { | |
| DIRECT_MESSAGES_ROOM: { roomId: string }; | |
| } | |
| type ChatRoomStackParamList = { | |
| CHAT_ROOM: { roomId: string }; | |
| }; | |
| type Props = NativeStackScreenProps<ChatRoomStackParamList, "CHAT_ROOM">; | |
| interface MessageBubbleProps { | |
| message: MessageWithUsefulFields; | |
| isOwn: boolean; | |
| isSending: boolean; | |
| showAvatar: boolean; | |
| onReply: (message: MessageWithUsefulFields) => void; | |
| repliedMessage?: MessageWithUsefulFields; | |
| meId: string; | |
| roomId: string; | |
| onRefresh: () => void; | |
| navigation: NativeStackNavigationProp<ChatRoomStackParamList>; | |
| } | |
| // Constants | |
| const USER_COLORS = [ | |
| "#FF6B6B", | |
| "#4ECDC4", | |
| "#45B7D1", | |
| "#96CEB4", | |
| "#FECA57", | |
| "#FF9FF3", | |
| "#54A0FF", | |
| "#5F27CD", | |
| "#00D2D3", | |
| "#FF9F43", | |
| "#10AC84", | |
| "#EE5A24", | |
| "#0984E3", | |
| "#6C5CE7", | |
| "#A29BFE", | |
| ]; | |
| const colors = { | |
| "app-blue": "#007AFF", | |
| "app-black": "#000000", | |
| }; | |
| // Simple storage implementation (replace with your preferred solution) | |
| const storage = { | |
| getString: (key: string) => null, | |
| set: (key: string, value: any) => {}, | |
| getObject: (key: string) => null, | |
| setObject: (key: string, value: any) => {}, | |
| }; | |
| // Utility functions | |
| const getUserColor = (userId: string): string => { | |
| const hash = userId | |
| .split("") | |
| .reduce((acc, char) => char.charCodeAt(0) + acc, 0); | |
| return USER_COLORS[hash % USER_COLORS.length]; | |
| }; | |
| const formatDateSeparator = (date: string): string => { | |
| const messageDate = dayjs(date); | |
| const today = dayjs(); | |
| const yesterday = today.subtract(1, "day"); | |
| if (messageDate.isSame(today, "day")) { | |
| return "Aujourd'hui"; | |
| } else if (messageDate.isSame(yesterday, "day")) { | |
| return "Hier"; | |
| } else if (messageDate.isSame(today, "week")) { | |
| return messageDate.format("dddd"); | |
| } else if (messageDate.isSame(today, "year")) { | |
| return messageDate.format("D MMMM"); | |
| } else { | |
| return messageDate.format("DD MM YYYY"); | |
| } | |
| }; | |
| const formatWord = (word: string, navigation: any) => { | |
| // Simple implementation - replace with your link formatting logic | |
| if (word.startsWith("http")) { | |
| return ( | |
| <Pressable | |
| onPress={() => { | |
| navigation.navigate("WEBVIEW", { link: word }); | |
| }} | |
| > | |
| <MyText className="underline" color="app-black" selectable> | |
| {word}{" "} | |
| </MyText> | |
| </Pressable> | |
| ); | |
| } | |
| return <Text key={word}>{word} </Text>; | |
| }; | |
| // Mock API service | |
| const API = { | |
| get: async ({ path, query }: { path: string; query?: any }) => { | |
| // Mock implementation - replace with your API calls | |
| return { | |
| ok: true, | |
| data: { | |
| posts: [], | |
| hasMore: false, | |
| }, | |
| }; | |
| }, | |
| post: async ({ path, body }: { path: string; body: any }) => { | |
| // Mock implementation - replace with your API calls | |
| return { ok: true, data: {} }; | |
| }, | |
| uploadMedia: async (media: any, type: string, userId: string) => { | |
| // Mock implementation - replace with your media upload logic | |
| return { ok: true, data: { url: "mock-url" } }; | |
| }, | |
| }; | |
| // Simple components (replace with your own implementations) | |
| const MyText = ({ | |
| children, | |
| className, | |
| color, | |
| style, | |
| numberOfLines, | |
| ...props | |
| }: any) => ( | |
| <Text | |
| style={[{ color: colors[color as keyof typeof colors] || color }, style]} | |
| numberOfLines={numberOfLines} | |
| {...props} | |
| > | |
| {children} | |
| </Text> | |
| ); | |
| const MyTextInput = ({ className, ...props }: any) => ( | |
| <TextInput | |
| style={{ | |
| fontSize: 16, | |
| color: colors["app-black"], | |
| }} | |
| {...props} | |
| /> | |
| ); | |
| const Header = ({ | |
| children, | |
| withBackButton, | |
| }: { | |
| children: React.ReactNode; | |
| withBackButton?: boolean; | |
| }) => ( | |
| <View | |
| style={{ | |
| backgroundColor: "white", | |
| borderBottomWidth: 1, | |
| borderBottomColor: "#e0e0e0", | |
| }} | |
| > | |
| {children} | |
| </View> | |
| ); | |
| const ProfileButton = ({ | |
| user, | |
| className, | |
| }: { | |
| user: User; | |
| className?: string; | |
| }) => ( | |
| <View | |
| style={{ | |
| width: 32, | |
| height: 32, | |
| borderRadius: 16, | |
| backgroundColor: getUserColor(user.id), | |
| alignItems: "center", | |
| justifyContent: "center", | |
| }} | |
| > | |
| <Text style={{ color: "white", fontSize: 12, fontWeight: "bold" }}> | |
| {user.firstName[0]} | |
| {user.lastName[0]} | |
| </Text> | |
| </View> | |
| ); | |
| const Loader = () => ( | |
| <ActivityIndicator size="large" color={colors["app-blue"]} /> | |
| ); | |
| const Camera = ({ | |
| onMediaCaptured, | |
| onClose, | |
| }: { | |
| onMediaCaptured: (media: any, type: "photo" | "video") => void; | |
| onClose: () => void; | |
| }) => ( | |
| <View | |
| style={{ | |
| flex: 1, | |
| backgroundColor: "black", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| }} | |
| > | |
| <Text style={{ color: "white", marginBottom: 20 }}>Camera Component</Text> | |
| <TouchableOpacity | |
| onPress={onClose} | |
| style={{ | |
| padding: 20, | |
| backgroundColor: colors["app-blue"], | |
| borderRadius: 8, | |
| }} | |
| > | |
| <Text style={{ color: "white" }}>Close</Text> | |
| </TouchableOpacity> | |
| </View> | |
| ); | |
| const PdfIcon = ({ className }: { className?: string }) => ( | |
| <Ionicons name="document" size={24} color="#666" /> | |
| ); | |
| // Components | |
| const DateSeparator = ({ date }: { date: string }) => { | |
| return ( | |
| <View style={{ marginVertical: 16, alignItems: "center" }}> | |
| <View | |
| style={{ | |
| backgroundColor: "#e0e0e0", | |
| paddingHorizontal: 12, | |
| paddingVertical: 4, | |
| borderRadius: 20, | |
| }} | |
| > | |
| <Text | |
| style={{ | |
| fontSize: 12, | |
| fontWeight: "500", | |
| color: colors["app-black"], | |
| }} | |
| > | |
| {date} | |
| </Text> | |
| </View> | |
| </View> | |
| ); | |
| }; | |
| function MessageBubble({ | |
| message, | |
| isOwn, | |
| isSending, | |
| showAvatar, | |
| onReply, | |
| repliedMessage, | |
| meId, | |
| roomId, | |
| onRefresh, | |
| navigation, | |
| }: MessageBubbleProps) { | |
| const userColor = getUserColor(message.author.id); | |
| const isAuthor = message.author.id === meId; | |
| const menuItems: Array<ContextMenuAction> = useMemo(() => { | |
| const items: Array<ContextMenuAction> = [ | |
| { | |
| title: message.isLiked ? "Ne plus aimer" : "J'aime", | |
| systemIcon: "heart", | |
| }, | |
| { | |
| title: "Répondre", | |
| systemIcon: "arrow.up.right", | |
| }, | |
| { | |
| title: "Signaler", | |
| systemIcon: "exclamationmark.triangle", | |
| }, | |
| ]; | |
| if (isAuthor) { | |
| items.push({ | |
| title: "Supprimer", | |
| systemIcon: "trash", | |
| destructive: true, | |
| }); | |
| } | |
| return items; | |
| }, [isAuthor, message.isLiked]); | |
| const handleMenuAction = async (action: string) => { | |
| switch (action) { | |
| case "Répondre": | |
| onReply(message); | |
| break; | |
| case "J'aime": | |
| case "Ne plus aimer": | |
| try { | |
| const response = await API.post({ | |
| path: `/post/${message.id}`, | |
| body: { | |
| action: message.isLiked ? "unlike" : "like", | |
| postId: message.id, | |
| roomId, | |
| authorId: meId, | |
| quotedUsers: "", | |
| }, | |
| }); | |
| if (response.ok) { | |
| onRefresh(); | |
| } | |
| } catch (error) { | |
| console.error("Failed to like/unlike message:", error); | |
| } | |
| break; | |
| case "Signaler": | |
| try { | |
| await API.post({ | |
| path: `/post/${message.id}`, | |
| body: { | |
| action: "report", | |
| postId: message.id, | |
| roomId, | |
| authorId: meId, | |
| quotedUsers: "", | |
| }, | |
| }); | |
| Alert.alert( | |
| "Merci", | |
| "Merci pour votre signalement, nous l'examinerons dans les plus brefs délais.", | |
| ); | |
| } catch (error) { | |
| console.error("Failed to report message:", error); | |
| } | |
| break; | |
| case "Supprimer": | |
| if (isAuthor) { | |
| Alert.alert( | |
| "Supprimer", | |
| "Voulez-vous vraiment supprimer ce message ? Cette opération est irréversible", | |
| [ | |
| { | |
| text: "Annuler", | |
| style: "cancel", | |
| }, | |
| { | |
| text: "Supprimer", | |
| style: "destructive", | |
| onPress: async () => { | |
| try { | |
| const response = await API.post({ | |
| path: `/post/${message.id}`, | |
| body: { | |
| action: "delete", | |
| postId: message.id, | |
| roomId, | |
| authorId: meId, | |
| quotedUsers: "", | |
| }, | |
| }); | |
| if (response.ok) { | |
| onRefresh(); | |
| } | |
| } catch (error) { | |
| console.error("Failed to delete message:", error); | |
| } | |
| }, | |
| }, | |
| ], | |
| ); | |
| } | |
| break; | |
| } | |
| }; | |
| return ( | |
| <View | |
| style={[ | |
| styles.messageContainer, | |
| { alignItems: isOwn ? "flex-end" : "flex-start" }, | |
| message.content ? { marginBottom: 4 } : {}, | |
| ]} | |
| > | |
| <View | |
| style={[ | |
| styles.messageRow, | |
| { flexDirection: isOwn ? "row-reverse" : "row" }, | |
| ]} | |
| > | |
| {!isOwn && showAvatar && ( | |
| <View style={{ marginRight: 8 }}> | |
| <ProfileButton user={message.author} /> | |
| </View> | |
| )} | |
| {!isOwn && !showAvatar && <View style={{ width: 40 }} />} | |
| <ContextMenu | |
| actions={menuItems} | |
| dropdownMenuMode | |
| onPress={(e) => { | |
| const { name } = e.nativeEvent; | |
| handleMenuAction(name); | |
| }} | |
| style={{ flex: 1 }} | |
| previewBackgroundColor="transparent" | |
| > | |
| <View | |
| style={[ | |
| styles.messageBubble, | |
| { | |
| backgroundColor: isOwn ? "#D8FDD1" : "white", | |
| borderBottomRightRadius: isOwn && message.content ? 4 : 16, | |
| borderBottomLeftRadius: !isOwn && message.content ? 4 : 16, | |
| marginBottom: message.likes > 0 ? 24 : 0, | |
| opacity: isSending ? 0.5 : 1, | |
| paddingTop: | |
| message.content && | |
| !message.postReferenceId && | |
| !message.attachments?.length | |
| ? 8 | |
| : 4, | |
| paddingBottom: message.postReferenceId ? 4 : 6, | |
| paddingRight: !isOwn && message.content ? 4 : 0, | |
| }, | |
| ]} | |
| > | |
| {!isOwn && showAvatar && ( | |
| <View style={{ marginBottom: 4, paddingHorizontal: 8 }}> | |
| <Text | |
| style={{ | |
| fontSize: 14, | |
| fontWeight: "bold", | |
| color: userColor, | |
| marginBottom: 4, | |
| }} | |
| > | |
| {message.author.firstName} {message.author.lastName} | |
| </Text> | |
| </View> | |
| )} | |
| {message.postReferenceId && repliedMessage && ( | |
| <View style={{ marginBottom: 8 }}> | |
| <View | |
| style={[ | |
| styles.replyContainer, | |
| { borderLeftColor: getUserColor(repliedMessage.author.id) }, | |
| ]} | |
| > | |
| <Text | |
| style={{ | |
| fontSize: 12, | |
| fontWeight: "600", | |
| color: getUserColor(repliedMessage.author.id), | |
| marginBottom: 4, | |
| }} | |
| > | |
| {repliedMessage.author.firstName}{" "} | |
| {repliedMessage.author.lastName} | |
| </Text> | |
| {repliedMessage.imageUrl ? ( | |
| <View | |
| style={{ flexDirection: "row", alignItems: "center" }} | |
| > | |
| <Image | |
| source={{ uri: repliedMessage.imageUrl }} | |
| style={{ | |
| width: 32, | |
| height: 32, | |
| borderRadius: 4, | |
| marginRight: 8, | |
| }} | |
| contentFit="cover" | |
| /> | |
| <Text | |
| style={{ flex: 1, fontSize: 12, color: "#666" }} | |
| numberOfLines={2} | |
| > | |
| {repliedMessage.content || "Image"} | |
| </Text> | |
| </View> | |
| ) : repliedMessage.attachments && | |
| repliedMessage.attachments.length > 0 ? ( | |
| <View | |
| style={{ flexDirection: "row", alignItems: "center" }} | |
| > | |
| <View | |
| style={{ | |
| width: 32, | |
| height: 32, | |
| borderRadius: 4, | |
| backgroundColor: "#ccc", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| marginRight: 8, | |
| }} | |
| > | |
| <Ionicons | |
| name={ | |
| repliedMessage.attachments[0].endsWith(".pdf") | |
| ? "document" | |
| : repliedMessage.attachments[0].match( | |
| /\.(jpg|jpeg|png|gif|webp)$/i, | |
| ) | |
| ? "image" | |
| : repliedMessage.attachments[0].match( | |
| /\.(mp4|mov|avi|mkv)$/i, | |
| ) | |
| ? "videocam" | |
| : "document" | |
| } | |
| size={16} | |
| color="#666" | |
| /> | |
| </View> | |
| <Text | |
| style={{ flex: 1, fontSize: 12, color: "#666" }} | |
| numberOfLines={2} | |
| > | |
| {repliedMessage.attachments[0].endsWith(".pdf") | |
| ? "Document" | |
| : repliedMessage.attachments[0].match( | |
| /\.(jpg|jpeg|png|gif|webp)$/i, | |
| ) | |
| ? "Image" | |
| : repliedMessage.attachments[0].match( | |
| /\.(mp4|mov|avi|mkv)$/i, | |
| ) | |
| ? "Vidéo" | |
| : "Document"} | |
| </Text> | |
| </View> | |
| ) : ( | |
| <Text | |
| style={{ fontSize: 12, color: "#666" }} | |
| numberOfLines={2} | |
| > | |
| {repliedMessage.content | |
| .split("\n") | |
| .filter((sentence) => sentence.trim()) | |
| .map((sentence, i, array) => ( | |
| <React.Fragment key={i}> | |
| {sentence.split(" ").map((word, index) => ( | |
| <React.Fragment key={word + index}> | |
| {formatWord(word, navigation)} | |
| </React.Fragment> | |
| ))} | |
| {i !== array.length - 1 && "\n"} | |
| </React.Fragment> | |
| ))} | |
| </Text> | |
| )} | |
| </View> | |
| </View> | |
| )} | |
| {message.attachments && message.attachments.length > 0 && ( | |
| <View | |
| style={[ | |
| { marginHorizontal: 4, marginTop: 4 }, | |
| message.content ? { marginBottom: 8 } : {}, | |
| ]} | |
| > | |
| {message.attachments.map((attachment, index) => ( | |
| <Pressable | |
| onPress={() => { | |
| // Handle attachment press - open in webview or native viewer | |
| console.log("Open attachment:", attachment); | |
| }} | |
| key={index} | |
| > | |
| {attachment.endsWith(".pdf") ? ( | |
| <View style={styles.pdfAttachment}> | |
| <View | |
| style={{ | |
| flexDirection: "row", | |
| alignItems: "center", | |
| gap: 8, | |
| }} | |
| > | |
| <PdfIcon /> | |
| <Text | |
| style={{ | |
| color: colors["app-blue"], | |
| fontSize: 16, | |
| fontWeight: "500", | |
| }} | |
| > | |
| Cliquez ici pour voir le PDF | |
| </Text> | |
| </View> | |
| </View> | |
| ) : [ | |
| "mp4", | |
| "mov", | |
| "avi", | |
| "webm", | |
| "mkv", | |
| "m4v", | |
| "wmv", | |
| "flv", | |
| "3gp", | |
| ].includes(attachment.split(".").at(-1)!) ? ( | |
| <View style={styles.videoAttachment}> | |
| <View | |
| style={{ | |
| flexDirection: "row", | |
| alignItems: "center", | |
| gap: 8, | |
| }} | |
| > | |
| <Ionicons | |
| name="play-circle" | |
| size={32} | |
| color={colors["app-blue"]} | |
| /> | |
| <Text | |
| style={{ | |
| color: colors["app-blue"], | |
| fontSize: 16, | |
| fontWeight: "500", | |
| }} | |
| > | |
| Cliquer ici pour voir la vidéo | |
| </Text> | |
| </View> | |
| </View> | |
| ) : ( | |
| <Image source={attachment} style={styles.messageImage} /> | |
| )} | |
| </Pressable> | |
| ))} | |
| </View> | |
| )} | |
| {message.content && ( | |
| <> | |
| <View style={{ paddingHorizontal: 8 }}> | |
| <Text style={{ fontSize: 16, color: colors["app-black"] }}> | |
| {message.content | |
| .split("\n") | |
| .filter((sentence) => sentence.trim()) | |
| .map((sentence, i, array) => ( | |
| <React.Fragment key={i}> | |
| {sentence.split(" ").map((word, index) => ( | |
| <React.Fragment key={word + index}> | |
| {formatWord(word, navigation)} | |
| </React.Fragment> | |
| ))} | |
| {i !== array.length - 1 && "\n"} | |
| </React.Fragment> | |
| ))} | |
| </Text> | |
| </View> | |
| <View | |
| style={[ | |
| { | |
| flexDirection: "row", | |
| alignItems: "center", | |
| justifyContent: "flex-end", | |
| }, | |
| isOwn ? { paddingRight: 8 } : {}, | |
| ]} | |
| > | |
| <Text | |
| style={{ | |
| fontSize: 12, | |
| opacity: 0.7, | |
| color: colors["app-black"], | |
| }} | |
| > | |
| {dayjs(message.createdAt).format("HH:mm")} | |
| </Text> | |
| </View> | |
| </> | |
| )} | |
| {message.likes > 0 && ( | |
| <View | |
| style={[ | |
| styles.likesContainer, | |
| isOwn ? { bottom: -24, right: 0 } : { bottom: -16, left: 0 }, | |
| ]} | |
| > | |
| <View style={styles.likesButton}> | |
| <Text style={{ fontSize: 12, color: colors["app-black"] }}> | |
| 👍{message.likes > 1 ? ` ${message.likes}` : ""} | |
| </Text> | |
| </View> | |
| </View> | |
| )} | |
| </View> | |
| </ContextMenu> | |
| </View> | |
| </View> | |
| ); | |
| } | |
| type ListItem = | |
| | { type: "message"; data: MessageWithUsefulFields; id: string } | |
| | { | |
| type: "sending-message"; | |
| data: MessageWithUsefulFields; | |
| id?: "sending-message"; | |
| } | |
| | { | |
| type: "dateSeparator"; | |
| data: { date: string; id: string; likes?: number; isLiked?: boolean }; | |
| id: string; | |
| }; | |
| export default function ChatRoom({ navigation, route }: Props) { | |
| const { roomId } = route.params; | |
| // Mock user - replace with your user management | |
| const me: User = { | |
| id: "current-user-id", | |
| firstName: "John", | |
| lastName: "Doe", | |
| blocked: [], | |
| deletedAt: null, | |
| }; | |
| // Use simple state instead of MMKV for this example | |
| const [listItems, setListItems] = useState<ListItem[]>([]); | |
| const [repliedMessages, setRepliedMessages] = useState< | |
| Record<string, MessageWithUsefulFields> | |
| >({}); | |
| const [loading, setLoading] = useState(true); | |
| const [page, setPage] = useState(0); | |
| const [hasMore, setHasMore] = useState(true); | |
| const [refreshing, setRefreshing] = useState(false); | |
| const [messageText, setMessageText] = useState(""); | |
| const [replyingTo, setReplyingTo] = useState<MessageWithUsefulFields | null>( | |
| null, | |
| ); | |
| const [showCamera, setShowCamera] = useState(false); | |
| const [attachments, setAttachments] = useState<string[]>([]); | |
| const [sending, setSending] = useState(false); | |
| const scrollViewRef = useRef<LegendListRef>(null); | |
| // Mock room data | |
| const roomName = "Chat Room"; | |
| const mediaMenuItems: Array<ContextMenuAction> = useMemo( | |
| () => [ | |
| { title: "Prendre une photo", systemIcon: "camera" }, | |
| { title: "Choisir depuis la galerie", systemIcon: "photo.on.rectangle" }, | |
| { title: "Document", systemIcon: "doc" }, | |
| ], | |
| [], | |
| ); | |
| const fetchMessages = useCallback( | |
| async (page: number) => { | |
| try { | |
| const response = await API.get({ | |
| path: "/post", | |
| query: { page, qRoomId: roomId }, | |
| }); | |
| if (response.ok) { | |
| const rawMessages: Array<MessageWithUsefulFields> = response.data.posts; | |
| const listItemsWithSeparators: ListItem[] = []; | |
| let prevDate: string | null = null; | |
| for (let i = rawMessages.length - 1; i >= 0; i--) { | |
| const message = rawMessages[i]; | |
| const messageDate = dayjs(message.createdAt).format("YYYY-MM-DD"); | |
| if (messageDate !== prevDate) { | |
| listItemsWithSeparators.push({ | |
| type: "dateSeparator", | |
| data: { | |
| date: formatDateSeparator(message.createdAt.toString()), | |
| id: `date-separator-${messageDate}`, | |
| }, | |
| id: `date-separator-${messageDate}`, | |
| }); | |
| prevDate = messageDate; | |
| } | |
| if (message.postReferenceId) { | |
| let repliedMessaged: MessageWithUsefulFields | undefined = | |
| repliedMessages?.[message.postReferenceId]; | |
| if (!repliedMessaged) { | |
| repliedMessaged = rawMessages.find( | |
| (m: any) => m.id === message.postReferenceId, | |
| ); | |
| if (repliedMessaged) { | |
| setRepliedMessages((prev) => ({ | |
| ...prev, | |
| [message.postReferenceId!]: repliedMessaged!, | |
| })); | |
| } | |
| } | |
| } | |
| listItemsWithSeparators.push({ | |
| type: "message", | |
| data: message, | |
| id: message.id, | |
| }); | |
| } | |
| if (page === 0) { | |
| setListItems(listItemsWithSeparators); | |
| } else { | |
| const existingItems = listItems || []; | |
| const newItems = listItemsWithSeparators; | |
| if (existingItems.length > 0 && newItems.length > 0) { | |
| let lastExistingItem: ListItem = existingItems[0]; | |
| if (lastExistingItem.type === "sending-message") { | |
| existingItems.shift(); | |
| lastExistingItem = existingItems[0]; | |
| } | |
| const firstNewItem = newItems[newItems.length - 1]; | |
| if ( | |
| lastExistingItem.type === "message" && | |
| firstNewItem.type === "message" | |
| ) { | |
| const lastExistingDate = dayjs( | |
| lastExistingItem.data.createdAt, | |
| ).format("YYYY-MM-DD"); | |
| const firstNewDate = dayjs(firstNewItem.data.createdAt).format( | |
| "YYYY-MM-DD", | |
| ); | |
| if ( | |
| newItems[0]?.type === "dateSeparator" && | |
| lastExistingDate === firstNewDate | |
| ) { | |
| newItems.shift(); | |
| } | |
| } | |
| } | |
| setListItems([...newItems, ...existingItems]); | |
| } | |
| setHasMore(response.data.hasMore || false); | |
| setPage(page); | |
| if (page === 0) { | |
| scrollViewRef.current?.scrollToEnd({ animated: true }); | |
| } | |
| } | |
| } catch (error) { | |
| console.error("Failed to fetch messages:", error); | |
| } finally { | |
| setLoading(false); | |
| setRefreshing(false); | |
| } | |
| }, | |
| [roomId, listItems, repliedMessages], | |
| ); | |
| useEffect(() => { | |
| fetchMessages(0); | |
| }, []); | |
| const handleSendMessage = async () => { | |
| if (!messageText.trim() && attachments.length === 0) return; | |
| const replyTo_localReply = replyingTo; | |
| const attachments_localAttachments = attachments; | |
| setSending(true); | |
| setListItems((prev) => [ | |
| ...(prev || []), | |
| { | |
| type: "sending-message", | |
| data: { | |
| content: messageText, | |
| attachments, | |
| author: me, | |
| createdAt: new Date(), | |
| likes: 0, | |
| isLiked: false, | |
| postReferenceId: null, | |
| id: "sending-message", | |
| imageUrl: "", | |
| }, | |
| id: "sending-message", | |
| }, | |
| ]); | |
| setMessageText(""); | |
| setReplyingTo(null); | |
| setAttachments([]); | |
| setTimeout(() => { | |
| scrollViewRef.current?.scrollToEnd({ animated: true }); | |
| }, 100); | |
| try { | |
| const response = await API.post({ | |
| path: "/post", | |
| body: { | |
| content: messageText, | |
| roomId, | |
| attachments, | |
| postReferenceId: replyingTo?.id, | |
| anonymous: false, | |
| }, | |
| }); | |
| if (response.ok) { | |
| fetchMessages(0); | |
| setTimeout(() => { | |
| scrollViewRef.current?.scrollToEnd({ animated: true }); | |
| }, 100); | |
| } else { | |
| console.log("Failed to send message", response); | |
| setMessageText(messageText); | |
| setReplyingTo(replyTo_localReply); | |
| setAttachments(attachments_localAttachments); | |
| setListItems((prev) => | |
| prev?.filter((item) => item.id !== "sending-message"), | |
| ); | |
| } | |
| } catch (error) { | |
| console.error("Failed to send message:", error); | |
| Alert.alert("Erreur", "Impossible d'envoyer le message"); | |
| } finally { | |
| setSending(false); | |
| } | |
| }; | |
| const handleReply = (message: MessageWithUsefulFields) => { | |
| setReplyingTo(message); | |
| }; | |
| const handleMediaPicker = async () => { | |
| try { | |
| const result = await ImagePicker.launchImageLibraryAsync({ | |
| mediaTypes: ["images", "videos"], | |
| allowsMultipleSelection: true, | |
| quality: 1, | |
| }); | |
| if (!result.canceled) { | |
| for (const asset of result.assets) { | |
| const response = await API.uploadMedia( | |
| { path: asset.uri }, | |
| asset.type === "video" ? "video" : "photo", | |
| me.id, | |
| ); | |
| if (response.ok) { | |
| setAttachments((prev) => [...prev, response.data.url]); | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| console.error("Error picking media:", error); | |
| } | |
| }; | |
| const handleDocumentPicker = async () => { | |
| try { | |
| const result = await DocumentPicker.getDocumentAsync({ | |
| type: "application/pdf", | |
| }); | |
| if (result.assets && result.assets.length > 0) { | |
| const file = result.assets[0]; | |
| const response = await API.uploadMedia( | |
| { path: file.uri }, | |
| "pdf", | |
| me.id, | |
| ); | |
| if (response.ok) { | |
| setAttachments((prev) => [...prev, response.data.url]); | |
| } | |
| } | |
| } catch (error) { | |
| console.error("Error picking document:", error); | |
| } | |
| }; | |
| const handleCameraCapture = async (media: any, type: "photo" | "video") => { | |
| try { | |
| const response = await API.uploadMedia(media, type, me.id); | |
| if (response.ok) { | |
| setAttachments((prev) => [...prev, response.data.url]); | |
| } | |
| setShowCamera(false); | |
| } catch (error) { | |
| console.error("Error uploading media:", error); | |
| } | |
| }; | |
| const renderListItem = ({ | |
| item, | |
| index, | |
| }: { | |
| item: ListItem; | |
| index: number; | |
| }) => { | |
| try { | |
| if (item.type === "dateSeparator") { | |
| return <DateSeparator date={item.data.date} />; | |
| } | |
| const message = item.data; | |
| const isOwn = message.author.id === me.id; | |
| let prevMessageItem: ListItem | null = null; | |
| for (let i = index - 1; i >= 0; i--) { | |
| if (listItems?.[i]?.type === "message") { | |
| prevMessageItem = listItems[i]; | |
| break; | |
| } | |
| } | |
| const prevMessage = | |
| prevMessageItem?.type === "message" ? prevMessageItem.data : null; | |
| const showAvatar = | |
| !prevMessage || | |
| prevMessage.author.id !== message.author.id || | |
| dayjs(message.createdAt).diff(dayjs(prevMessage.createdAt), "minute") > | |
| 5; | |
| return ( | |
| <MessageBubble | |
| message={message} | |
| isOwn={isOwn} | |
| isSending={message.id === "sending-message"} | |
| showAvatar={showAvatar} | |
| onReply={handleReply} | |
| repliedMessage={repliedMessages?.[message.postReferenceId!]} | |
| meId={me.id} | |
| roomId={roomId} | |
| onRefresh={() => fetchMessages(0)} | |
| navigation={navigation} | |
| /> | |
| ); | |
| } catch (error) { | |
| console.error("Error rendering list item:", error); | |
| console.log(item); | |
| } | |
| return null; | |
| }; | |
| if (loading) { | |
| return ( | |
| <SafeAreaView | |
| style={{ flex: 1, alignItems: "center", justifyContent: "center" }} | |
| > | |
| <Loader /> | |
| </SafeAreaView> | |
| ); | |
| } | |
| return ( | |
| <SafeAreaView | |
| style={{ flex: 1, backgroundColor: "white" }} | |
| edges={["top", "bottom"]} | |
| > | |
| <Header withBackButton> | |
| <View | |
| style={{ | |
| flexDirection: "row", | |
| alignItems: "center", | |
| paddingVertical: 16, | |
| }} | |
| > | |
| <View> | |
| <Text style={{ fontWeight: "bold", color: colors["app-blue"] }}> | |
| {roomName} | |
| </Text> | |
| </View> | |
| </View> | |
| </Header> | |
| <KeyboardAvoidingView | |
| behavior={Platform.select({ ios: "padding", android: undefined })} | |
| style={{ flex: 1 }} | |
| keyboardVerticalOffset={0} | |
| > | |
| <ImageBackground | |
| source={require("./chat-bg.jpg")} // Replace with your background image | |
| style={{ flex: 1 }} | |
| imageStyle={{ opacity: 0.3 }} | |
| resizeMode="cover" | |
| > | |
| <LegendList | |
| ref={scrollViewRef} | |
| data={listItems || []} | |
| renderItem={renderListItem} | |
| keyExtractor={(item: ListItem, index: number) => | |
| item.id + | |
| String(item.data?.likes || 0) + | |
| String(item.data?.isLiked || false) | |
| } | |
| style={{ flex: 1, paddingHorizontal: 16 }} | |
| alignItemsAtEnd | |
| maintainScrollAtEnd | |
| maintainScrollAtEndThreshold={0.1} | |
| recycleItems={false} | |
| maintainVisibleContentPosition | |
| estimatedItemSize={Dimensions.get("window").height / 4} | |
| refreshing={refreshing} | |
| ListEmptyComponent={() => ( | |
| <View | |
| style={{ | |
| flex: 1, | |
| alignItems: "center", | |
| justifyContent: "center", | |
| }} | |
| > | |
| <DateSeparator date="Aujourd'hui" /> | |
| </View> | |
| )} | |
| /> | |
| </ImageBackground> | |
| {replyingTo && ( | |
| <View | |
| style={{ | |
| borderLeftWidth: 4, | |
| borderLeftColor: getUserColor(replyingTo.author.id), | |
| backgroundColor: "rgba(0, 0, 0, 0.05)", | |
| paddingHorizontal: 16, | |
| paddingVertical: 8, | |
| }} | |
| > | |
| <View | |
| style={{ | |
| flexDirection: "row", | |
| alignItems: "center", | |
| justifyContent: "space-between", | |
| }} | |
| > | |
| <View style={{ flex: 1 }}> | |
| <Text | |
| style={{ | |
| fontSize: 12, | |
| fontWeight: "600", | |
| color: getUserColor(replyingTo.author.id), | |
| marginBottom: 4, | |
| }} | |
| > | |
| {replyingTo.author.firstName} {replyingTo.author.lastName} | |
| </Text> | |
| <Text | |
| style={{ | |
| fontSize: 14, | |
| opacity: 0.7, | |
| color: colors["app-black"], | |
| }} | |
| numberOfLines={1} | |
| > | |
| {replyingTo.content} | |
| </Text> | |
| </View> | |
| <TouchableOpacity onPress={() => setReplyingTo(null)}> | |
| <Ionicons name="close" size={20} color={colors["app-blue"]} /> | |
| </TouchableOpacity> | |
| </View> | |
| </View> | |
| )} | |
| {attachments.length > 0 && ( | |
| <ScrollView | |
| horizontal | |
| style={{ paddingHorizontal: 16, paddingVertical: 8 }} | |
| > | |
| {attachments.map((attachment, index) => ( | |
| <View | |
| key={index} | |
| style={{ position: "relative", marginRight: 8 }} | |
| > | |
| {attachment.endsWith(".pdf") ? ( | |
| <View | |
| style={{ | |
| width: 64, | |
| height: 64, | |
| alignItems: "center", | |
| justifyContent: "center", | |
| borderRadius: 8, | |
| backgroundColor: "#e0e0e0", | |
| }} | |
| > | |
| <PdfIcon /> | |
| </View> | |
| ) : ( | |
| <Image source={attachment} style={styles.attachmentPreview} /> | |
| )} | |
| <TouchableOpacity | |
| onPress={() => | |
| setAttachments((prev) => prev.filter((_, i) => i !== index)) | |
| } | |
| style={{ | |
| position: "absolute", | |
| top: -8, | |
| right: -8, | |
| width: 24, | |
| height: 24, | |
| borderRadius: 12, | |
| backgroundColor: "#ff4444", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| }} | |
| > | |
| <Ionicons name="close" size={12} color="white" /> | |
| </TouchableOpacity> | |
| </View> | |
| ))} | |
| </ScrollView> | |
| )} | |
| <View | |
| style={{ | |
| flexDirection: "row", | |
| alignItems: "flex-end", | |
| gap: 8, | |
| borderTopWidth: 1, | |
| borderTopColor: "#e0e0e0", | |
| backgroundColor: "white", | |
| paddingHorizontal: 16, | |
| paddingVertical: 8, | |
| }} | |
| > | |
| <ContextMenu | |
| dropdownMenuMode | |
| actions={mediaMenuItems} | |
| onPress={(e) => { | |
| const { name } = e.nativeEvent; | |
| if (name === "Prendre une photo") { | |
| setShowCamera(true); | |
| } else if (name === "Choisir depuis la galerie") { | |
| handleMediaPicker(); | |
| } else if (name === "Document") { | |
| handleDocumentPicker(); | |
| } | |
| }} | |
| > | |
| <View | |
| style={{ | |
| width: 40, | |
| height: 40, | |
| alignItems: "center", | |
| justifyContent: "center", | |
| }} | |
| > | |
| <Ionicons name="add-sharp" size={24} color={colors["app-blue"]} /> | |
| </View> | |
| </ContextMenu> | |
| <View | |
| style={{ | |
| flex: 1, | |
| borderRadius: 20, | |
| backgroundColor: "#f0f0f0", | |
| paddingHorizontal: 16, | |
| paddingVertical: 8, | |
| }} | |
| > | |
| <MyTextInput | |
| value={messageText} | |
| onChangeText={setMessageText} | |
| placeholder="Tapez votre message..." | |
| multiline | |
| maxLength={1000} | |
| style={{ maxHeight: 80 }} | |
| /> | |
| </View> | |
| <TouchableOpacity | |
| onPress={handleSendMessage} | |
| disabled={ | |
| sending || (!messageText.trim() && attachments.length === 0) | |
| } | |
| style={{ | |
| width: 40, | |
| height: 40, | |
| borderRadius: 20, | |
| alignItems: "center", | |
| justifyContent: "center", | |
| backgroundColor: | |
| messageText.trim() || attachments.length > 0 | |
| ? colors["app-blue"] | |
| : "#ccc", | |
| }} | |
| > | |
| <Ionicons name="send" size={18} color="white" /> | |
| </TouchableOpacity> | |
| </View> | |
| </KeyboardAvoidingView> | |
| {showCamera && ( | |
| <Camera | |
| onMediaCaptured={handleCameraCapture} | |
| onClose={() => setShowCamera(false)} | |
| /> | |
| )} | |
| </SafeAreaView> | |
| ); | |
| } | |
| const styles = StyleSheet.create({ | |
| messageContainer: { | |
| position: "relative", | |
| }, | |
| messageRow: { | |
| maxWidth: "80%", | |
| }, | |
| messageBubble: { | |
| borderRadius: 16, | |
| borderWidth: 1, | |
| borderColor: "#e0e0e0", | |
| flex: 1, | |
| }, | |
| replyContainer: { | |
| borderRadius: 8, | |
| backgroundColor: "rgba(0, 0, 0, 0.05)", | |
| padding: 8, | |
| borderLeftWidth: 3, | |
| }, | |
| pdfAttachment: { | |
| alignItems: "center", | |
| justifyContent: "center", | |
| borderRadius: 8, | |
| backgroundColor: "transparent", | |
| padding: 16, | |
| }, | |
| videoAttachment: { | |
| alignItems: "center", | |
| justifyContent: "center", | |
| borderRadius: 8, | |
| backgroundColor: "transparent", | |
| padding: 16, | |
| }, | |
| messageImage: { | |
| width: Dimensions.get("window").width * 0.6, | |
| height: Dimensions.get("window").width * 0.6, | |
| borderRadius: 12, | |
| }, | |
| likesContainer: { | |
| position: "absolute", | |
| marginTop: 4, | |
| flexDirection: "row", | |
| flexWrap: "wrap", | |
| }, | |
| likesButton: { | |
| marginRight: 4, | |
| borderRadius: 20, | |
| borderWidth: 1, | |
| borderColor: "#e0e0e0", | |
| backgroundColor: "white", | |
| paddingHorizontal: 8, | |
| paddingVertical: 4, | |
| }, | |
| attachmentPreview: { | |
| width: 64, | |
| height: 64, | |
| borderRadius: 8, | |
| }, | |
| }); |
Author
arnaudambro
commented
Sep 10, 2025
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment