Compare commits

...
3 Commits
Author SHA1 Message Date
dimitar a658eaf65c chat needs redesign 2026-04-03 05:29:39 +02:00
dimitar 3f39a3d05a db 2026-04-03 05:10:10 +02:00
dimitar 573238f65e live sync working 2026-04-03 05:09:55 +02:00
4 changed files with 612 additions and 251 deletions

No files matched your search

Binary file not shown.
+128 -66
View File
@@ -57,6 +57,11 @@ export default function AdminChatPage() {
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const socketRef = useRef<Socket | null>(null); const socketRef = useRef<Socket | null>(null);
const getTokenRef = useRef(getToken);
const activeThreadIdRef = useRef<string | null>(null);
const subscribedThreadIdsRef = useRef<Set<string>>(new Set());
const loadingThreadsRef = useRef(false);
const loadingMessagesRef = useRef<Record<string, boolean>>({});
const activeMessages = useMemo( const activeMessages = useMemo(
() => (activeThreadId ? (messagesByThreadId[activeThreadId] ?? []) : []), () => (activeThreadId ? (messagesByThreadId[activeThreadId] ?? []) : []),
@@ -69,71 +74,97 @@ export default function AdminChatPage() {
); );
useEffect(() => { useEffect(() => {
const loadThreads = async () => { getTokenRef.current = getToken;
const token = await getToken(); }, [getToken]);
if (!token) {
return;
}
try { useEffect(() => {
activeThreadIdRef.current = activeThreadId;
}, [activeThreadId]);
const loadThreads = async (showLoader = true) => {
if (loadingThreadsRef.current) {
return;
}
const token = await getTokenRef.current();
if (!token) {
return;
}
loadingThreadsRef.current = true;
try {
if (showLoader) {
setLoadingThreads(true); setLoadingThreads(true);
const response = await axios.get<{ threads: ChatThread[] }>( }
"/api/chat/threads", const response = await axios.get<{ threads: ChatThread[] }>(
{ "/api/chat/threads",
headers: { Authorization: `Bearer ${token}` }, {
}, headers: { Authorization: `Bearer ${token}` },
); },
);
setThreads(response.data.threads); setThreads(response.data.threads);
setActiveThreadId( setActiveThreadId((prev) => prev ?? response.data.threads[0]?.id ?? null);
(prev) => prev ?? response.data.threads[0]?.id ?? null, setReadByThreadId({});
); } finally {
setReadByThreadId({}); if (showLoader) {
} finally {
setLoadingThreads(false); setLoadingThreads(false);
} }
}; loadingThreadsRef.current = false;
}
};
void loadThreads(); const loadMessages = async (threadId: string, showLoader = true) => {
}, [getToken]); if (loadingMessagesRef.current[threadId]) {
return;
}
const token = await getTokenRef.current();
if (!token) {
return;
}
loadingMessagesRef.current[threadId] = true;
try {
if (showLoader) {
setLoadingMessages(true);
}
const response = await axios.get<{ messages: ChatMessage[] }>(
`/api/chat/threads/${threadId}/messages`,
{
headers: { Authorization: `Bearer ${token}` },
},
);
setMessagesByThreadId((prev) => ({
...prev,
[threadId]: response.data.messages,
}));
} finally {
if (showLoader) {
setLoadingMessages(false);
}
loadingMessagesRef.current[threadId] = false;
}
};
useEffect(() => {
void loadThreads(true);
}, []);
useEffect(() => { useEffect(() => {
if (!activeThreadId) { if (!activeThreadId) {
return; return;
} }
const loadMessages = async () => { void loadMessages(activeThreadId, true);
const token = await getToken(); }, [activeThreadId]);
if (!token) {
return;
}
try {
setLoadingMessages(true);
const response = await axios.get<{ messages: ChatMessage[] }>(
`/api/chat/threads/${activeThreadId}/messages`,
{
headers: { Authorization: `Bearer ${token}` },
},
);
setMessagesByThreadId((prev) => ({
...prev,
[activeThreadId]: response.data.messages,
}));
} finally {
setLoadingMessages(false);
}
};
void loadMessages();
}, [activeThreadId, getToken]);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
const setupSocket = async () => { const setupSocket = async () => {
const token = await getToken(); const token = await getTokenRef.current();
if (!token || !mounted) { if (!token || !mounted) {
return; return;
} }
@@ -151,14 +182,14 @@ export default function AdminChatPage() {
socket.on("connect", () => { socket.on("connect", () => {
setSocketConnected(true); setSocketConnected(true);
if (activeThreadId) { subscribedThreadIdsRef.current.clear();
socket.emit("chat:subscribe", { threadId: activeThreadId }); void loadThreads(false);
}
}); });
socket.on("disconnect", () => { socket.on("disconnect", () => {
setSocketConnected(false); setSocketConnected(false);
setTypingByThreadId({}); setTypingByThreadId({});
subscribedThreadIdsRef.current.clear();
}); });
socket.on( socket.on(
@@ -185,7 +216,7 @@ export default function AdminChatPage() {
lastMessageAt: event.message.createdAt, lastMessageAt: event.message.createdAt,
lastMessageBody: event.message.body, lastMessageBody: event.message.body,
unreadCount: unreadCount:
event.threadId === activeThreadId || event.threadId === activeThreadIdRef.current ||
event.message.senderUserId === userId event.message.senderUserId === userId
? thread.unreadCount ? thread.unreadCount
: thread.unreadCount + 1, : thread.unreadCount + 1,
@@ -263,6 +294,8 @@ export default function AdminChatPage() {
[event.userId!]: event.lastReadMessageId!, [event.userId!]: event.lastReadMessageId!,
}, },
})); }));
void loadThreads(false);
}); });
socket.on("chat:error", (event: { code?: string; message?: string }) => { socket.on("chat:error", (event: { code?: string; message?: string }) => {
@@ -284,28 +317,57 @@ export default function AdminChatPage() {
socketRef.current = null; socketRef.current = null;
} }
}; };
}, [activeThreadId, getToken, userId]); }, [userId]);
useEffect(() => { useEffect(() => {
if (!activeThreadId || !socketRef.current || !socketConnected) { if (!socketRef.current || !socketConnected) {
return;
}
const socket = socketRef.current;
const nextIds = new Set(threads.map((thread) => thread.id));
threads.forEach((thread) => {
if (!subscribedThreadIdsRef.current.has(thread.id)) {
socket.emit("chat:subscribe", { threadId: thread.id });
subscribedThreadIdsRef.current.add(thread.id);
}
});
Array.from(subscribedThreadIdsRef.current).forEach((threadId) => {
if (!nextIds.has(threadId)) {
socket.emit("chat:unsubscribe", { threadId });
subscribedThreadIdsRef.current.delete(threadId);
}
});
}, [socketConnected, threads]);
useEffect(() => {
if (!activeThreadId) {
return; return;
} }
socketRef.current.emit("chat:subscribe", { threadId: activeThreadId });
setThreads((prev) => setThreads((prev) =>
prev.map((thread) => prev.map((thread) =>
thread.id === activeThreadId ? { ...thread, unreadCount: 0 } : thread, thread.id === activeThreadId ? { ...thread, unreadCount: 0 } : thread,
), ),
); );
}, [activeThreadId]);
return () => { useEffect(() => {
if (socketRef.current) { if (!socketConnected) {
socketRef.current.emit("chat:unsubscribe", { const interval = setInterval(() => {
threadId: activeThreadId, void loadThreads(false);
}); if (activeThreadIdRef.current) {
} void loadMessages(activeThreadIdRef.current, false);
}; }
}, [activeThreadId, socketConnected]); }, 5000);
return () => {
clearInterval(interval);
};
}
}, [socketConnected]);
useEffect(() => { useEffect(() => {
if (!activeThreadId) { if (!activeThreadId) {
@@ -319,7 +381,7 @@ export default function AdminChatPage() {
} }
const markRead = async () => { const markRead = async () => {
const token = await getToken(); const token = await getTokenRef.current();
if (!token) { if (!token) {
return; return;
} }
@@ -351,7 +413,7 @@ export default function AdminChatPage() {
}; };
void markRead(); void markRead();
}, [activeThreadId, getToken, messagesByThreadId, socketConnected, userId]); }, [activeThreadId, messagesByThreadId, socketConnected, userId]);
const onChangeDraft = (value: string) => { const onChangeDraft = (value: string) => {
setDraft(value); setDraft(value);
@@ -419,7 +481,7 @@ export default function AdminChatPage() {
return; return;
} }
const token = await getToken(); const token = await getTokenRef.current();
if (!token) { if (!token) {
return; return;
} }
+346 -128
View File
@@ -2,20 +2,25 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
FlatList, FlatList,
RefreshControl, KeyboardAvoidingView,
Platform,
Pressable, Pressable,
RefreshControl,
StyleSheet, StyleSheet,
Text, Text,
TextInput, TextInput,
View, View,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useAuth } from "@clerk/clerk-expo"; import { useAuth } from "@clerk/clerk-expo";
import { useChat } from "../../contexts/ChatContext"; import { useChat } from "../../contexts/ChatContext";
import { useTheme } from "../../contexts/ThemeContext"; import { useTheme } from "../../contexts/ThemeContext";
import { MinimalCard } from "../../components/MinimalCard";
import { SectionHeader } from "../../components/SectionHeader";
export default function ChatScreen() { export default function ChatScreen() {
const { userId } = useAuth(); const { userId } = useAuth();
const { colors } = useTheme(); const { colors, typography } = useTheme();
const { const {
threads, threads,
activeThreadId, activeThreadId,
@@ -38,15 +43,32 @@ export default function ChatScreen() {
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeMessages = useMemo( const activeMessages = useMemo(
() => (activeThreadId ? (messagesByThreadId[activeThreadId] ?? []) : []), () => (activeThreadId ? (messagesByThreadId[activeThreadId] ?? []) : []),
[activeThreadId, messagesByThreadId], [activeThreadId, messagesByThreadId],
); );
const typingUsers = useMemo( const typingUsers = useMemo(
() => (activeThreadId ? (typingByThreadId[activeThreadId] ?? []) : []), () => (activeThreadId ? (typingByThreadId[activeThreadId] ?? []) : []),
[activeThreadId, typingByThreadId], [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 () => { const handleSend = async () => {
if (!activeThreadId) { if (!activeThreadId) {
return; return;
@@ -73,8 +95,7 @@ export default function ChatScreen() {
return; return;
} }
const isTyping = value.trim().length > 0; setTyping(activeThreadId, value.trim().length > 0);
setTyping(activeThreadId, isTyping);
if (typingTimeoutRef.current) { if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current); clearTimeout(typingTimeoutRef.current);
@@ -109,54 +130,97 @@ export default function ChatScreen() {
return ( return (
<View style={[styles.container, { backgroundColor: colors.background }]}> <View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={[styles.connection, { borderColor: colors.border }]}> <View style={styles.headerWrap}>
<Text style={{ color: colors.textSecondary }}> <SectionHeader
{socketConnected ? "Realtime connected" : "Realtime disconnected"} title="Messages"
</Text> subtitle={
<Text style={{ color: colors.textTertiary, marginTop: 2 }}> socketConnected
{totalUnreadCount} unread total ? `${totalUnreadCount} unread across chats`
</Text> : "Reconnecting..."
}
/>
</View> </View>
<View style={styles.layout}> <View style={styles.content}>
<View style={[styles.sidebar, { borderColor: colors.border }]}> <View style={styles.threadRail}>
<Text style={[styles.railLabel, typography.label]}>
Conversations
</Text>
{loadingThreads ? ( {loadingThreads ? (
<ActivityIndicator /> <View style={styles.loaderWrap}>
<ActivityIndicator color={colors.primary} />
</View>
) : ( ) : (
<FlatList <FlatList
data={threads} data={orderedThreads}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.threadListContent}
renderItem={({ item }) => { renderItem={({ item }) => {
const selected = item.id === activeThreadId; const selected = item.id === activeThreadId;
return ( return (
<Pressable <Pressable onPress={() => setActiveThread(item.id)}>
onPress={() => setActiveThread(item.id)} <MinimalCard
style={[ variant={selected ? "elevated" : "bordered"}
styles.threadItem, padding={10}
{ style={[
backgroundColor: selected styles.threadCard,
? colors.surface selected && {
: "transparent", borderWidth: 1,
borderColor: colors.border, borderColor: colors.primary,
}, },
]} ]}
>
<Text
style={{ color: colors.textPrimary, fontWeight: "600" }}
> >
{item.type === "gym" ? "Gym Room" : "Trainer Chat"} <View style={styles.threadCardTopRow}>
</Text> <View style={styles.threadTypeChip}>
<Text <Ionicons
numberOfLines={1} name={item.type === "gym" ? "people" : "person"}
style={{ color: colors.textSecondary, marginTop: 2 }} size={12}
> color={colors.primary}
{item.lastMessageBody ?? "No messages yet"} />
</Text> <Text
{item.unreadCount > 0 && ( style={[
<Text style={{ color: colors.primary, marginTop: 4 }}> styles.threadTypeText,
{item.unreadCount} unread { 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>
)}
<Text
numberOfLines={2}
style={[
styles.threadPreview,
{ color: colors.textSecondary },
]}
>
{item.lastMessageBody ?? "No messages yet"}
</Text>
</MinimalCard>
</Pressable> </Pressable>
); );
}} }}
@@ -164,22 +228,63 @@ export default function ChatScreen() {
)} )}
</View> </View>
<View style={[styles.chatPane, { borderColor: colors.border }]}> <KeyboardAvoidingView
style={styles.chatArea}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
{!activeThreadId ? ( {!activeThreadId ? (
<View style={styles.emptyState}> <MinimalCard variant="bordered" style={styles.emptyCard}>
<Text style={{ color: colors.textSecondary }}> <Ionicons
Select a thread to start chatting name="chatbox-ellipses-outline"
size={34}
color={colors.textTertiary}
/>
<Text
style={[
typography.h3,
{ color: colors.textPrimary, marginTop: 10 },
]}
>
Pick a chat
</Text> </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 ? ( {loadingMessages ? (
<ActivityIndicator style={{ marginTop: 16 }} /> <View style={styles.loaderWrap}>
<ActivityIndicator color={colors.primary} />
</View>
) : ( ) : (
<FlatList <FlatList
data={activeMessages} data={activeMessages}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
contentContainerStyle={styles.messagesList} showsVerticalScrollIndicator={false}
contentContainerStyle={styles.messagesContent}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={loadingMessages} refreshing={loadingMessages}
@@ -193,24 +298,28 @@ export default function ChatScreen() {
} }
ListHeaderComponent={ ListHeaderComponent={
activeThreadId && hasMoreMessages(activeThreadId) ? ( activeThreadId && hasMoreMessages(activeThreadId) ? (
<Pressable <Pressable onPress={handleLoadOlder}>
onPress={handleLoadOlder} <MinimalCard
style={[ variant="bordered"
styles.loadOlder, padding={8}
{ style={styles.loadOlderCard}
borderColor: colors.border, >
backgroundColor: colors.surface, {isLoadingOlderMessages(activeThreadId) ? (
}, <ActivityIndicator
]} size="small"
disabled={isLoadingOlderMessages(activeThreadId)} color={colors.primary}
> />
{isLoadingOlderMessages(activeThreadId) ? ( ) : (
<ActivityIndicator size="small" /> <Text
) : ( style={[
<Text style={{ color: colors.textSecondary }}> typography.caption,
Load older messages { color: colors.textSecondary },
</Text> ]}
)} >
Load earlier messages
</Text>
)}
</MinimalCard>
</Pressable> </Pressable>
) : null ) : null
} }
@@ -219,12 +328,10 @@ export default function ChatScreen() {
const threadReads = activeThreadId const threadReads = activeThreadId
? (readByThreadId[activeThreadId] ?? {}) ? (readByThreadId[activeThreadId] ?? {})
: {}; : {};
const readers = Object.entries(threadReads) const seen = Object.entries(threadReads).some(
.filter( ([readerUserId, messageId]) =>
([readerUserId, messageId]) => readerUserId !== userId && messageId === item.id,
readerUserId !== userId && messageId === item.id, );
)
.map(([readerUserId]) => readerUserId);
return ( return (
<View <View
@@ -235,28 +342,46 @@ export default function ChatScreen() {
backgroundColor: own backgroundColor: own
? colors.primary ? colors.primary
: colors.surface, : colors.surface,
borderColor: colors.border, borderColor: own
? colors.primaryDark
: colors.border,
}, },
]} ]}
> >
<Text <Text
style={{ color: own ? "white" : colors.textPrimary }} style={[
typography.body,
{ color: own ? colors.white : colors.textPrimary },
]}
> >
{item.body} {item.body}
</Text> </Text>
{own && readers.length > 0 && ( <View style={styles.metaRow}>
<Text <Text
style={{ style={{
color: own
? "rgba(255,255,255,0.85)"
: colors.textSecondary,
marginTop: 4,
fontSize: 10, fontSize: 10,
color: own
? "rgba(255,255,255,0.82)"
: colors.textTertiary,
}} }}
> >
Seen {formatTime(item.createdAt)}
</Text> </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> </View>
); );
}} }}
@@ -265,7 +390,17 @@ export default function ChatScreen() {
{typingUsers.length > 0 && ( {typingUsers.length > 0 && (
<View style={styles.typingRow}> <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 {typingUsers.length === 1
? "Someone is typing..." ? "Someone is typing..."
: `${typingUsers.length} people are typing...`} : `${typingUsers.length} people are typing...`}
@@ -273,17 +408,22 @@ export default function ChatScreen() {
</View> </View>
)} )}
<View style={[styles.composer, { borderColor: colors.border }]}> <View
style={[styles.composer, { borderTopColor: colors.border }]}
>
<TextInput <TextInput
value={draft} value={draft}
onChangeText={handleDraftChange} onChangeText={handleDraftChange}
placeholder="Type a message" placeholder="Message"
placeholderTextColor={colors.textTertiary} placeholderTextColor={colors.textTertiary}
multiline
maxLength={2000}
style={[ style={[
styles.input, styles.input,
{ {
color: colors.textPrimary, color: colors.textPrimary,
borderColor: colors.border, borderColor: colors.border,
backgroundColor: colors.surface,
}, },
]} ]}
/> />
@@ -291,95 +431,173 @@ export default function ChatScreen() {
onPress={handleSend} onPress={handleSend}
style={[ style={[
styles.sendButton, styles.sendButton,
{ backgroundColor: colors.primary }, {
backgroundColor: draft.trim()
? colors.primary
: colors.border,
},
]} ]}
> >
<Text style={{ color: "white", fontWeight: "700" }}> <Ionicons name="send" size={15} color={colors.white} />
Send
</Text>
</Pressable> </Pressable>
</View> </View>
</> </>
)} )}
</View> </KeyboardAvoidingView>
</View> </View>
</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({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
connection: { headerWrap: {
borderBottomWidth: 1,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 8, paddingTop: 16,
paddingBottom: 8,
}, },
layout: { content: {
flex: 1, flex: 1,
flexDirection: "row", flexDirection: "row",
paddingHorizontal: 12,
paddingBottom: 8,
gap: 10,
}, },
sidebar: { threadRail: {
width: 160, width: 148,
borderRightWidth: 1,
padding: 8,
}, },
threadItem: { railLabel: {
borderWidth: 1,
borderRadius: 12,
padding: 10,
marginBottom: 8, marginBottom: 8,
}, },
chatPane: { loaderWrap: {
flex: 1,
borderLeftWidth: 0,
},
emptyState: {
flex: 1, flex: 1,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
messagesList: { threadListContent: {
padding: 12, 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, gap: 8,
}, },
loadOlder: { loadOlderCard: {
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 10,
paddingVertical: 8,
alignSelf: "center", alignSelf: "center",
borderRadius: 12,
marginBottom: 10, marginBottom: 10,
}, },
messageBubble: { messageBubble: {
borderRadius: 12,
borderWidth: 1, borderWidth: 1,
paddingHorizontal: 10, borderRadius: 16,
paddingVertical: 8, paddingHorizontal: 12,
maxWidth: "80%", paddingVertical: 9,
maxWidth: "84%",
}, },
composer: { metaRow: {
borderTopWidth: 1, marginTop: 4,
padding: 10,
flexDirection: "row", flexDirection: "row",
gap: 8,
alignItems: "center", alignItems: "center",
}, },
typingRow: { typingRow: {
paddingHorizontal: 14, paddingHorizontal: 6,
paddingBottom: 6, paddingBottom: 6,
flexDirection: "row",
alignItems: "center",
gap: 4,
},
composer: {
borderTopWidth: 1,
paddingTop: 8,
paddingBottom: 10,
flexDirection: "row",
alignItems: "flex-end",
gap: 8,
}, },
input: { input: {
flex: 1, flex: 1,
borderWidth: 1, borderWidth: 1,
borderRadius: 10, borderRadius: 14,
paddingHorizontal: 10, paddingHorizontal: 12,
paddingVertical: 8, paddingVertical: 9,
minHeight: 42,
maxHeight: 110,
textAlignVertical: "top",
fontSize: 14,
}, },
sendButton: { sendButton: {
borderRadius: 10, width: 40,
paddingHorizontal: 14, height: 40,
paddingVertical: 10, borderRadius: 20,
alignItems: "center",
justifyContent: "center",
}, },
}); });
+138 -57
View File
@@ -74,16 +74,26 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
>({}); >({});
const socketRef = useRef<Socket | null>(null); const socketRef = useRef<Socket | null>(null);
const getTokenRef = useRef(getToken);
const lastMarkedReadByThreadRef = useRef<Record<string, string>>({}); const lastMarkedReadByThreadRef = useRef<Record<string, string>>({});
const activeThreadIdRef = useRef<string | null>(null); const activeThreadIdRef = useRef<string | null>(null);
const currentUserIdRef = useRef<string | undefined>(undefined); const currentUserIdRef = useRef<string | undefined>(undefined);
const refreshThreadsRef = useRef<() => Promise<void>>(async () => {}); const subscribedThreadIdsRef = useRef<Set<string>>(new Set());
const refreshMessagesRef = useRef<(threadId: string) => Promise<void>>( const refreshThreadsRef = useRef<(showLoader?: boolean) => Promise<void>>(
async () => {}, async () => {},
); );
const refreshMessagesRef = useRef<
(threadId: string, showLoader?: boolean) => Promise<void>
>(async () => {});
const markThreadReadRef = useRef< const markThreadReadRef = useRef<
(threadId: string, lastReadMessageId?: string) => Promise<void> (threadId: string, lastReadMessageId?: string) => Promise<void>
>(async () => {}); >(async () => {});
const refreshingThreadsRef = useRef(false);
const refreshingMessagesRef = useRef<Record<string, boolean>>({});
useEffect(() => {
getTokenRef.current = getToken;
}, [getToken]);
const clearAll = useCallback(() => { const clearAll = useCallback(() => {
setThreads([]); setThreads([]);
@@ -97,6 +107,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
setSocketConnected(false); setSocketConnected(false);
setTypingByThreadId({}); setTypingByThreadId({});
lastMarkedReadByThreadRef.current = {}; lastMarkedReadByThreadRef.current = {};
subscribedThreadIdsRef.current.clear();
if (socketRef.current) { if (socketRef.current) {
socketRef.current.disconnect(); socketRef.current.disconnect();
@@ -104,56 +115,80 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
} }
}, []); }, []);
const refreshThreads = useCallback(async () => { const refreshThreads = useCallback(
if (!isSignedIn) { async (showLoader = true) => {
return;
}
const token = await getToken();
if (!token) {
return;
}
try {
setLoadingThreads(true);
if (isClientUser) {
try {
await chatApi.getMyDmThread(token);
} catch {
// ignore bootstrap errors for now
}
}
const data = await chatApi.getThreads(token);
setThreads(data);
setActiveThreadId((prev) => {
if (prev && data.some((thread) => thread.id === prev)) {
return prev;
}
return data.length > 0 ? data[0].id : null;
});
} catch (error) {
log.warn("Failed to refresh chat threads", { error });
} finally {
setLoadingThreads(false);
}
}, [getToken, isSignedIn, isClientUser]);
const refreshMessages = useCallback(
async (threadId: string) => {
if (!isSignedIn) { if (!isSignedIn) {
return; return;
} }
const token = await getToken(); if (refreshingThreadsRef.current) {
return;
}
refreshingThreadsRef.current = true;
const token = await getTokenRef.current();
if (!token) { if (!token) {
refreshingThreadsRef.current = false;
return; return;
} }
try { try {
setLoadingMessages(true); if (showLoader) {
setLoadingThreads(true);
}
if (isClientUser) {
try {
await chatApi.getMyDmThread(token);
} catch {
// ignore bootstrap errors for now
}
}
const data = await chatApi.getThreads(token);
setThreads(data);
setActiveThreadId((prev) => {
if (prev && data.some((thread) => thread.id === prev)) {
return prev;
}
return data.length > 0 ? data[0].id : null;
});
} catch (error) {
log.warn("Failed to refresh chat threads", { error });
} finally {
if (showLoader) {
setLoadingThreads(false);
}
refreshingThreadsRef.current = false;
}
},
[isSignedIn, isClientUser],
);
const refreshMessages = useCallback(
async (threadId: string, showLoader = true) => {
if (!isSignedIn) {
return;
}
if (refreshingMessagesRef.current[threadId]) {
return;
}
refreshingMessagesRef.current[threadId] = true;
const token = await getTokenRef.current();
if (!token) {
refreshingMessagesRef.current[threadId] = false;
return;
}
try {
if (showLoader) {
setLoadingMessages(true);
}
const response = await chatApi.getThreadMessages(threadId, token); const response = await chatApi.getThreadMessages(threadId, token);
setMessagesByThreadId((prev) => ({ setMessagesByThreadId((prev) => ({
...prev, ...prev,
@@ -172,10 +207,13 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
} catch (error) { } catch (error) {
log.warn("Failed to refresh chat messages", { threadId, error }); log.warn("Failed to refresh chat messages", { threadId, error });
} finally { } finally {
setLoadingMessages(false); if (showLoader) {
setLoadingMessages(false);
}
refreshingMessagesRef.current[threadId] = false;
} }
}, },
[getToken, isSignedIn], [isSignedIn],
); );
const loadOlderMessages = useCallback( const loadOlderMessages = useCallback(
@@ -193,7 +231,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
return; return;
} }
const token = await getToken(); const token = await getTokenRef.current();
if (!token) { if (!token) {
return; return;
} }
@@ -235,7 +273,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
setLoadingOlderByThreadId((prev) => ({ ...prev, [threadId]: false })); setLoadingOlderByThreadId((prev) => ({ ...prev, [threadId]: false }));
} }
}, },
[getToken, isSignedIn, loadingOlderByThreadId, nextCursorByThreadId], [isSignedIn, loadingOlderByThreadId, nextCursorByThreadId],
); );
const markThreadRead = useCallback( const markThreadRead = useCallback(
@@ -244,7 +282,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
return; return;
} }
const token = await getToken(); const token = await getTokenRef.current();
if (!token) { if (!token) {
return; return;
} }
@@ -271,7 +309,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
log.warn("Failed to mark thread read", { threadId, error }); log.warn("Failed to mark thread read", { threadId, error });
} }
}, },
[getToken, isSignedIn, user?.id], [isSignedIn, user?.id],
); );
useEffect(() => { useEffect(() => {
@@ -301,7 +339,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
return; return;
} }
const token = await getToken(); const token = await getTokenRef.current();
if (!token) { if (!token) {
return; return;
} }
@@ -360,7 +398,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
throw error; throw error;
} }
}, },
[getToken, isSignedIn, socketConnected, user?.id], [isSignedIn, socketConnected, user?.id],
); );
useEffect(() => { useEffect(() => {
@@ -372,7 +410,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
let mounted = true; let mounted = true;
const setupSocket = async () => { const setupSocket = async () => {
const token = await getToken(); const token = await getTokenRef.current();
if (!token || !mounted) { if (!token || !mounted) {
return; return;
} }
@@ -382,7 +420,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
socket.on("connect", () => { socket.on("connect", () => {
setSocketConnected(true); setSocketConnected(true);
void refreshThreadsRef.current(); void refreshThreadsRef.current(false);
if (activeThreadIdRef.current) { if (activeThreadIdRef.current) {
socket.emit("chat:subscribe", { socket.emit("chat:subscribe", {
@@ -394,6 +432,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
socket.on("disconnect", () => { socket.on("disconnect", () => {
setSocketConnected(false); setSocketConnected(false);
setTypingByThreadId({}); setTypingByThreadId({});
subscribedThreadIdsRef.current.clear();
}); });
socket.on( socket.on(
@@ -460,6 +499,8 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
: thread, : thread,
), ),
); );
} else if (event.threadId !== activeThreadIdRef.current) {
void refreshThreadsRef.current(false);
} }
}, },
); );
@@ -523,7 +564,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
}, },
})); }));
void refreshThreadsRef.current(); void refreshThreadsRef.current(false);
}, },
); );
@@ -575,14 +616,14 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
socketRef.current = null; socketRef.current = null;
} }
}; };
}, [clearAll, getToken, isSignedIn]); }, [clearAll, isSignedIn]);
useEffect(() => { useEffect(() => {
if (!isSignedIn) { if (!isSignedIn) {
return; return;
} }
void refreshThreadsRef.current(); void refreshThreadsRef.current(true);
}, [isSignedIn, userRole]); }, [isSignedIn, userRole]);
useEffect(() => { useEffect(() => {
@@ -594,7 +635,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
socketRef.current.emit("chat:subscribe", { threadId: activeThreadId }); socketRef.current.emit("chat:subscribe", { threadId: activeThreadId });
} }
void refreshMessagesRef.current(activeThreadId); void refreshMessagesRef.current(activeThreadId, true);
setThreads((prev) => setThreads((prev) =>
prev.map((thread) => prev.map((thread) =>
@@ -611,6 +652,46 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
}; };
}, [activeThreadId, socketConnected]); }, [activeThreadId, socketConnected]);
useEffect(() => {
if (!socketRef.current || !socketConnected) {
return;
}
const socket = socketRef.current;
const nextIds = new Set(threads.map((thread) => thread.id));
threads.forEach((thread) => {
if (!subscribedThreadIdsRef.current.has(thread.id)) {
socket.emit("chat:subscribe", { threadId: thread.id });
subscribedThreadIdsRef.current.add(thread.id);
}
});
Array.from(subscribedThreadIdsRef.current).forEach((threadId) => {
if (!nextIds.has(threadId)) {
socket.emit("chat:unsubscribe", { threadId });
subscribedThreadIdsRef.current.delete(threadId);
}
});
}, [socketConnected, threads]);
useEffect(() => {
if (!isSignedIn || socketConnected) {
return;
}
const interval = setInterval(() => {
void refreshThreadsRef.current(false);
if (activeThreadIdRef.current) {
void refreshMessagesRef.current(activeThreadIdRef.current, false);
}
}, 5000);
return () => {
clearInterval(interval);
};
}, [isSignedIn, socketConnected]);
useEffect(() => { useEffect(() => {
if (!activeThreadId) { if (!activeThreadId) {
return; return;