feat(expo): rewrite channel screen with custom chat backend

This commit is contained in:
Arthur Belleville 2026-04-15 17:11:56 +02:00
parent b321d42b89
commit 60ac81deb1

View file

@ -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<FlatList>(null);
channel.on("message.new", handleNewMessage);
// Build user lookup map
const userMap = useMemo(() => {
const map: Record<string, TabloMember> = {};
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 <ActivityIndicator />;
}
// Group messages for display (reversed for inverted FlatList)
const messageGroups = useMemo(
() => groupMessages(messages, currentUserId).reverse(),
[messages, currentUserId]
);
const EmptyState = () => (
<View style={styles.emptyContainer}>
<View style={styles.emptyIconContainer}>
<MessageCircle size={64} color={iconColor} strokeWidth={1.5} />
<View style={styles.decorativeElements}>
<View style={styles.floatingIcon1}>
<Users size={20} color={iconSecondaryColor} />
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 (
<View style={[styles.groupContainer, item.isOwn && styles.groupContainerOwn]}>
{/* Avatar + name for other users */}
{!item.isOwn && (
<View style={styles.senderRow}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{initial}</Text>
</View>
<Text style={[styles.senderName, { color: subtextColor }]}>{senderName}</Text>
<Text style={[styles.timestamp, { color: subtextColor }]}>{timestamp}</Text>
</View>
<View style={styles.floatingIcon2}>
<Smile size={18} color={iconSecondaryColor} />
)}
{item.isOwn && (
<Text style={[styles.timestamp, styles.timestampOwn, { color: subtextColor }]}>
{timestamp}
</Text>
)}
{/* Message bubbles */}
{item.messages.map((msg) => (
<View
key={msg.id}
style={[
styles.bubble,
item.isOwn
? [styles.bubbleOwn, { backgroundColor: ownBubbleBg }]
: [styles.bubbleOther, { backgroundColor: otherBubbleBg }],
msg.optimistic && styles.bubbleOptimistic,
]}
>
<Text style={[styles.messageText, { color: item.isOwn ? "#ffffff" : textColor }]}>
{msg.text}
</Text>
</View>
))}
</View>
);
};
return (
<SafeAreaView style={[styles.container, { backgroundColor: bgColor }]}>
{/* Header */}
<View style={[styles.header, { backgroundColor: headerBg, borderBottomColor: borderColor }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<ArrowLeft size={22} color={textColor} />
</TouchableOpacity>
<View style={styles.headerContent}>
<Text style={[styles.headerTitle, { color: textColor }]} numberOfLines={1}>
{tablo?.name ?? "Discussion"}
</Text>
{onlineUsers.length > 0 && (
<Text style={[styles.headerSubtitle, { color: subtextColor }]}>
{onlineUsers.length} en ligne
</Text>
)}
</View>
</View>
<Text style={[styles.emptyTitle, { color: textColor }]}>Commencez la conversation</Text>
<Text style={[styles.emptyMessage, { color: subtitleColor }]}>
Soyez le premier à envoyer un message dans ce canal !
</Text>
</View>
);
{/* Connection banner */}
{!isConnected && (
<View style={styles.connectionBanner}>
<Text style={styles.connectionText}>Reconnexion...</Text>
</View>
)}
return (
<SafeAreaView style={{ flex: 1, backgroundColor }}>
<Channel channel={channel} keyboardVerticalOffset={headerHeight}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3b82f6" />
<Text style={[styles.loadingText, { color: subtitleColor }]}>
Chargement des messages...
<KeyboardAvoidingView
style={styles.content}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
{/* Message list */}
{messages.length === 0 ? (
<View style={styles.emptyState}>
<MessageCircle size={48} color={subtextColor} strokeWidth={1.5} />
<Text style={[styles.emptyTitle, { color: textColor }]}>
Commencez la conversation
</Text>
<Text style={{ color: subtextColor, textAlign: "center" }}>
Soyez le premier à envoyer un message !
</Text>
</View>
) : hasMessages ? (
<MessageList />
) : (
<EmptyState />
<FlatList
ref={flatListRef}
data={messageGroups}
keyExtractor={(item) => item.key}
renderItem={renderMessageGroup}
inverted
onEndReached={loadMoreMessages}
onEndReachedThreshold={0.3}
ListFooterComponent={
hasMoreMessages ? (
<ActivityIndicator style={styles.loadingMore} color="#3b82f6" />
) : null
}
contentContainerStyle={styles.messageListContent}
/>
)}
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 20}
style={styles.keyboardContainer}
>
<MessageInput />
</KeyboardAvoidingView>
</Channel>
{/* Typing indicator */}
{typingText && (
<View style={[styles.typingBar, { borderTopColor: borderColor }]}>
<Text style={[styles.typingText, { color: subtextColor }]}>{typingText}</Text>
</View>
)}
{/* Input */}
<View style={[styles.inputContainer, { borderTopColor: borderColor }]}>
<TextInput
style={[styles.input, { backgroundColor: inputBg, color: textColor }]}
value={inputText}
onChangeText={handleTextChange}
placeholder="Message..."
placeholderTextColor="#9ca3af"
multiline
maxLength={4000}
/>
<TouchableOpacity
style={[styles.sendButton, !inputText.trim() && styles.sendButtonDisabled]}
onPress={handleSend}
disabled={!inputText.trim()}
>
<Send size={20} color={inputText.trim() ? "#3b82f6" : "#9ca3af"} />
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
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,
},
});