chat implemented

need testing
This commit is contained in:
dimitar committed 2026-04-03 03:25:11 +02:00
1 parent c90f8cb1fa
commit 9cbdc35903
40 files changed
+7068 -10

No files matched your search

+5
View File
@@ -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)
# =============================================================================
+86
View File
@@ -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",
+1
View File
@@ -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": {
+118
View File
@@ -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 },
});
}
+1
View File
@@ -17,3 +17,4 @@ export * from "./helpers";
export * from "./membership";
export * from "./food";
export * from "./gyms";
export * from "./chat";
+6
View File
@@ -83,6 +83,12 @@ export default function TabLayout() {
title: "Plans",
}}
/>
<Tabs.Screen
name="chat"
options={{
title: "Chat",
}}
/>
<Tabs.Screen
name="profile"
options={{
+385
View File
@@ -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,
},
});
+8 -5
View File
@@ -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",
},
});
+10
View File
@@ -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",
+723
View File
@@ -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;
}