chat implemented
need testing
This commit is contained in:
1 parent
c90f8cb1fa
commit
9cbdc35903
40 files changed
+7068
-10
No files matched your search
@@ -34,6 +34,11 @@ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
|
||||
#
|
||||
EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||
|
||||
# Optional realtime override (defaults to EXPO_PUBLIC_API_URL host)
|
||||
# Set this when your Socket.IO service runs on a different origin/port.
|
||||
EXPO_PUBLIC_REALTIME_URL=http://localhost:3001
|
||||
EXPO_PUBLIC_REALTIME_PATH=/socket.io
|
||||
|
||||
# =============================================================================
|
||||
# App Configuration (Optional)
|
||||
# =============================================================================
|
||||
|
||||
Generated
+86
@@ -46,6 +46,7 @@
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.15.3",
|
||||
"react-native-web": "^0.21.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -4602,6 +4603,12 @@
|
||||
"@sinonjs/commons": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.6.0.tgz",
|
||||
@@ -6806,6 +6813,49 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client/node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
@@ -12740,6 +12790,34 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
@@ -14037,6 +14115,14 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.15.3",
|
||||
"react-native-web": "^0.21.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import { API_BASE_URL, API_ENDPOINTS } from "../config/api";
|
||||
import { apiClient, withAuth } from "./client";
|
||||
|
||||
export interface ChatThread {
|
||||
id: string;
|
||||
type: "gym" | "dm";
|
||||
gymId: string | null;
|
||||
trainerId: string | null;
|
||||
clientId: string | null;
|
||||
createdBy: string;
|
||||
lastMessageAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt: string | null;
|
||||
unreadCount: number;
|
||||
lastMessageBody: string | null;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
threadId: string;
|
||||
senderUserId: string;
|
||||
body: string;
|
||||
kind: "text" | "system";
|
||||
attachments: Array<{ url: string; type: string; name?: string }>;
|
||||
clientMessageId: string | null;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ThreadMessagesResponse {
|
||||
messages: ChatMessage[];
|
||||
nextCursor: number | null;
|
||||
}
|
||||
|
||||
export const chatApi = {
|
||||
getThreads: async (token: string): Promise<ChatThread[]> => {
|
||||
const response = await apiClient.get<{ threads: ChatThread[] }>(
|
||||
API_ENDPOINTS.CHAT.THREADS,
|
||||
withAuth(token),
|
||||
);
|
||||
return response.data.threads;
|
||||
},
|
||||
|
||||
getGymRoomThread: async (token: string): Promise<ChatThread> => {
|
||||
const response = await apiClient.get<{ thread: ChatThread }>(
|
||||
API_ENDPOINTS.CHAT.GYM_ROOM,
|
||||
withAuth(token),
|
||||
);
|
||||
return response.data.thread;
|
||||
},
|
||||
|
||||
getMyDmThread: async (token: string): Promise<ChatThread> => {
|
||||
const response = await apiClient.get<{ thread: ChatThread }>(
|
||||
API_ENDPOINTS.CHAT.MY_DM_THREAD,
|
||||
withAuth(token),
|
||||
);
|
||||
return response.data.thread;
|
||||
},
|
||||
|
||||
getThreadMessages: async (
|
||||
threadId: string,
|
||||
token: string,
|
||||
cursor?: number,
|
||||
): Promise<ThreadMessagesResponse> => {
|
||||
const endpoint =
|
||||
cursor === undefined
|
||||
? API_ENDPOINTS.CHAT.THREAD_MESSAGES(threadId)
|
||||
: `${API_ENDPOINTS.CHAT.THREAD_MESSAGES(threadId)}?cursor=${cursor}`;
|
||||
|
||||
const response = await apiClient.get<ThreadMessagesResponse>(
|
||||
endpoint,
|
||||
withAuth(token),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
sendMessage: async (
|
||||
threadId: string,
|
||||
body: string,
|
||||
token: string,
|
||||
clientMessageId?: string,
|
||||
): Promise<ChatMessage> => {
|
||||
const response = await apiClient.post<{ message: ChatMessage }>(
|
||||
API_ENDPOINTS.CHAT.THREAD_MESSAGES(threadId),
|
||||
{ body, clientMessageId },
|
||||
withAuth(token),
|
||||
);
|
||||
return response.data.message;
|
||||
},
|
||||
|
||||
markThreadRead: async (
|
||||
threadId: string,
|
||||
token: string,
|
||||
lastReadMessageId?: string,
|
||||
): Promise<void> => {
|
||||
await apiClient.post(
|
||||
API_ENDPOINTS.CHAT.THREAD_READ(threadId),
|
||||
{ lastReadMessageId },
|
||||
withAuth(token),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export function createChatSocket(token: string): Socket {
|
||||
const baseUrl = new URL(API_BASE_URL);
|
||||
const realtimeBase =
|
||||
process.env.EXPO_PUBLIC_REALTIME_URL ??
|
||||
`${baseUrl.protocol}//${baseUrl.host}`;
|
||||
|
||||
return io(realtimeBase, {
|
||||
path: process.env.EXPO_PUBLIC_REALTIME_PATH ?? "/socket.io",
|
||||
transports: ["websocket"],
|
||||
auth: { token },
|
||||
});
|
||||
}
|
||||
@@ -17,3 +17,4 @@ export * from "./helpers";
|
||||
export * from "./membership";
|
||||
export * from "./food";
|
||||
export * from "./gyms";
|
||||
export * from "./chat";
|
||||
@@ -83,6 +83,12 @@ export default function TabLayout() {
|
||||
title: "Plans",
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="chat"
|
||||
options={{
|
||||
title: "Chat",
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import { useChat } from "../../contexts/ChatContext";
|
||||
import { useTheme } from "../../contexts/ThemeContext";
|
||||
|
||||
export default function ChatScreen() {
|
||||
const { userId } = useAuth();
|
||||
const { colors } = useTheme();
|
||||
const {
|
||||
threads,
|
||||
activeThreadId,
|
||||
messagesByThreadId,
|
||||
readByThreadId,
|
||||
loadingThreads,
|
||||
loadingMessages,
|
||||
socketConnected,
|
||||
totalUnreadCount,
|
||||
typingByThreadId,
|
||||
setActiveThread,
|
||||
refreshMessages,
|
||||
refreshThreads,
|
||||
sendMessage,
|
||||
setTyping,
|
||||
loadOlderMessages,
|
||||
hasMoreMessages,
|
||||
isLoadingOlderMessages,
|
||||
} = useChat();
|
||||
|
||||
const [draft, setDraft] = useState("");
|
||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const activeMessages = useMemo(
|
||||
() => (activeThreadId ? (messagesByThreadId[activeThreadId] ?? []) : []),
|
||||
[activeThreadId, messagesByThreadId],
|
||||
);
|
||||
const typingUsers = useMemo(
|
||||
() => (activeThreadId ? (typingByThreadId[activeThreadId] ?? []) : []),
|
||||
[activeThreadId, typingByThreadId],
|
||||
);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!activeThreadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = draft.trim();
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraft("");
|
||||
try {
|
||||
await sendMessage(activeThreadId, body);
|
||||
setTyping(activeThreadId, false);
|
||||
} catch {
|
||||
setDraft(body);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDraftChange = (value: string) => {
|
||||
setDraft(value);
|
||||
|
||||
if (!activeThreadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTyping = value.trim().length > 0;
|
||||
setTyping(activeThreadId, isTyping);
|
||||
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
if (activeThreadId) {
|
||||
setTyping(activeThreadId, false);
|
||||
}
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
const handleLoadOlder = () => {
|
||||
if (!activeThreadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadOlderMessages(activeThreadId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (activeThreadId) {
|
||||
setTyping(activeThreadId, false);
|
||||
}
|
||||
};
|
||||
}, [activeThreadId, setTyping]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={[styles.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>
|
||||
|
||||
<View style={styles.layout}>
|
||||
<View style={[styles.sidebar, { borderColor: colors.border }]}>
|
||||
{loadingThreads ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<FlatList
|
||||
data={threads}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => {
|
||||
const selected = item.id === activeThreadId;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => setActiveThread(item.id)}
|
||||
style={[
|
||||
styles.threadItem,
|
||||
{
|
||||
backgroundColor: selected
|
||||
? colors.surface
|
||||
: "transparent",
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{ color: colors.textPrimary, fontWeight: "600" }}
|
||||
>
|
||||
{item.type === "gym" ? "Gym Room" : "Trainer Chat"}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ color: colors.textSecondary, marginTop: 2 }}
|
||||
>
|
||||
{item.lastMessageBody ?? "No messages yet"}
|
||||
</Text>
|
||||
{item.unreadCount > 0 && (
|
||||
<Text style={{ color: colors.primary, marginTop: 4 }}>
|
||||
{item.unreadCount} unread
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.chatPane, { borderColor: colors.border }]}>
|
||||
{!activeThreadId ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={{ color: colors.textSecondary }}>
|
||||
Select a thread to start chatting
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{loadingMessages ? (
|
||||
<ActivityIndicator style={{ marginTop: 16 }} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={activeMessages}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.messagesList}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loadingMessages}
|
||||
onRefresh={() =>
|
||||
activeThreadId
|
||||
? void refreshMessages(activeThreadId)
|
||||
: void refreshThreads()
|
||||
}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
ListHeaderComponent={
|
||||
activeThreadId && hasMoreMessages(activeThreadId) ? (
|
||||
<Pressable
|
||||
onPress={handleLoadOlder}
|
||||
style={[
|
||||
styles.loadOlder,
|
||||
{
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
]}
|
||||
disabled={isLoadingOlderMessages(activeThreadId)}
|
||||
>
|
||||
{isLoadingOlderMessages(activeThreadId) ? (
|
||||
<ActivityIndicator size="small" />
|
||||
) : (
|
||||
<Text style={{ color: colors.textSecondary }}>
|
||||
Load older messages
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null
|
||||
}
|
||||
renderItem={({ item }) => {
|
||||
const own = item.senderUserId === userId;
|
||||
const threadReads = activeThreadId
|
||||
? (readByThreadId[activeThreadId] ?? {})
|
||||
: {};
|
||||
const readers = Object.entries(threadReads)
|
||||
.filter(
|
||||
([readerUserId, messageId]) =>
|
||||
readerUserId !== userId && messageId === item.id,
|
||||
)
|
||||
.map(([readerUserId]) => readerUserId);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.messageBubble,
|
||||
{
|
||||
alignSelf: own ? "flex-end" : "flex-start",
|
||||
backgroundColor: own
|
||||
? colors.primary
|
||||
: colors.surface,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{ color: own ? "white" : colors.textPrimary }}
|
||||
>
|
||||
{item.body}
|
||||
</Text>
|
||||
{own && readers.length > 0 && (
|
||||
<Text
|
||||
style={{
|
||||
color: own
|
||||
? "rgba(255,255,255,0.85)"
|
||||
: colors.textSecondary,
|
||||
marginTop: 4,
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
Seen
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{typingUsers.length > 0 && (
|
||||
<View style={styles.typingRow}>
|
||||
<Text style={{ color: colors.textSecondary, fontSize: 12 }}>
|
||||
{typingUsers.length === 1
|
||||
? "Someone is typing..."
|
||||
: `${typingUsers.length} people are typing...`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.composer, { borderColor: colors.border }]}>
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChangeText={handleDraftChange}
|
||||
placeholder="Type a message"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: colors.textPrimary,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={handleSend}
|
||||
style={[
|
||||
styles.sendButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
]}
|
||||
>
|
||||
<Text style={{ color: "white", fontWeight: "700" }}>
|
||||
Send
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
connection: {
|
||||
borderBottomWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
layout: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
},
|
||||
sidebar: {
|
||||
width: 160,
|
||||
borderRightWidth: 1,
|
||||
padding: 8,
|
||||
},
|
||||
threadItem: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
padding: 10,
|
||||
marginBottom: 8,
|
||||
},
|
||||
chatPane: {
|
||||
flex: 1,
|
||||
borderLeftWidth: 0,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
messagesList: {
|
||||
padding: 12,
|
||||
gap: 8,
|
||||
},
|
||||
loadOlder: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
alignSelf: "center",
|
||||
marginBottom: 10,
|
||||
},
|
||||
messageBubble: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
maxWidth: "80%",
|
||||
},
|
||||
composer: {
|
||||
borderTopWidth: 1,
|
||||
padding: 10,
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
},
|
||||
typingRow: {
|
||||
paddingHorizontal: 14,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
sendButton: {
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { FitnessGoalsProvider } from "../contexts/FitnessGoalsContext";
|
||||
import { RecommendationsProvider } from "../contexts/RecommendationsContext";
|
||||
import { NotificationsProvider } from "../contexts/NotificationsContext";
|
||||
import { MembershipProvider } from "../contexts/MembershipContext";
|
||||
import { ChatProvider } from "../contexts/ChatContext";
|
||||
import { queryClient } from "../lib/query-client";
|
||||
import { useAutoWorkoutGeofence } from "../hooks/useAutoWorkoutGeofence";
|
||||
import log from "../utils/logger";
|
||||
@@ -184,11 +185,13 @@ export default function RootLayout() {
|
||||
<NotificationsProvider>
|
||||
<StatisticsProvider>
|
||||
<MembershipProvider>
|
||||
<FitnessGoalsProvider>
|
||||
<RecommendationsProvider>
|
||||
<AppContent />
|
||||
</RecommendationsProvider>
|
||||
</FitnessGoalsProvider>
|
||||
<ChatProvider>
|
||||
<FitnessGoalsProvider>
|
||||
<RecommendationsProvider>
|
||||
<AppContent />
|
||||
</RecommendationsProvider>
|
||||
</FitnessGoalsProvider>
|
||||
</ChatProvider>
|
||||
</MembershipProvider>
|
||||
</StatisticsProvider>
|
||||
</NotificationsProvider>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BottomTabBarProps } from "@react-navigation/bottom-tabs";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
import { useChat } from "../contexts/ChatContext";
|
||||
|
||||
export function CustomTabBar({
|
||||
state,
|
||||
@@ -12,6 +13,7 @@ export function CustomTabBar({
|
||||
}: BottomTabBarProps) {
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { totalUnreadCount } = useChat();
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -53,6 +55,8 @@ export function CustomTabBar({
|
||||
return focused ? "sparkles" : "sparkles-outline";
|
||||
case "profile":
|
||||
return focused ? "person" : "person-outline";
|
||||
case "chat":
|
||||
return focused ? "chatbubbles" : "chatbubbles-outline";
|
||||
default:
|
||||
return "ellipse-outline";
|
||||
}
|
||||
@@ -68,6 +72,8 @@ export function CustomTabBar({
|
||||
return "Plans";
|
||||
case "profile":
|
||||
return "Profile";
|
||||
case "chat":
|
||||
return "Chat";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -90,6 +96,15 @@ export function CustomTabBar({
|
||||
size={26}
|
||||
color={isFocused ? colors.primary : colors.textTertiary}
|
||||
/>
|
||||
{route.name === "chat" && totalUnreadCount > 0 && (
|
||||
<View
|
||||
style={[styles.badge, { backgroundColor: colors.danger }]}
|
||||
>
|
||||
<Text style={styles.badgeText}>
|
||||
{totalUnreadCount > 99 ? "99+" : totalUnreadCount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{isFocused && (
|
||||
<View
|
||||
style={[
|
||||
@@ -144,4 +159,20 @@ const styles = StyleSheet.create({
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
},
|
||||
badge: {
|
||||
position: "absolute",
|
||||
top: -6,
|
||||
right: -12,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
badgeText: {
|
||||
color: "white",
|
||||
fontSize: 10,
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
@@ -63,6 +63,16 @@ export const API_ENDPOINTS = {
|
||||
GET_RANGE: (startDate: string, endDate: string) =>
|
||||
`/api/hydration?startDate=${startDate}&endDate=${endDate}`,
|
||||
},
|
||||
CHAT: {
|
||||
THREADS: "/api/chat/threads",
|
||||
GYM_ROOM: "/api/chat/gym-room",
|
||||
START_DM: "/api/chat/dm/start",
|
||||
MY_DM_THREAD: "/api/chat/dm/my-thread",
|
||||
THREAD: (threadId: string) => `/api/chat/threads/${threadId}`,
|
||||
THREAD_MESSAGES: (threadId: string) =>
|
||||
`/api/chat/threads/${threadId}/messages`,
|
||||
THREAD_READ: (threadId: string) => `/api/chat/threads/${threadId}/read`,
|
||||
},
|
||||
FITNESS_GOALS: {
|
||||
LIST: "/api/fitness-goals",
|
||||
CREATE: "/api/fitness-goals",
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useAuth, useUser } from "@clerk/clerk-expo";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import {
|
||||
chatApi,
|
||||
createChatSocket,
|
||||
type ChatMessage,
|
||||
type ChatThread,
|
||||
} from "../api/chat";
|
||||
import log from "../utils/logger";
|
||||
|
||||
interface ChatContextValue {
|
||||
threads: ChatThread[];
|
||||
totalUnreadCount: number;
|
||||
activeThreadId: string | null;
|
||||
messagesByThreadId: Record<string, ChatMessage[]>;
|
||||
readByThreadId: Record<string, Record<string, string>>;
|
||||
loadingThreads: boolean;
|
||||
loadingMessages: boolean;
|
||||
socketConnected: boolean;
|
||||
typingByThreadId: Record<string, string[]>;
|
||||
setActiveThread: (threadId: string | null) => void;
|
||||
refreshThreads: () => Promise<void>;
|
||||
refreshMessages: (threadId: string) => Promise<void>;
|
||||
loadOlderMessages: (threadId: string) => Promise<void>;
|
||||
hasMoreMessages: (threadId: string) => boolean;
|
||||
isLoadingOlderMessages: (threadId: string) => boolean;
|
||||
sendMessage: (threadId: string, body: string) => Promise<void>;
|
||||
setTyping: (threadId: string, isTyping: boolean) => void;
|
||||
markThreadRead: (
|
||||
threadId: string,
|
||||
lastReadMessageId?: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextValue | undefined>(undefined);
|
||||
|
||||
export function ChatProvider({ children }: { children: React.ReactNode }) {
|
||||
const { isSignedIn, getToken } = useAuth();
|
||||
const { user } = useUser();
|
||||
const userRole =
|
||||
typeof user?.publicMetadata?.role === "string"
|
||||
? user.publicMetadata.role
|
||||
: undefined;
|
||||
const isClientUser = userRole === "client";
|
||||
|
||||
const [threads, setThreads] = useState<ChatThread[]>([]);
|
||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
||||
const [messagesByThreadId, setMessagesByThreadId] = useState<
|
||||
Record<string, ChatMessage[]>
|
||||
>({});
|
||||
const [readByThreadId, setReadByThreadId] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
const [loadingThreads, setLoadingThreads] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [socketConnected, setSocketConnected] = useState(false);
|
||||
const [typingByThreadId, setTypingByThreadId] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
const [nextCursorByThreadId, setNextCursorByThreadId] = useState<
|
||||
Record<string, number | null | undefined>
|
||||
>({});
|
||||
const [loadingOlderByThreadId, setLoadingOlderByThreadId] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const lastMarkedReadByThreadRef = useRef<Record<string, string>>({});
|
||||
const activeThreadIdRef = useRef<string | null>(null);
|
||||
const currentUserIdRef = useRef<string | undefined>(undefined);
|
||||
const refreshThreadsRef = useRef<() => Promise<void>>(async () => {});
|
||||
const markThreadReadRef = useRef<
|
||||
(threadId: string, lastReadMessageId?: string) => Promise<void>
|
||||
>(async () => {});
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setThreads([]);
|
||||
setActiveThreadId(null);
|
||||
setMessagesByThreadId({});
|
||||
setReadByThreadId({});
|
||||
setNextCursorByThreadId({});
|
||||
setLoadingOlderByThreadId({});
|
||||
setLoadingThreads(false);
|
||||
setLoadingMessages(false);
|
||||
setSocketConnected(false);
|
||||
setTypingByThreadId({});
|
||||
lastMarkedReadByThreadRef.current = {};
|
||||
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshThreads = useCallback(async () => {
|
||||
if (!isSignedIn) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingMessages(true);
|
||||
const response = await chatApi.getThreadMessages(threadId, token);
|
||||
setMessagesByThreadId((prev) => ({
|
||||
...prev,
|
||||
[threadId]: response.messages,
|
||||
}));
|
||||
setNextCursorByThreadId((prev) => ({
|
||||
...prev,
|
||||
[threadId]: response.nextCursor,
|
||||
}));
|
||||
|
||||
setThreads((prev) =>
|
||||
prev.map((thread) =>
|
||||
thread.id === threadId ? { ...thread, unreadCount: 0 } : thread,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn("Failed to refresh chat messages", { threadId, error });
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
},
|
||||
[getToken, isSignedIn],
|
||||
);
|
||||
|
||||
const loadOlderMessages = useCallback(
|
||||
async (threadId: string) => {
|
||||
if (!isSignedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextCursor = nextCursorByThreadId[threadId];
|
||||
if (nextCursor === null || nextCursor === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingOlderByThreadId[threadId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingOlderByThreadId((prev) => ({ ...prev, [threadId]: true }));
|
||||
|
||||
const response = await chatApi.getThreadMessages(
|
||||
threadId,
|
||||
token,
|
||||
nextCursor,
|
||||
);
|
||||
|
||||
setMessagesByThreadId((prev) => {
|
||||
const existing = prev[threadId] ?? [];
|
||||
const merged = [...response.messages, ...existing];
|
||||
const dedupedById = new Map<string, ChatMessage>();
|
||||
merged.forEach((message) => {
|
||||
dedupedById.set(message.id, message);
|
||||
});
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[threadId]: Array.from(dedupedById.values()).sort(
|
||||
(a, b) =>
|
||||
new Date(a.createdAt).getTime() -
|
||||
new Date(b.createdAt).getTime(),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
setNextCursorByThreadId((prev) => ({
|
||||
...prev,
|
||||
[threadId]: response.nextCursor,
|
||||
}));
|
||||
} catch (error) {
|
||||
log.warn("Failed to load older chat messages", { threadId, error });
|
||||
} finally {
|
||||
setLoadingOlderByThreadId((prev) => ({ ...prev, [threadId]: false }));
|
||||
}
|
||||
},
|
||||
[getToken, isSignedIn, loadingOlderByThreadId, nextCursorByThreadId],
|
||||
);
|
||||
|
||||
const markThreadRead = useCallback(
|
||||
async (threadId: string, lastReadMessageId?: string) => {
|
||||
if (!isSignedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await chatApi.markThreadRead(threadId, token, lastReadMessageId);
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.emit("chat:read", {
|
||||
threadId,
|
||||
lastReadMessageId,
|
||||
});
|
||||
}
|
||||
|
||||
if (lastReadMessageId && user?.id) {
|
||||
setReadByThreadId((prev) => ({
|
||||
...prev,
|
||||
[threadId]: {
|
||||
...(prev[threadId] ?? {}),
|
||||
[user.id]: lastReadMessageId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Failed to mark thread read", { threadId, error });
|
||||
}
|
||||
},
|
||||
[getToken, isSignedIn, user?.id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
activeThreadIdRef.current = activeThreadId;
|
||||
}, [activeThreadId]);
|
||||
|
||||
useEffect(() => {
|
||||
currentUserIdRef.current = user?.id;
|
||||
}, [user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshThreadsRef.current = refreshThreads;
|
||||
}, [refreshThreads]);
|
||||
|
||||
useEffect(() => {
|
||||
markThreadReadRef.current = markThreadRead;
|
||||
}, [markThreadRead]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (threadId: string, body: string) => {
|
||||
const text = body.trim();
|
||||
if (!text || !isSignedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientMessageId = `mobile_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const optimistic: ChatMessage = {
|
||||
id: clientMessageId,
|
||||
threadId,
|
||||
senderUserId: user?.id ?? "",
|
||||
body: text,
|
||||
kind: "text",
|
||||
attachments: [],
|
||||
clientMessageId,
|
||||
createdAt: new Date().toISOString(),
|
||||
editedAt: null,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
setMessagesByThreadId((prev) => ({
|
||||
...prev,
|
||||
[threadId]: [...(prev[threadId] ?? []), optimistic],
|
||||
}));
|
||||
|
||||
try {
|
||||
if (socketRef.current && socketConnected) {
|
||||
socketRef.current.emit("chat:send", {
|
||||
threadId,
|
||||
body: text,
|
||||
clientMessageId,
|
||||
});
|
||||
} else {
|
||||
const persisted = await chatApi.sendMessage(
|
||||
threadId,
|
||||
text,
|
||||
token,
|
||||
clientMessageId,
|
||||
);
|
||||
|
||||
setMessagesByThreadId((prev) => ({
|
||||
...prev,
|
||||
[threadId]: (prev[threadId] ?? []).map((msg) =>
|
||||
msg.id === clientMessageId ? persisted : msg,
|
||||
),
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setMessagesByThreadId((prev) => ({
|
||||
...prev,
|
||||
[threadId]: (prev[threadId] ?? []).filter(
|
||||
(msg) => msg.id !== clientMessageId,
|
||||
),
|
||||
}));
|
||||
|
||||
log.warn("Failed to send chat message", { threadId, error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[getToken, isSignedIn, socketConnected, user?.id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedIn) {
|
||||
clearAll();
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const setupSocket = async () => {
|
||||
const token = await getToken();
|
||||
if (!token || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = createChatSocket(token);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect", () => {
|
||||
setSocketConnected(true);
|
||||
void refreshThreadsRef.current();
|
||||
|
||||
if (activeThreadIdRef.current) {
|
||||
socket.emit("chat:subscribe", {
|
||||
threadId: activeThreadIdRef.current,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
setSocketConnected(false);
|
||||
setTypingByThreadId({});
|
||||
});
|
||||
|
||||
socket.on(
|
||||
"chat:message:new",
|
||||
(event: { threadId: string; message: ChatMessage }) => {
|
||||
setMessagesByThreadId((prev) => {
|
||||
const current = prev[event.threadId] ?? [];
|
||||
if (current.some((msg) => msg.id === event.message.id)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const deduped = current.filter(
|
||||
(msg) =>
|
||||
!(
|
||||
msg.clientMessageId &&
|
||||
event.message.clientMessageId &&
|
||||
msg.clientMessageId === event.message.clientMessageId
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[event.threadId]: [...deduped, event.message],
|
||||
};
|
||||
});
|
||||
|
||||
setThreads((prev) =>
|
||||
prev
|
||||
.map((thread) => {
|
||||
if (thread.id !== event.threadId) {
|
||||
return thread;
|
||||
}
|
||||
|
||||
const isIncoming =
|
||||
event.message.senderUserId !== currentUserIdRef.current;
|
||||
const incrementUnread =
|
||||
isIncoming && event.threadId !== activeThreadIdRef.current;
|
||||
|
||||
return {
|
||||
...thread,
|
||||
lastMessageAt: event.message.createdAt,
|
||||
lastMessageBody: event.message.body,
|
||||
unreadCount: incrementUnread
|
||||
? thread.unreadCount + 1
|
||||
: thread.unreadCount,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aTime = a.lastMessageAt ?? a.createdAt;
|
||||
const bTime = b.lastMessageAt ?? b.createdAt;
|
||||
return new Date(bTime).getTime() - new Date(aTime).getTime();
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
event.threadId === activeThreadIdRef.current &&
|
||||
event.message.senderUserId !== currentUserIdRef.current
|
||||
) {
|
||||
void markThreadReadRef.current(event.threadId, event.message.id);
|
||||
setThreads((prev) =>
|
||||
prev.map((thread) =>
|
||||
thread.id === event.threadId
|
||||
? { ...thread, unreadCount: 0 }
|
||||
: thread,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
socket.on(
|
||||
"chat:message:ack",
|
||||
(event: {
|
||||
threadId: string;
|
||||
message: ChatMessage;
|
||||
clientMessageId?: string | null;
|
||||
}) => {
|
||||
setMessagesByThreadId((prev) => ({
|
||||
...prev,
|
||||
[event.threadId]: (prev[event.threadId] ?? []).map((msg) =>
|
||||
event.clientMessageId
|
||||
? msg.clientMessageId === event.clientMessageId
|
||||
? event.message
|
||||
: msg
|
||||
: msg.id === event.message.id
|
||||
? event.message
|
||||
: msg,
|
||||
),
|
||||
}));
|
||||
|
||||
setThreads((prev) =>
|
||||
prev
|
||||
.map((thread) =>
|
||||
thread.id === event.threadId
|
||||
? {
|
||||
...thread,
|
||||
lastMessageAt: event.message.createdAt,
|
||||
lastMessageBody: event.message.body,
|
||||
}
|
||||
: thread,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aTime = a.lastMessageAt ?? a.createdAt;
|
||||
const bTime = b.lastMessageAt ?? b.createdAt;
|
||||
return new Date(bTime).getTime() - new Date(aTime).getTime();
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
socket.on(
|
||||
"chat:read:update",
|
||||
(event: {
|
||||
threadId?: string;
|
||||
userId?: string;
|
||||
lastReadMessageId?: string | null;
|
||||
}) => {
|
||||
if (!event.threadId || !event.userId || !event.lastReadMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReadByThreadId((prev) => ({
|
||||
...prev,
|
||||
[event.threadId!]: {
|
||||
...(prev[event.threadId!] ?? {}),
|
||||
[event.userId!]: event.lastReadMessageId!,
|
||||
},
|
||||
}));
|
||||
|
||||
void refreshThreadsRef.current();
|
||||
},
|
||||
);
|
||||
|
||||
socket.on(
|
||||
"chat:typing",
|
||||
(event: { threadId?: string; userId?: string; isTyping?: boolean }) => {
|
||||
const threadId = event?.threadId;
|
||||
const typingUserId = event?.userId;
|
||||
if (
|
||||
!threadId ||
|
||||
!typingUserId ||
|
||||
typingUserId === currentUserIdRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTypingByThreadId((prev) => {
|
||||
const current = prev[threadId] ?? [];
|
||||
if (event.isTyping) {
|
||||
if (current.includes(typingUserId)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[threadId]: [...current, typingUserId],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[threadId]: current.filter((id) => id !== typingUserId),
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
socket.on("chat:error", (event) => {
|
||||
log.warn("Chat socket error event", { event });
|
||||
});
|
||||
};
|
||||
|
||||
void setupSocket();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [clearAll, getToken, isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshThreadsRef.current();
|
||||
}, [isSignedIn, userRole]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeThreadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (socketRef.current && socketConnected) {
|
||||
socketRef.current.emit("chat:subscribe", { threadId: activeThreadId });
|
||||
}
|
||||
|
||||
void refreshMessages(activeThreadId);
|
||||
|
||||
setThreads((prev) =>
|
||||
prev.map((thread) =>
|
||||
thread.id === activeThreadId ? { ...thread, unreadCount: 0 } : thread,
|
||||
),
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.emit("chat:unsubscribe", {
|
||||
threadId: activeThreadId,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [activeThreadId, refreshMessages, socketConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeThreadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = messagesByThreadId[activeThreadId] ?? [];
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (!lastMessage || lastMessage.senderUserId === user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastMarkedReadByThreadRef.current[activeThreadId] === lastMessage.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastMarkedReadByThreadRef.current[activeThreadId] = lastMessage.id;
|
||||
void markThreadRead(activeThreadId, lastMessage.id);
|
||||
}, [activeThreadId, markThreadRead, messagesByThreadId, user?.id]);
|
||||
|
||||
const setActiveThread = useCallback((threadId: string | null) => {
|
||||
setActiveThreadId(threadId);
|
||||
if (!threadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setThreads((prev) =>
|
||||
prev.map((thread) =>
|
||||
thread.id === threadId ? { ...thread, unreadCount: 0 } : thread,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setTyping = useCallback(
|
||||
(threadId: string, isTyping: boolean) => {
|
||||
if (!socketRef.current || !socketConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
socketRef.current.emit("chat:typing", { threadId, isTyping });
|
||||
},
|
||||
[socketConnected],
|
||||
);
|
||||
|
||||
const hasMoreMessages = useCallback(
|
||||
(threadId: string) =>
|
||||
nextCursorByThreadId[threadId] !== null &&
|
||||
nextCursorByThreadId[threadId] !== undefined,
|
||||
[nextCursorByThreadId],
|
||||
);
|
||||
|
||||
const isLoadingOlderMessages = useCallback(
|
||||
(threadId: string) => Boolean(loadingOlderByThreadId[threadId]),
|
||||
[loadingOlderByThreadId],
|
||||
);
|
||||
|
||||
const totalUnreadCount = useMemo(
|
||||
() => threads.reduce((sum, thread) => sum + thread.unreadCount, 0),
|
||||
[threads],
|
||||
);
|
||||
|
||||
const value = useMemo<ChatContextValue>(
|
||||
() => ({
|
||||
threads,
|
||||
totalUnreadCount,
|
||||
activeThreadId,
|
||||
messagesByThreadId,
|
||||
readByThreadId,
|
||||
loadingThreads,
|
||||
loadingMessages,
|
||||
socketConnected,
|
||||
typingByThreadId,
|
||||
setActiveThread,
|
||||
refreshThreads,
|
||||
refreshMessages,
|
||||
loadOlderMessages,
|
||||
hasMoreMessages,
|
||||
isLoadingOlderMessages,
|
||||
sendMessage,
|
||||
setTyping,
|
||||
markThreadRead,
|
||||
}),
|
||||
[
|
||||
threads,
|
||||
totalUnreadCount,
|
||||
activeThreadId,
|
||||
messagesByThreadId,
|
||||
readByThreadId,
|
||||
loadingThreads,
|
||||
loadingMessages,
|
||||
socketConnected,
|
||||
typingByThreadId,
|
||||
setActiveThread,
|
||||
refreshThreads,
|
||||
refreshMessages,
|
||||
loadOlderMessages,
|
||||
hasMoreMessages,
|
||||
isLoadingOlderMessages,
|
||||
sendMessage,
|
||||
setTyping,
|
||||
markThreadRead,
|
||||
],
|
||||
);
|
||||
|
||||
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
||||
}
|
||||
|
||||
export function useChat(): ChatContextValue {
|
||||
const context = useContext(ChatContext);
|
||||
if (!context) {
|
||||
throw new Error("useChat must be used within ChatProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in new issue
Block a user