diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index 123a239..5f9356d 100644 Binary files a/apps/admin/data/fitai.db and b/apps/admin/data/fitai.db differ diff --git a/apps/mobile/src/app/(tabs)/chat.tsx b/apps/mobile/src/app/(tabs)/chat.tsx index dcaf5c1..05240d7 100644 --- a/apps/mobile/src/app/(tabs)/chat.tsx +++ b/apps/mobile/src/app/(tabs)/chat.tsx @@ -2,20 +2,25 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, FlatList, - RefreshControl, + KeyboardAvoidingView, + Platform, Pressable, + RefreshControl, StyleSheet, Text, TextInput, View, } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; import { useAuth } from "@clerk/clerk-expo"; import { useChat } from "../../contexts/ChatContext"; import { useTheme } from "../../contexts/ThemeContext"; +import { MinimalCard } from "../../components/MinimalCard"; +import { SectionHeader } from "../../components/SectionHeader"; export default function ChatScreen() { const { userId } = useAuth(); - const { colors } = useTheme(); + const { colors, typography } = useTheme(); const { threads, activeThreadId, @@ -38,15 +43,32 @@ export default function ChatScreen() { const [draft, setDraft] = useState(""); const typingTimeoutRef = useRef | null>(null); + const activeMessages = useMemo( () => (activeThreadId ? (messagesByThreadId[activeThreadId] ?? []) : []), [activeThreadId, messagesByThreadId], ); + const typingUsers = useMemo( () => (activeThreadId ? (typingByThreadId[activeThreadId] ?? []) : []), [activeThreadId, typingByThreadId], ); + const activeThread = useMemo( + () => threads.find((thread) => thread.id === activeThreadId) ?? null, + [activeThreadId, threads], + ); + + const orderedThreads = useMemo( + () => + [...threads].sort((a, b) => { + const aTime = a.lastMessageAt ?? a.createdAt; + const bTime = b.lastMessageAt ?? b.createdAt; + return new Date(bTime).getTime() - new Date(aTime).getTime(); + }), + [threads], + ); + const handleSend = async () => { if (!activeThreadId) { return; @@ -73,8 +95,7 @@ export default function ChatScreen() { return; } - const isTyping = value.trim().length > 0; - setTyping(activeThreadId, isTyping); + setTyping(activeThreadId, value.trim().length > 0); if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); @@ -109,54 +130,97 @@ export default function ChatScreen() { return ( - - - {socketConnected ? "Realtime connected" : "Realtime disconnected"} - - - {totalUnreadCount} unread total - + + - - + + + + Conversations + {loadingThreads ? ( - + + + ) : ( item.id} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.threadListContent} renderItem={({ item }) => { const selected = item.id === activeThreadId; return ( - setActiveThread(item.id)} - style={[ - styles.threadItem, - { - backgroundColor: selected - ? colors.surface - : "transparent", - borderColor: colors.border, - }, - ]} - > - setActiveThread(item.id)}> + - {item.type === "gym" ? "Gym Room" : "Trainer Chat"} - - - {item.lastMessageBody ?? "No messages yet"} - - {item.unreadCount > 0 && ( - - {item.unreadCount} unread + + + + + {item.type === "gym" ? "Gym" : "Direct"} + + + {item.unreadCount > 0 && ( + + + {item.unreadCount > 9 ? "9+" : item.unreadCount} + + + )} + + + + {item.type === "gym" ? "Gym Room" : "Trainer Chat"} - )} + + + {item.lastMessageBody ?? "No messages yet"} + + ); }} @@ -164,22 +228,63 @@ export default function ChatScreen() { )} - + {!activeThreadId ? ( - - - Select a thread to start chatting + + + + Pick a chat - + + Choose a conversation on the left to start messaging. + + ) : ( <> + + + {activeThread?.type === "gym" ? "Gym Room" : "Trainer Chat"} + + + {socketConnected + ? "Live updates enabled" + : "Offline sync active"} + + + {loadingMessages ? ( - + + + ) : ( item.id} - contentContainerStyle={styles.messagesList} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.messagesContent} refreshControl={ - {isLoadingOlderMessages(activeThreadId) ? ( - - ) : ( - - Load older messages - - )} + + + {isLoadingOlderMessages(activeThreadId) ? ( + + ) : ( + + Load earlier messages + + )} + ) : null } @@ -219,12 +328,10 @@ export default function ChatScreen() { const threadReads = activeThreadId ? (readByThreadId[activeThreadId] ?? {}) : {}; - const readers = Object.entries(threadReads) - .filter( - ([readerUserId, messageId]) => - readerUserId !== userId && messageId === item.id, - ) - .map(([readerUserId]) => readerUserId); + const seen = Object.entries(threadReads).some( + ([readerUserId, messageId]) => + readerUserId !== userId && messageId === item.id, + ); return ( {item.body} - {own && readers.length > 0 && ( + - Seen + {formatTime(item.createdAt)} - )} + {own && seen && ( + + Seen + + )} + ); }} @@ -265,7 +390,17 @@ export default function ChatScreen() { {typingUsers.length > 0 && ( - + + {typingUsers.length === 1 ? "Someone is typing..." : `${typingUsers.length} people are typing...`} @@ -273,17 +408,22 @@ export default function ChatScreen() { )} - + @@ -291,95 +431,173 @@ export default function ChatScreen() { onPress={handleSend} style={[ styles.sendButton, - { backgroundColor: colors.primary }, + { + backgroundColor: draft.trim() + ? colors.primary + : colors.border, + }, ]} > - - Send - + )} - + ); } +function formatTime(isoDate: string): string { + const date = new Date(isoDate); + if (Number.isNaN(date.getTime())) { + return ""; + } + + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} + const styles = StyleSheet.create({ container: { flex: 1, }, - connection: { - borderBottomWidth: 1, + headerWrap: { paddingHorizontal: 16, - paddingVertical: 8, + paddingTop: 16, + paddingBottom: 8, }, - layout: { + content: { flex: 1, flexDirection: "row", + paddingHorizontal: 12, + paddingBottom: 8, + gap: 10, }, - sidebar: { - width: 160, - borderRightWidth: 1, - padding: 8, + threadRail: { + width: 148, }, - threadItem: { - borderWidth: 1, - borderRadius: 12, - padding: 10, + railLabel: { marginBottom: 8, }, - chatPane: { - flex: 1, - borderLeftWidth: 0, - }, - emptyState: { + loaderWrap: { flex: 1, alignItems: "center", justifyContent: "center", }, - messagesList: { - padding: 12, + threadListContent: { + gap: 8, + paddingBottom: 20, + }, + threadCard: { + borderRadius: 16, + }, + threadCardTopRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 8, + }, + threadTypeChip: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + threadTypeText: { + fontSize: 11, + fontWeight: "700", + }, + threadUnreadBadge: { + minWidth: 18, + height: 18, + borderRadius: 9, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 4, + }, + threadUnreadText: { + color: "white", + fontSize: 10, + fontWeight: "700", + }, + threadTitle: { + fontSize: 13, + fontWeight: "700", + }, + threadPreview: { + fontSize: 11, + marginTop: 4, + lineHeight: 15, + }, + chatArea: { + flex: 1, + }, + emptyCard: { + flex: 1, + alignItems: "center", + justifyContent: "center", + marginBottom: 8, + }, + chatHeadCard: { + borderRadius: 14, + marginBottom: 8, + }, + messagesContent: { + paddingHorizontal: 6, + paddingBottom: 12, gap: 8, }, - loadOlder: { - borderWidth: 1, - borderRadius: 10, - paddingHorizontal: 10, - paddingVertical: 8, + loadOlderCard: { alignSelf: "center", + borderRadius: 12, marginBottom: 10, }, messageBubble: { - borderRadius: 12, borderWidth: 1, - paddingHorizontal: 10, - paddingVertical: 8, - maxWidth: "80%", + borderRadius: 16, + paddingHorizontal: 12, + paddingVertical: 9, + maxWidth: "84%", }, - composer: { - borderTopWidth: 1, - padding: 10, + metaRow: { + marginTop: 4, flexDirection: "row", - gap: 8, alignItems: "center", }, typingRow: { - paddingHorizontal: 14, + paddingHorizontal: 6, paddingBottom: 6, + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + composer: { + borderTopWidth: 1, + paddingTop: 8, + paddingBottom: 10, + flexDirection: "row", + alignItems: "flex-end", + gap: 8, }, input: { flex: 1, borderWidth: 1, - borderRadius: 10, - paddingHorizontal: 10, - paddingVertical: 8, + borderRadius: 14, + paddingHorizontal: 12, + paddingVertical: 9, + minHeight: 42, + maxHeight: 110, + textAlignVertical: "top", + fontSize: 14, }, sendButton: { - borderRadius: 10, - paddingHorizontal: 14, - paddingVertical: 10, + width: 40, + height: 40, + borderRadius: 20, + alignItems: "center", + justifyContent: "center", }, });