feat(expo): rewrite channel screen with custom chat backend
This commit is contained in:
parent
b321d42b89
commit
60ac81deb1
1 changed files with 368 additions and 153 deletions
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue