fitaiProto/apps/mobile/src/app/(tabs)/chat.tsx
2026-04-03 05:29:39 +02:00

604 lines
17 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
FlatList,
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, typography } = useTheme();
const {
threads,
activeThreadId,
messagesByThreadId,
readByThreadId,
loadingThreads,
loadingMessages,
socketConnected,
totalUnreadCount,
typingByThreadId,
setActiveThread,
refreshMessages,
refreshThreads,
sendMessage,
setTyping,
loadOlderMessages,
hasMoreMessages,
isLoadingOlderMessages,
} = useChat();
const [draft, setDraft] = useState("");
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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;
}
const body = draft.trim();
if (!body) {
return;
}
setDraft("");
try {
await sendMessage(activeThreadId, body);
setTyping(activeThreadId, false);
} catch {
setDraft(body);
}
};
const handleDraftChange = (value: string) => {
setDraft(value);
if (!activeThreadId) {
return;
}
setTyping(activeThreadId, value.trim().length > 0);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
if (activeThreadId) {
setTyping(activeThreadId, false);
}
}, 1200);
};
const handleLoadOlder = () => {
if (!activeThreadId) {
return;
}
void loadOlderMessages(activeThreadId);
};
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
if (activeThreadId) {
setTyping(activeThreadId, false);
}
};
}, [activeThreadId, setTyping]);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.headerWrap}>
<SectionHeader
title="Messages"
subtitle={
socketConnected
? `${totalUnreadCount} unread across chats`
: "Reconnecting..."
}
/>
</View>
<View style={styles.content}>
<View style={styles.threadRail}>
<Text style={[styles.railLabel, typography.label]}>
Conversations
</Text>
{loadingThreads ? (
<View style={styles.loaderWrap}>
<ActivityIndicator color={colors.primary} />
</View>
) : (
<FlatList
data={orderedThreads}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.threadListContent}
renderItem={({ item }) => {
const selected = item.id === activeThreadId;
return (
<Pressable onPress={() => setActiveThread(item.id)}>
<MinimalCard
variant={selected ? "elevated" : "bordered"}
padding={10}
style={[
styles.threadCard,
selected && {
borderWidth: 1,
borderColor: colors.primary,
},
]}
>
<View style={styles.threadCardTopRow}>
<View style={styles.threadTypeChip}>
<Ionicons
name={item.type === "gym" ? "people" : "person"}
size={12}
color={colors.primary}
/>
<Text
style={[
styles.threadTypeText,
{ color: colors.primary },
]}
>
{item.type === "gym" ? "Gym" : "Direct"}
</Text>
</View>
{item.unreadCount > 0 && (
<View
style={[
styles.threadUnreadBadge,
{ backgroundColor: colors.danger },
]}
>
<Text style={styles.threadUnreadText}>
{item.unreadCount > 9 ? "9+" : item.unreadCount}
</Text>
</View>
)}
</View>
<Text
numberOfLines={1}
style={[
styles.threadTitle,
{ color: colors.textPrimary },
]}
>
{item.type === "gym" ? "Gym Room" : "Trainer Chat"}
</Text>
<Text
numberOfLines={2}
style={[
styles.threadPreview,
{ color: colors.textSecondary },
]}
>
{item.lastMessageBody ?? "No messages yet"}
</Text>
</MinimalCard>
</Pressable>
);
}}
/>
)}
</View>
<KeyboardAvoidingView
style={styles.chatArea}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
{!activeThreadId ? (
<MinimalCard variant="bordered" style={styles.emptyCard}>
<Ionicons
name="chatbox-ellipses-outline"
size={34}
color={colors.textTertiary}
/>
<Text
style={[
typography.h3,
{ color: colors.textPrimary, marginTop: 10 },
]}
>
Pick a chat
</Text>
<Text
style={[
typography.body,
{ color: colors.textSecondary, marginTop: 6 },
]}
>
Choose a conversation on the left to start messaging.
</Text>
</MinimalCard>
) : (
<>
<MinimalCard
variant="default"
padding={12}
style={styles.chatHeadCard}
>
<Text style={[typography.h4, { color: colors.textPrimary }]}>
{activeThread?.type === "gym" ? "Gym Room" : "Trainer Chat"}
</Text>
<Text
style={[typography.caption, { color: colors.textSecondary }]}
>
{socketConnected
? "Live updates enabled"
: "Offline sync active"}
</Text>
</MinimalCard>
{loadingMessages ? (
<View style={styles.loaderWrap}>
<ActivityIndicator color={colors.primary} />
</View>
) : (
<FlatList
data={activeMessages}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.messagesContent}
refreshControl={
<RefreshControl
refreshing={loadingMessages}
onRefresh={() =>
activeThreadId
? void refreshMessages(activeThreadId)
: void refreshThreads()
}
tintColor={colors.primary}
/>
}
ListHeaderComponent={
activeThreadId && hasMoreMessages(activeThreadId) ? (
<Pressable onPress={handleLoadOlder}>
<MinimalCard
variant="bordered"
padding={8}
style={styles.loadOlderCard}
>
{isLoadingOlderMessages(activeThreadId) ? (
<ActivityIndicator
size="small"
color={colors.primary}
/>
) : (
<Text
style={[
typography.caption,
{ color: colors.textSecondary },
]}
>
Load earlier messages
</Text>
)}
</MinimalCard>
</Pressable>
) : null
}
renderItem={({ item }) => {
const own = item.senderUserId === userId;
const threadReads = activeThreadId
? (readByThreadId[activeThreadId] ?? {})
: {};
const seen = Object.entries(threadReads).some(
([readerUserId, messageId]) =>
readerUserId !== userId && messageId === item.id,
);
return (
<View
style={[
styles.messageBubble,
{
alignSelf: own ? "flex-end" : "flex-start",
backgroundColor: own
? colors.primary
: colors.surface,
borderColor: own
? colors.primaryDark
: colors.border,
},
]}
>
<Text
style={[
typography.body,
{ color: own ? colors.white : colors.textPrimary },
]}
>
{item.body}
</Text>
<View style={styles.metaRow}>
<Text
style={{
fontSize: 10,
color: own
? "rgba(255,255,255,0.82)"
: colors.textTertiary,
}}
>
{formatTime(item.createdAt)}
</Text>
{own && seen && (
<Text
style={{
marginLeft: 8,
fontSize: 10,
fontWeight: "700",
color: own
? "rgba(255,255,255,0.92)"
: colors.textSecondary,
}}
>
Seen
</Text>
)}
</View>
</View>
);
}}
/>
)}
{typingUsers.length > 0 && (
<View style={styles.typingRow}>
<Ionicons
name="ellipsis-horizontal"
size={14}
color={colors.textSecondary}
/>
<Text
style={[
typography.caption,
{ color: colors.textSecondary },
]}
>
{typingUsers.length === 1
? "Someone is typing..."
: `${typingUsers.length} people are typing...`}
</Text>
</View>
)}
<View
style={[styles.composer, { borderTopColor: colors.border }]}
>
<TextInput
value={draft}
onChangeText={handleDraftChange}
placeholder="Message"
placeholderTextColor={colors.textTertiary}
multiline
maxLength={2000}
style={[
styles.input,
{
color: colors.textPrimary,
borderColor: colors.border,
backgroundColor: colors.surface,
},
]}
/>
<Pressable
onPress={handleSend}
style={[
styles.sendButton,
{
backgroundColor: draft.trim()
? colors.primary
: colors.border,
},
]}
>
<Ionicons name="send" size={15} color={colors.white} />
</Pressable>
</View>
</>
)}
</KeyboardAvoidingView>
</View>
</View>
);
}
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,
},
headerWrap: {
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 8,
},
content: {
flex: 1,
flexDirection: "row",
paddingHorizontal: 12,
paddingBottom: 8,
gap: 10,
},
threadRail: {
width: 148,
},
railLabel: {
marginBottom: 8,
},
loaderWrap: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
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,
},
loadOlderCard: {
alignSelf: "center",
borderRadius: 12,
marginBottom: 10,
},
messageBubble: {
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 12,
paddingVertical: 9,
maxWidth: "84%",
},
metaRow: {
marginTop: 4,
flexDirection: "row",
alignItems: "center",
},
typingRow: {
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: 14,
paddingHorizontal: 12,
paddingVertical: 9,
minHeight: 42,
maxHeight: 110,
textAlignVertical: "top",
fontSize: 14,
},
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: "center",
justifyContent: "center",
},
});