604 lines
17 KiB
TypeScript
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",
|
|
},
|
|
});
|