chat needs redesign
This commit is contained in:
parent
3f39a3d05a
commit
a658eaf65c
Binary file not shown.
@ -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<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;
|
||||
@ -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 (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={[styles.connection, { borderColor: colors.border }]}>
|
||||
<Text style={{ color: colors.textSecondary }}>
|
||||
{socketConnected ? "Realtime connected" : "Realtime disconnected"}
|
||||
</Text>
|
||||
<Text style={{ color: colors.textTertiary, marginTop: 2 }}>
|
||||
{totalUnreadCount} unread total
|
||||
</Text>
|
||||
<View style={styles.headerWrap}>
|
||||
<SectionHeader
|
||||
title="Messages"
|
||||
subtitle={
|
||||
socketConnected
|
||||
? `${totalUnreadCount} unread across chats`
|
||||
: "Reconnecting..."
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.layout}>
|
||||
<View style={[styles.sidebar, { borderColor: colors.border }]}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.threadRail}>
|
||||
<Text style={[styles.railLabel, typography.label]}>
|
||||
Conversations
|
||||
</Text>
|
||||
{loadingThreads ? (
|
||||
<ActivityIndicator />
|
||||
<View style={styles.loaderWrap}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={threads}
|
||||
data={orderedThreads}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.threadListContent}
|
||||
renderItem={({ item }) => {
|
||||
const selected = item.id === activeThreadId;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => setActiveThread(item.id)}
|
||||
<Pressable onPress={() => setActiveThread(item.id)}>
|
||||
<MinimalCard
|
||||
variant={selected ? "elevated" : "bordered"}
|
||||
padding={10}
|
||||
style={[
|
||||
styles.threadItem,
|
||||
{
|
||||
backgroundColor: selected
|
||||
? colors.surface
|
||||
: "transparent",
|
||||
borderColor: colors.border,
|
||||
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={{ color: colors.textPrimary, fontWeight: "600" }}
|
||||
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={1}
|
||||
style={{ color: colors.textSecondary, marginTop: 2 }}
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
styles.threadPreview,
|
||||
{ color: colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{item.lastMessageBody ?? "No messages yet"}
|
||||
</Text>
|
||||
{item.unreadCount > 0 && (
|
||||
<Text style={{ color: colors.primary, marginTop: 4 }}>
|
||||
{item.unreadCount} unread
|
||||
</Text>
|
||||
)}
|
||||
</MinimalCard>
|
||||
</Pressable>
|
||||
);
|
||||
}}
|
||||
@ -164,22 +228,63 @@ export default function ChatScreen() {
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.chatPane, { borderColor: colors.border }]}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.chatArea}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
{!activeThreadId ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={{ color: colors.textSecondary }}>
|
||||
Select a thread to start chatting
|
||||
<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>
|
||||
</View>
|
||||
<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 ? (
|
||||
<ActivityIndicator style={{ marginTop: 16 }} />
|
||||
<View style={styles.loaderWrap}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={activeMessages}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.messagesList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.messagesContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loadingMessages}
|
||||
@ -193,24 +298,28 @@ export default function ChatScreen() {
|
||||
}
|
||||
ListHeaderComponent={
|
||||
activeThreadId && hasMoreMessages(activeThreadId) ? (
|
||||
<Pressable
|
||||
onPress={handleLoadOlder}
|
||||
style={[
|
||||
styles.loadOlder,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
]}
|
||||
disabled={isLoadingOlderMessages(activeThreadId)}
|
||||
<Pressable onPress={handleLoadOlder}>
|
||||
<MinimalCard
|
||||
variant="bordered"
|
||||
padding={8}
|
||||
style={styles.loadOlderCard}
|
||||
>
|
||||
{isLoadingOlderMessages(activeThreadId) ? (
|
||||
<ActivityIndicator size="small" />
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={colors.primary}
|
||||
/>
|
||||
) : (
|
||||
<Text style={{ color: colors.textSecondary }}>
|
||||
Load older messages
|
||||
<Text
|
||||
style={[
|
||||
typography.caption,
|
||||
{ color: colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
Load earlier messages
|
||||
</Text>
|
||||
)}
|
||||
</MinimalCard>
|
||||
</Pressable>
|
||||
) : null
|
||||
}
|
||||
@ -219,12 +328,10 @@ export default function ChatScreen() {
|
||||
const threadReads = activeThreadId
|
||||
? (readByThreadId[activeThreadId] ?? {})
|
||||
: {};
|
||||
const readers = Object.entries(threadReads)
|
||||
.filter(
|
||||
const seen = Object.entries(threadReads).some(
|
||||
([readerUserId, messageId]) =>
|
||||
readerUserId !== userId && messageId === item.id,
|
||||
)
|
||||
.map(([readerUserId]) => readerUserId);
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
@ -235,29 +342,47 @@ export default function ChatScreen() {
|
||||
backgroundColor: own
|
||||
? colors.primary
|
||||
: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderColor: own
|
||||
? colors.primaryDark
|
||||
: colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{ color: own ? "white" : colors.textPrimary }}
|
||||
style={[
|
||||
typography.body,
|
||||
{ color: own ? colors.white : colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{item.body}
|
||||
</Text>
|
||||
{own && readers.length > 0 && (
|
||||
<View style={styles.metaRow}>
|
||||
<Text
|
||||
style={{
|
||||
color: own
|
||||
? "rgba(255,255,255,0.85)"
|
||||
: colors.textSecondary,
|
||||
marginTop: 4,
|
||||
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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@ -265,7 +390,17 @@ export default function ChatScreen() {
|
||||
|
||||
{typingUsers.length > 0 && (
|
||||
<View style={styles.typingRow}>
|
||||
<Text style={{ color: colors.textSecondary, fontSize: 12 }}>
|
||||
<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...`}
|
||||
@ -273,17 +408,22 @@ export default function ChatScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.composer, { borderColor: colors.border }]}>
|
||||
<View
|
||||
style={[styles.composer, { borderTopColor: colors.border }]}
|
||||
>
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChangeText={handleDraftChange}
|
||||
placeholder="Type a message"
|
||||
placeholder="Message"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
multiline
|
||||
maxLength={2000}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: colors.textPrimary,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@ -291,95 +431,173 @@ export default function ChatScreen() {
|
||||
onPress={handleSend}
|
||||
style={[
|
||||
styles.sendButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
{
|
||||
backgroundColor: draft.trim()
|
||||
? colors.primary
|
||||
: colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={{ color: "white", fontWeight: "700" }}>
|
||||
Send
|
||||
</Text>
|
||||
<Ionicons name="send" size={15} color={colors.white} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</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,
|
||||
},
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user