Compare commits
3
Commits
76fa04d129
...
chatFunc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a658eaf65c | ||
|
|
3f39a3d05a | ||
|
|
573238f65e |
No files matched your search
Binary file not shown.
@@ -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
@@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user