Skip to content

Instantly share code, notes, and snippets.

@arnaudambro
Last active September 10, 2025 13:08
Show Gist options
  • Save arnaudambro/76da78bdd7953e46f644bc1af918d1cb to your computer and use it in GitHub Desktop.
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
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,
},
});
@arnaudambro
Copy link
Author

image image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment