From 60ac81deb1d7206360a2e2440a5ed222025e51c8 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 17:11:56 +0200 Subject: [PATCH] feat(expo): rewrite channel screen with custom chat backend --- xtablo-expo/app/(app)/channel/[cid].tsx | 521 +++++++++++++++++------- 1 file changed, 368 insertions(+), 153 deletions(-) diff --git a/xtablo-expo/app/(app)/channel/[cid].tsx b/xtablo-expo/app/(app)/channel/[cid].tsx index 8b43dde..f1197f5 100644 --- a/xtablo-expo/app/(app)/channel/[cid].tsx +++ b/xtablo-expo/app/(app)/channel/[cid].tsx @@ -1,206 +1,421 @@ -import { Stack, useLocalSearchParams } from "expo-router"; +import React, { useState, useRef, useCallback, useMemo } from "react"; import { - ActivityIndicator, - SafeAreaView, View, Text, + FlatList, + TextInput, + TouchableOpacity, StyleSheet, + SafeAreaView, KeyboardAvoidingView, Platform, + ActivityIndicator, } from "react-native"; -import { Channel, MessageInput, MessageList, useChatContext } from "stream-chat-expo"; -import { MessageCircle, Users, Smile } from "lucide-react-native"; -import { useEffect, useState } from "react"; -import { useHeaderHeight } from "@react-navigation/elements"; +import { useLocalSearchParams, router } from "expo-router"; +import { useFocusEffect } from "@react-navigation/native"; +import { ArrowLeft, Send, MessageCircle } from "lucide-react-native"; import { useThemeColor } from "@/hooks/useThemeColor"; import { useColorScheme } from "@/hooks/useColorScheme"; +import { useChat } from "@/hooks/chat"; +import { useTablosList } from "@/hooks/tablos"; +import { useTabloMembers, TabloMember } from "@/hooks/members"; +import { useQueryClient } from "@tanstack/react-query"; +import { ChatMessage } from "@/types/chat.types"; +import { useAuthStore } from "@/stores/auth"; + +const MESSAGE_GROUP_INTERVAL = 2 * 60 * 1000; // 2 minutes in ms + +type MessageGroup = { + key: string; + userId: string; + messages: ChatMessage[]; + isOwn: boolean; +}; + +function groupMessages(messages: ChatMessage[], currentUserId: string): MessageGroup[] { + const groups: MessageGroup[] = []; + + for (const msg of messages) { + const lastGroup = groups[groups.length - 1]; + const timeDiff = lastGroup + ? new Date(msg.createdAt).getTime() - + new Date(lastGroup.messages[lastGroup.messages.length - 1].createdAt).getTime() + : Infinity; + + if (lastGroup && lastGroup.userId === msg.userId && timeDiff < MESSAGE_GROUP_INTERVAL) { + lastGroup.messages.push(msg); + } else { + groups.push({ + key: msg.id, + userId: msg.userId, + messages: [msg], + isOwn: msg.userId === currentUserId, + }); + } + } + + return groups; +} export default function ChannelScreen() { const { cid } = useLocalSearchParams<{ cid: string }>(); - const [type, id] = cid.split(":"); - const { client } = useChatContext(); - const channel = client.channel(type, id); - const [hasMessages, setHasMessages] = useState(false); - const [isLoading, setIsLoading] = useState(true); + const channelId = cid; + const session = useAuthStore((state) => state.session); + const currentUserId = session?.user?.id ?? ""; + const queryClient = useQueryClient(); + const colorScheme = useColorScheme(); - - const headerHeight = useHeaderHeight(); - - // Theme-aware colors - const backgroundColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background"); + const isDark = colorScheme === "dark"; + const bgColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background"); const textColor = useThemeColor({ light: "#1f2937", dark: "#f9fafb" }, "text"); - const subtitleColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text"); - const iconColor = useThemeColor({ light: "#d1d5db", dark: "#6b7280" }, "icon"); - const iconSecondaryColor = useThemeColor({ light: "#e5e7eb", dark: "#4b5563" }, "icon"); + const subtextColor = useThemeColor({ light: "#6b7280", dark: "#9ca3af" }, "text"); + const inputBg = useThemeColor({ light: "#f1f5f9", dark: "#374151" }, "background"); + const headerBg = useThemeColor({ light: "#ffffff", dark: "#1f2937" }, "background"); + const borderColor = isDark ? "#374151" : "#e5e7eb"; + const ownBubbleBg = "#3b82f6"; + const otherBubbleBg = isDark ? "#374151" : "#e5e7eb"; - useEffect(() => { - if (channel) { - const checkMessages = async () => { - try { - // Get channel state to check for messages - await channel.watch(); - const messages = channel.state.messages || []; - setHasMessages(messages.length > 0); - } catch (error) { - } finally { - setIsLoading(false); - } - }; + const { + messages, + sendMessage, + sendTyping, + isConnected, + typingUsers, + onlineUsers, + loadMoreMessages, + hasMoreMessages, + markAsRead, + } = useChat(channelId); - checkMessages(); + const { data: tablos } = useTablosList(); + const { data: members } = useTabloMembers(channelId); + const tablo = tablos?.find((t) => t.id === channelId); - // Listen for new messages - const handleNewMessage = () => { - setHasMessages(true); - }; + const [inputText, setInputText] = useState(""); + const flatListRef = useRef(null); - channel.on("message.new", handleNewMessage); + // Build user lookup map + const userMap = useMemo(() => { + const map: Record = {}; + members?.forEach((m) => { map[m.id] = m; }); + return map; + }, [members]); - return () => { - channel.off("message.new", handleNewMessage); - }; - } - }, [channel]); + // Mark as read when screen gains focus + useFocusEffect( + useCallback(() => { + markAsRead(); + queryClient.invalidateQueries({ queryKey: ["chat-unread"] }); + }, [markAsRead, queryClient]) + ); - if (!channel) { - return ; - } + // Group messages for display (reversed for inverted FlatList) + const messageGroups = useMemo( + () => groupMessages(messages, currentUserId).reverse(), + [messages, currentUserId] + ); - const EmptyState = () => ( - - - - - - + const handleSend = () => { + const text = inputText.trim(); + if (!text) return; + sendMessage(text); + setInputText(""); + }; + + const handleTextChange = (text: string) => { + setInputText(text); + sendTyping(); + }; + + // Typing indicator text + const typingText = useMemo(() => { + if (typingUsers.length === 0) return null; + const names = typingUsers.map((id) => userMap[id]?.name ?? "Quelqu'un"); + if (names.length === 1) return `${names[0]} est en train d'écrire...`; + return `${names.join(" et ")} sont en train d'écrire...`; + }, [typingUsers, userMap]); + + const renderMessageGroup = ({ item }: { item: MessageGroup }) => { + const user = userMap[item.userId]; + const senderName = user?.name ?? "Utilisateur"; + const initial = senderName.charAt(0).toUpperCase(); + const timestamp = new Date(item.messages[0].createdAt).toLocaleTimeString("fr-FR", { + hour: "2-digit", + minute: "2-digit", + }); + + return ( + + {/* Avatar + name for other users */} + {!item.isOwn && ( + + + {initial} + + {senderName} + {timestamp} - - + )} + {item.isOwn && ( + + {timestamp} + + )} + + {/* Message bubbles */} + {item.messages.map((msg) => ( + + + {msg.text} + + ))} + + ); + }; + + return ( + + {/* Header */} + + router.back()} style={styles.backButton}> + + + + + {tablo?.name ?? "Discussion"} + + {onlineUsers.length > 0 && ( + + {onlineUsers.length} en ligne + + )} - Commencez la conversation - - Soyez le premier à envoyer un message dans ce canal ! - - - ); + {/* Connection banner */} + {!isConnected && ( + + Reconnexion... + + )} - return ( - - - {isLoading ? ( - - - - Chargement des messages... + + {/* Message list */} + {messages.length === 0 ? ( + + + + Commencez la conversation + + + Soyez le premier à envoyer un message ! - ) : hasMessages ? ( - ) : ( - + item.key} + renderItem={renderMessageGroup} + inverted + onEndReached={loadMoreMessages} + onEndReachedThreshold={0.3} + ListFooterComponent={ + hasMoreMessages ? ( + + ) : null + } + contentContainerStyle={styles.messageListContent} + /> )} - - - - + + {/* Typing indicator */} + {typingText && ( + + {typingText} + + )} + + {/* Input */} + + + + + + + ); } const styles = StyleSheet.create({ - loadingContainer: { + container: { flex: 1, - justifyContent: "center", + }, + header: { + flexDirection: "row", alignItems: "center", - gap: 16, + paddingHorizontal: 12, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + gap: 10, }, - loadingText: { - fontSize: 16, - fontWeight: "500", + backButton: { + padding: 4, }, - emptyContainer: { + headerContent: { flex: 1, - justifyContent: "center", + }, + headerTitle: { + fontSize: 17, + fontWeight: "700", + }, + headerSubtitle: { + fontSize: 12, + marginTop: 1, + }, + connectionBanner: { + backgroundColor: "#f59e0b", + paddingVertical: 4, alignItems: "center", + }, + connectionText: { + color: "#ffffff", + fontSize: 12, + fontWeight: "600", + }, + content: { + flex: 1, + }, + messageListContent: { + paddingHorizontal: 12, + paddingVertical: 8, + }, + emptyState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 12, paddingHorizontal: 40, }, - emptyIconContainer: { - position: "relative", - marginBottom: 32, - width: 120, - height: 120, - justifyContent: "center", - alignItems: "center", - }, - decorativeElements: { - position: "absolute", - width: "100%", - height: "100%", - }, - floatingIcon1: { - position: "absolute", - top: 10, - right: 5, - backgroundColor: "rgba(255, 255, 255, 0.9)", - borderRadius: 15, - padding: 6, - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - floatingIcon2: { - position: "absolute", - bottom: 15, - left: 8, - backgroundColor: "rgba(255, 255, 255, 0.9)", - borderRadius: 12, - padding: 5, - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, emptyTitle: { - fontSize: 24, - fontWeight: "bold", + fontSize: 20, + fontWeight: "700", + }, + loadingMore: { + paddingVertical: 16, + }, + groupContainer: { marginBottom: 12, - textAlign: "center", + maxWidth: "80%", + alignSelf: "flex-start", }, - emptyMessage: { - fontSize: 16, - textAlign: "center", - lineHeight: 24, - marginBottom: 32, + groupContainerOwn: { + alignSelf: "flex-end", }, - emptyHint: { - backgroundColor: "rgba(255, 255, 255, 0.9)", - paddingHorizontal: 20, - paddingVertical: 12, + senderRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + marginBottom: 4, + }, + avatar: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: "#3b82f6", + alignItems: "center", + justifyContent: "center", + }, + avatarText: { + color: "#ffffff", + fontSize: 11, + fontWeight: "700", + }, + senderName: { + fontSize: 12, + fontWeight: "600", + }, + timestamp: { + fontSize: 11, + }, + timestampOwn: { + textAlign: "right", + marginBottom: 4, + }, + bubble: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 16, + marginBottom: 2, + }, + bubbleOwn: { + borderBottomRightRadius: 4, + }, + bubbleOther: { + borderBottomLeftRadius: 4, + }, + bubbleOptimistic: { + opacity: 0.7, + }, + messageText: { + fontSize: 15, + lineHeight: 20, + }, + typingBar: { + paddingHorizontal: 16, + paddingVertical: 6, + borderTopWidth: StyleSheet.hairlineWidth, + }, + typingText: { + fontSize: 12, + fontStyle: "italic", + }, + inputContainer: { + flexDirection: "row", + alignItems: "flex-end", + paddingHorizontal: 12, + paddingVertical: 8, + borderTopWidth: StyleSheet.hairlineWidth, + gap: 8, + }, + input: { + flex: 1, borderRadius: 20, - borderWidth: 1, - borderColor: "rgba(0, 0, 0, 0.1)", - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.05, - shadowRadius: 4, - elevation: 1, + paddingHorizontal: 16, + paddingVertical: 10, + fontSize: 15, + maxHeight: 100, }, - emptyHintText: { - fontSize: 14, - fontWeight: "500", - textAlign: "center", + sendButton: { + padding: 10, }, - keyboardContainer: { - flexShrink: 0, + sendButtonDisabled: { + opacity: 0.5, }, });