diff --git a/Dockerfile b/Dockerfile index cebe479..acc12ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,8 +15,8 @@ COPY . . ENV NEXT_PUBLIC_SITE_URL=https://modstagram.com ENV NODE_ENV=production -# Build با ignore errors -RUN npm run build || true +# Build (fail loudly on error) +RUN npm run build # مرحله اجرا FROM node:20-alpine AS runner @@ -35,9 +35,9 @@ COPY --from=builder /app/next-sitemap.config.js ./ ENV NODE_ENV=production ENV HOST=0.0.0.0 +ENV PORT=3001 ENV NEXT_PUBLIC_SITE_URL=https://modstagram.com -EXPOSE 3000 +EXPOSE 3001 -# اجرا با ignore errors -CMD ["sh", "-c", "npm start || true"] \ No newline at end of file +CMD ["npm", "start"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 57929a3..4684ec8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,10 +6,11 @@ services: container_name: modstagram-next restart: unless-stopped ports: - - "3004:3000" + - "3001:3001" environment: - NODE_ENV=production - HOST=0.0.0.0 + - PORT=3001 volumes: - ./:/app - /app/node_modules diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..264d4cc --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,24 @@ +/** PM2 — production frontend (nginx upstream — usually port 3004 on server) */ +module.exports = { + apps: [ + { + name: "modstagram-next", + cwd: __dirname, + script: "node_modules/next/dist/bin/next", + args: "start --port 3004 --hostname 0.0.0.0", + instances: 1, + exec_mode: "fork", + autorestart: true, + max_memory_restart: "800M", + env: { + NODE_ENV: "production", + PORT: "3004", + HOSTNAME: "0.0.0.0", + UPSTREAM_API_URL: "https://api.modstagram.ir", + NEXT_PUBLIC_SOCKET_URL: "https://api.modstagram.ir", + NEXT_PUBLIC_IMAGE_BASE_URL: "https://api.modstagram.ir/storage", + NEXT_PUBLIC_BASE_URL: "/api/v1", + }, + }, + ], +}; diff --git a/next-sitemap.config.js b/next-sitemap.config.js index 991bdfc..d2e75fd 100644 --- a/next-sitemap.config.js +++ b/next-sitemap.config.js @@ -88,7 +88,7 @@ module.exports = { // تابع additionalPaths برای اضافه کردن مسیرهای داینامیک از API additionalPaths: async (config) => { const paths = []; - const BASE_URL_API = "https://app.modstagram.ir/api/v1"; // آدرس پایه API شما + const BASE_URL_API = "https://api.modstagram.ir/api/v1"; paths.push({ loc: '/', changefreq: 'daily', diff --git a/package.json b/package.json index 19f266b..9ee987f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "scripts": { "dev": "next dev --turbopack --port 3004", "build": "next build", - "start": "next start --port 3004", + "start": "next start --port 3004 --hostname 0.0.0.0", + "start:local": "next start --port 3004 --hostname 0.0.0.0", + "pm2:prod": "pm2 start ecosystem.config.cjs --update-env", "lint": "next lint", "postbuild": "next-sitemap" }, diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml index f311c16..4c53f8b 100644 --- a/public/sitemap-0.xml +++ b/public/sitemap-0.xml @@ -1,11 +1,11 @@ -https://modstagram.com/sitemap.xml2026-07-10T11:28:38.990Zdaily0.7 -https://modstagram.com/robots.txt2026-07-10T11:28:38.990Zdaily0.7 -https://modstagram.com/explore2026-07-10T11:28:38.991Zdaily0.7 -https://modstagram.com/academy/payment/success2026-07-10T11:28:38.991Zdaily0.7 -https://modstagram.com/about-us2026-07-10T11:28:38.991Zmonthly0.8 -https://modstagram.com/academy/payment/failed2026-07-10T11:28:38.991Zdaily0.7 +https://modstagram.com/sitemap.xml2026-07-16T17:09:58.119Zdaily0.7 +https://modstagram.com/robots.txt2026-07-16T17:09:58.120Zdaily0.7 +https://modstagram.com/academy/payment/success2026-07-16T17:09:58.120Zdaily0.7 +https://modstagram.com/academy/payment/failed2026-07-16T17:09:58.120Zdaily0.7 +https://modstagram.com/about-us2026-07-16T17:09:58.120Zmonthly0.8 +https://modstagram.com/explore2026-07-16T17:09:58.120Zdaily0.7 https://modstagram.comdaily1 https://modstagram.com/projectsdaily0.9 https://modstagram.com/billboardsdaily0.9 diff --git a/scripts/deploy-server.sh b/scripts/deploy-server.sh new file mode 100644 index 0000000..e01f4f0 --- /dev/null +++ b/scripts/deploy-server.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Deploy modstagram-next on Ubuntu server (after git pull / upload) +set -euo pipefail + +APP_DIR="${APP_DIR:-/root/modstagram-next}" +cd "$APP_DIR" + +echo ">>> Node: $(node -v) | npm: $(npm -v)" + +if [ ! -f .env.production ] && [ ! -f .env.local ]; then + echo "WARN: no .env.production — copy .env.production.example and fill values" +fi + +echo ">>> npm ci ..." +npm ci + +echo ">>> npm run build ..." +npm run build + +if [ ! -d .next ]; then + echo "ERROR: .next missing — build failed" + exit 1 +fi + +echo ">>> PM2 — stop old process (may be on port 3004) ..." +pm2 delete modstagram-next 2>/dev/null || true + +echo ">>> PM2 start on port 3004 (nginx upstream) ..." +pm2 start ecosystem.config.cjs --update-env +pm2 save + +echo ">>> Verify listen ports ..." +ss -tlnp | grep -E '3001|3004' || true + +echo ">>> Health check (port 3004) ..." +sleep 3 +curl -sf -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:3004/ || { + echo "ERROR: Next.js not responding on 127.0.0.1:3004" + pm2 logs modstagram-next --lines 40 --nostream + exit 1 +} + +if ss -tlnp | grep -q ':3004'; then + echo "WARN: something still listens on 3004 — check pm2 list / duplicate processes" +fi + +echo ">>> Done. Reload nginx if you changed config:" +echo " nginx -t && systemctl reload nginx" diff --git a/scripts/server-fix-502.sh b/scripts/server-fix-502.sh new file mode 100644 index 0000000..90a50ae --- /dev/null +++ b/scripts/server-fix-502.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Run on server as root: +# cd /root/modstagram-next && bash scripts/server-fix-502.sh +set -euo pipefail + +APP_DIR="${APP_DIR:-/root/modstagram-next}" +cd "$APP_DIR" +echo ">>> modstagram-next deploy | $APP_DIR" + +# Stop anything blocking ports +pm2 delete modstagram-next 2>/dev/null || true +fuser -k 3004/tcp 2>/dev/null || true +fuser -k 3001/tcp 2>/dev/null || true + +# PM2 config — port 3004 (nginx upstream on this server) +cat > "$APP_DIR/ecosystem.config.cjs" << EOF +module.exports = { + apps: [{ + name: "modstagram-next", + cwd: "$APP_DIR", + script: "$APP_DIR/node_modules/next/dist/bin/next", + args: "start --port 3004 --hostname 0.0.0.0", + instances: 1, + exec_mode: "fork", + autorestart: true, + max_restarts: 20, + min_uptime: 5000, + max_memory_restart: "900M", + env: { + NODE_ENV: "production", + PORT: "3004", + HOSTNAME: "0.0.0.0", + UPSTREAM_API_URL: "https://api.modstagram.ir", + NEXT_PUBLIC_SOCKET_URL: "https://api.modstagram.ir", + NEXT_PUBLIC_IMAGE_BASE_URL: "https://api.modstagram.ir/storage", + NEXT_PUBLIC_BASE_URL: "/api/v1", + NEXT_PUBLIC_SITE_URL: "https://modstagram.com", + }, + }], +}; +EOF + +# Production env +if [ ! -f .env.production ]; then + cat > .env.production << 'ENVEOF' +PORT=3004 +HOSTNAME=0.0.0.0 +UPSTREAM_API_URL=https://api.modstagram.ir +NEXT_PUBLIC_SOCKET_URL=https://api.modstagram.ir +NEXT_PUBLIC_IMAGE_BASE_URL=https://api.modstagram.ir/storage +NEXT_PUBLIC_BASE_URL=/api/v1 +NEXT_PUBLIC_SITE_URL=https://modstagram.com +ENVEOF +fi + +# Copy Google Client ID from .env.local if present +if [ -f .env.local ] && grep -q GOOGLE .env.local 2>/dev/null; then + grep GOOGLE .env.local >> .env.production 2>/dev/null || true +fi + +npm pkg set scripts.start="next start --port 3004 --hostname 0.0.0.0" + +echo ">>> npm install (do NOT run npm ci — lock file on server may be old) ..." +npm install @react-oauth/google@^0.13.5 --save +npm install + +echo ">>> npm run build ..." +if ! npm run build; then + echo "" + echo "!!! BUILD FAILED — site stays 502 until build succeeds." + echo " Send the error lines above to fix the code/deps." + exit 1 +fi + +if [ ! -f .next/BUILD_ID ]; then + echo "ERROR: .next/BUILD_ID missing after build" + exit 1 +fi + +echo ">>> PM2 start ..." +pm2 start "$APP_DIR/ecosystem.config.cjs" --update-env +pm2 save + +sleep 3 +echo "" +echo ">>> PM2 status:" +pm2 list | grep -E "modstagram|name" || pm2 list + +echo "" +echo ">>> Ports:" +ss -tlnp | grep -E '3001|3004' || echo "(nothing on 3001/3004)" + +echo "" +HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3004/ || echo "000") +echo ">>> curl 127.0.0.1:3004 → HTTP $HTTP" + +if [ "$HTTP" != "200" ] && [ "$HTTP" != "304" ] && [ "$HTTP" != "307" ]; then + echo "" + echo "!!! Next.js not healthy. Last logs:" + pm2 logs modstagram-next --lines 40 --nostream + exit 1 +fi + +echo "" +echo ">>> OK — modstagram.com should work (nginx → 3004)." diff --git a/src/app/settings/chats/[username]/[id]/page.tsx b/src/app/settings/chats/[username]/[id]/page.tsx index 9af916d..e74a57a 100644 --- a/src/app/settings/chats/[username]/[id]/page.tsx +++ b/src/app/settings/chats/[username]/[id]/page.tsx @@ -5,8 +5,9 @@ import Container from "@/components/elements/Container"; import useAxios from "@/hooks/useAxios"; import { User } from "@/types/types"; import React, { useState, useEffect, useRef, useCallback } from "react"; -import { io } from "socket.io-client"; -import { SOCKET_URL } from "@/components/main/BaseUrl"; +import { + getChatSocket, +} from "@/lib/chat/socketClient"; import MessageInput from "@/components/chat/MessageInput"; import MultiImageModal from "@/components/chat/MultiImageModal"; import ChatActionBar from "@/components/chat/ChatActionBar"; @@ -22,12 +23,14 @@ import ForwardMessageModal from "@/components/chat/ForwardMessageModal"; import { AnimatePresence } from "framer-motion"; import { useQueryClient } from "@tanstack/react-query"; import { chatThreadQueryKey } from "@/lib/chat/queryKeys"; -import { appendMessageToThreadCache, normalizeThreadId } from "@/lib/chat/threadCache"; +import { normalizeThreadId, upsertMessageInThreadCache } from "@/lib/chat/threadCache"; import { useStableMessageKeys } from "@/hooks/useStableMessageKeys"; import { getStoredUserId } from "@/lib/auth/session"; import { buildExpiresAtIso } from "@/lib/chat/timedMessages"; import { isViewOnceMediaType } from "@/lib/chat/viewOnce"; -import { filterResolvedPendingMessages } from "@/lib/chat/dedupeMessages"; +import { + findPendingMatchForServer, +} from "@/lib/chat/dedupeMessages"; interface ITicketChatProps { params: Promise<{ id: string; username: string }>; @@ -67,44 +70,61 @@ function TicketChat({ params }: ITicketChatProps) { const { linkIds, getStableKey } = useStableMessageKeys(); const [receiverId, setReceiverId] = useState(chatPartnerId); const [isTyping, setIsTyping] = useState(false); - const socketRef = useRef | null>(null); const typingTimeoutRef = useRef(null); const activeReplyRef = useRef(null); - useEffect(() => { - if (!user?._id || !receiverId) return; - const socket = io(SOCKET_URL, { - transports: ["websocket", "polling"], - reconnection: true, - reconnectionAttempts: Infinity, - reconnectionDelay: 800, - }); - socketRef.current = socket; + const handleIncomingMessage = useCallback( + (msg: ChatMessage) => { + setPendingMessages((prev) => { + const match = findPendingMatchForServer(msg, prev); + if (match) linkIds(match._id, msg._id); + return prev; + }); + }, + [linkIds] + ); - // عضویت در روم‌ها روی هر اتصال/اتصال مجدد تا بعد از قطعی هم پیام‌ها برسند - const joinRooms = () => { - socket.emit("joinChat", { userId: user._id, receiverId }); - socket.emit("joinUser", { userId: user._id }); - }; - socket.on("connect", joinRooms); - if (socket.connected) joinRooms(); + const pruneResolvedPending = useCallback((ids: string[]) => { + if (!ids.length) return; + setPendingMessages((prev) => prev.filter((m) => !ids.includes(m._id))); + }, []); - return () => { - socket.off("connect", joinRooms); - socket.disconnect(); - socketRef.current = null; - }; - }, [user?._id, receiverId]); + const confirmSentMessage = useCallback( + (tempId: string, serverMsg: ChatMessage, replyPayload?: ChatMessage["replyTo"]) => { + const sentMsg: ChatMessage = { + ...serverMsg, + status: "sent", + replyTo: serverMsg.replyTo ?? replyPayload, + }; + + const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId()); + const targetReceiverId = normalizeThreadId( + userTwoDetail?._id ?? receiverId ?? chatPartnerId + ); + + linkIds(tempId, sentMsg._id); + + if (currentUserId && targetReceiverId) { + queryClient.setQueryData( + chatThreadQueryKey(currentUserId, targetReceiverId), + (oldData) => upsertMessageInThreadCache(oldData, sentMsg) + ); + } + // pending را اینجا حذف نکن — ChatMessageList بعد از sync شدن cache prune می‌کند + }, + [user?._id, userTwoDetail?._id, receiverId, chatPartnerId, linkIds, queryClient] + ); const handleTyping = useCallback(() => { - if (!socketRef.current || !user?._id || !receiverId) return; - socketRef.current.emit("typing", { + const socket = getChatSocket(); + if (!socket || !user?._id || !receiverId) return; + socket.emit("typing", { senderId: user._id, receiverId, }); if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); typingTimeoutRef.current = setTimeout(() => { - socketRef.current?.emit("stopTyping", { + getChatSocket()?.emit("stopTyping", { senderId: user._id, receiverId, }); @@ -219,36 +239,6 @@ function TicketChat({ params }: ITicketChatProps) { }; }; - const handleIncomingMessage = useCallback((msg: ChatMessage) => { - setPendingMessages((prev) => filterResolvedPendingMessages([msg], prev)); - }, []); - - const confirmSentMessage = useCallback( - (tempId: string, serverMsg: ChatMessage, replyPayload?: ChatMessage["replyTo"]) => { - const sentMsg: ChatMessage = { - ...serverMsg, - status: "sent", - replyTo: serverMsg.replyTo ?? replyPayload, - }; - - const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId()); - const targetReceiverId = normalizeThreadId( - userTwoDetail?._id ?? receiverId ?? chatPartnerId - ); - - if (currentUserId && targetReceiverId) { - linkIds(tempId, sentMsg._id); - queryClient.setQueryData( - chatThreadQueryKey(currentUserId, targetReceiverId), - (oldData) => appendMessageToThreadCache(oldData, sentMsg) - ); - } - - setPendingMessages((prev) => prev.filter((m) => m._id !== tempId)); - }, - [user?._id, userTwoDetail?._id, receiverId, chatPartnerId, linkIds, queryClient] - ); - const processSendMessage = async ( fileToUpload: File | null, fileType?: ChatMessage["fileType"], @@ -486,6 +476,7 @@ function TicketChat({ params }: ITicketChatProps) { onToggleDeleteSelect={toggleDeleteSelect} onExpirePending={handleExpirePending} onIncomingMessage={handleIncomingMessage} + onPruneResolvedPending={pruneResolvedPending} extraBottomRem={ (replyingTo ? 2.75 : 0) + (selfDestructSeconds != null ? 2 : 0) + diff --git a/src/app/settings/chats/page.tsx b/src/app/settings/chats/page.tsx index 46916f3..54704c9 100644 --- a/src/app/settings/chats/page.tsx +++ b/src/app/settings/chats/page.tsx @@ -2,7 +2,7 @@ import Container from "@/components/elements/Container"; import RoundedInput from "@/components/elements/RoundedInput"; -import { IMAGE_BASE_URL, SOCKET_URL, buildStorageUrl } from "@/components/main/BaseUrl"; +import { buildStorageUrl } from "@/components/main/BaseUrl"; import VerificationBadge from "@/components/main/VerificationBadge"; import PageTitle from "@/components/settings/PageTitle"; import UserDetails from "@/components/settings/UserDetails"; @@ -12,8 +12,12 @@ import Image from "next/image"; import Link from "next/link"; import React, { useEffect, useState } from "react"; import { ChatListSkeleton } from "@/components/ui/ChatSkeletons"; -import { io } from "socket.io-client"; import { useQueryClient } from "@tanstack/react-query"; +import { + acquireChatSocket, + joinUserRoom, + releaseChatSocket, +} from "@/lib/chat/socketClient"; export interface IMessage { first_name: string; @@ -43,21 +47,24 @@ function Chats() { useEffect(() => { if (!user?._id) return; - const socket = io(SOCKET_URL); - socket.emit("joinUser", { userId: user._id }); + const socket = acquireChatSocket(); + const uid = String(user._id); - socket.on("chatListUpdate", () => { - queryClient.invalidateQueries({ queryKey: ["messages"] }); - }); + const onConnect = () => joinUserRoom(uid); + socket.on("connect", onConnect); + if (socket.connected) onConnect(); - socket.on("newMessage", () => { + const invalidate = () => { queryClient.invalidateQueries({ queryKey: ["messages"] }); - }); + }; + socket.on("chatListUpdate", invalidate); + socket.on("newMessage", invalidate); return () => { - socket.off("chatListUpdate"); - socket.off("newMessage"); - socket.disconnect(); + socket.off("connect", onConnect); + socket.off("chatListUpdate", invalidate); + socket.off("newMessage", invalidate); + releaseChatSocket(); }; }, [user?._id, queryClient]); @@ -75,82 +82,59 @@ function Chats() { setSearchText(e.target.value)} - placeholder="جستجو" - className="w-full" + onKeyDown={(e) => { + if (e.key === "Enter") setSearch(searchText); + }} + placeholder="جستجو..." /> - - -
- {isLoading ? ( - - ) : isEmpty ? ( -

- هنوز مکالمه‌ای ندارید. -

- ) : ( - data?.pages?.map((page, pageIndex) => ( - - {page?.filteredUsersData?.map((item: IMessage) => ( - -
-
- {item?.profile_image ? ( - {item?.user_name} - ) : ( -
- )} -
- - {item.first_name} {item.last_name} - -
- - @{item?.user_name} - - -
-
-
-
- - {item?.last_online} - - {item?.unread_messages_count ? ( - - {item.unread_messages_count} - - ) : null} -
+ {isLoading ? ( + + ) : isEmpty ? ( +

+ هنوز مکالمه‌ای ندارید. +

+ ) : ( +
+ {data?.pages.map((page) => + page.filteredUsersData?.map((item: IMessage) => ( + +
+ {item.profile_image ? ( + {item.user_name} + ) : ( +
+ )} +
+ + {item.first_name} {item.last_name} + + {item.user_name}
- - ))} - - )) - )} -
+
+
+ + {item.unread_messages_count ? ( + + {item.unread_messages_count} + + ) : null} +
+ + )) + )} +
+ )}
); diff --git a/src/components/chat/ChatMessageList.tsx b/src/components/chat/ChatMessageList.tsx index 99c2a84..070aeae 100644 --- a/src/components/chat/ChatMessageList.tsx +++ b/src/components/chat/ChatMessageList.tsx @@ -1,18 +1,24 @@ "use client"; import React, { useEffect, useState, useRef, useMemo, useCallback } from "react"; -import { SOCKET_URL, getApiBaseUrl } from "../main/BaseUrl"; +import { getApiBaseUrl } from "../main/BaseUrl"; +import { + acquireChatSocket, + joinChatRoom, + joinUserRoom, + releaseChatSocket, +} from "@/lib/chat/socketClient"; import { apiClient } from "@/hooks/useAxios"; import { chatThreadQueryKey } from "@/lib/chat/queryKeys"; import { appendMessageToThreadCache, normalizeThreadId, + upsertMessageInThreadCache, } from "@/lib/chat/threadCache"; import ChatMessageCard, { ChatMessage } from "./ChatMessageCard"; import ChatDateSeparator from "./ChatDateSeparator"; import { groupMessagesByDate } from "@/lib/chat/groupMessagesByDate"; -import { dedupeChatMessages, filterResolvedPendingMessages } from "@/lib/chat/dedupeMessages"; -import { io } from "socket.io-client"; +import { dedupeChatMessages, filterResolvedPendingMessages, findPendingMatchForServer } from "@/lib/chat/dedupeMessages"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { User } from "@/types/types"; import { ChatMessagesSkeleton } from "@/components/ui/ChatSkeletons"; @@ -59,6 +65,7 @@ interface ChatMessageListProps { getStableKey?: (id: string) => string; onExpirePending?: (ids: string[]) => void; onIncomingMessage?: (message: ChatMessage) => void; + onPruneResolvedPending?: (ids: string[]) => void; /** فضای اضافه وقتی بنر پاسخ/زماندار بالای input است */ extraBottomRem?: number; } @@ -82,6 +89,7 @@ const ChatMessageList = ({ getStableKey = (id) => id, onExpirePending, onIncomingMessage, + onPruneResolvedPending, extraBottomRem = 0, }: ChatMessageListProps) => { const scrollRef = useRef(null); @@ -97,6 +105,12 @@ const ChatMessageList = ({ const [highlightedMessageId, setHighlightedMessageId] = useState( null ); + const onIncomingMessageRef = useRef(onIncomingMessage); + const onTypingChangeRef = useRef(onTypingChange); + const onPruneResolvedPendingRef = useRef(onPruneResolvedPending); + onIncomingMessageRef.current = onIncomingMessage; + onTypingChangeRef.current = onTypingChange; + onPruneResolvedPendingRef.current = onPruneResolvedPending; const senderId = normalizeThreadId(userDetail?._id ?? getStoredUserId()); const receiverId = normalizeThreadId( @@ -142,7 +156,8 @@ const ChatMessageList = ({ getNextPageParam: (lastPage) => lastPage.nextPage, staleTime: 60_000, gcTime: 10 * 60 * 1000, - refetchOnMount: true, + refetchOnMount: false, + refetchOnWindowFocus: false, }); const hasCachedMessages = Boolean( @@ -186,44 +201,45 @@ const ChatMessageList = ({ useEffect(() => { if (!threadReady) return; - const socket = io(SOCKET_URL, { - transports: ["websocket", "polling"], - reconnection: true, - reconnectionAttempts: Infinity, - reconnectionDelay: 800, - }); + const socket = acquireChatSocket(); + const uid = senderId; + const rid = receiverId; - // عضویت در روم چت روی هر اتصال/اتصال مجدد const joinRoom = () => { - socket.emit("joinChat", { userId: senderId, receiverId }); - socket.emit("joinUser", { userId: senderId }); + joinUserRoom(uid); + joinChatRoom(uid, rid); }; - socket.on("connect", joinRoom); - if (socket.connected) joinRoom(); - const belongs = (msg: ChatMessage) => - belongsToThread(msg, senderId, receiverId) || - (msg.senderId === senderId && msg.receiverId === receiverId) || - (msg.senderId === receiverId && msg.receiverId === senderId); + const onConnect = () => { + joinRoom(); + }; - socket.on("newMessage", (message: ChatMessage) => { + socket.on("connect", onConnect); + if (socket.connected) onConnect(); + + const belongs = (msg: ChatMessage) => belongsToThread(msg, senderId, receiverId); + + const onNewMessage = (message: ChatMessage) => { if (!belongs(message)) return; + onIncomingMessageRef.current?.(message); + queryClient.setQueryData( chatThreadQueryKey(senderId, receiverId), - (oldData) => appendMessageToThreadCache(oldData, message) + (oldData) => upsertMessageInThreadCache(oldData, message) ); - onIncomingMessage?.(message); setToEnd(true); - if (message.senderId === receiverId) { + if (String(message.senderId) === String(receiverId)) { socket.emit("messageSeen", { messageId: message._id, senderId: message.senderId, receiverId: senderId, }); } - }); + }; + + socket.on("newMessage", onNewMessage); socket.on( "messageStatusUpdate", @@ -248,17 +264,17 @@ const ChatMessageList = ({ let typingHideTimer: ReturnType | null = null; socket.on("userTyping", ({ userId }: { userId: string }) => { - if (userId === receiverId) { - onTypingChange?.(true); + if (String(userId) === String(receiverId)) { + onTypingChangeRef.current?.(true); if (typingHideTimer) clearTimeout(typingHideTimer); - typingHideTimer = setTimeout(() => onTypingChange?.(false), 3500); + typingHideTimer = setTimeout(() => onTypingChangeRef.current?.(false), 3500); } }); socket.on("userStoppedTyping", ({ userId }: { userId: string }) => { - if (userId === receiverId) { + if (String(userId) === String(receiverId)) { if (typingHideTimer) clearTimeout(typingHideTimer); - onTypingChange?.(false); + onTypingChangeRef.current?.(false); } }); @@ -285,15 +301,15 @@ const ChatMessageList = ({ return () => { if (typingHideTimer) clearTimeout(typingHideTimer); - socket.off("connect", joinRoom); - socket.off("newMessage"); + socket.off("connect", onConnect); + socket.off("newMessage", onNewMessage); socket.off("messageStatusUpdate"); socket.off("messagesDeleted"); socket.off("userTyping"); socket.off("userStoppedTyping"); - socket.disconnect(); + releaseChatSocket(); }; - }, [threadReady, senderId, receiverId, queryClient, onTypingChange, onIncomingMessage]); + }, [threadReady, senderId, receiverId, queryClient]); const allMessages = useMemo(() => { const server = @@ -310,6 +326,19 @@ const ChatMessageList = ({ ); }, [data, pendingMessages, searchQuery]); + useEffect(() => { + if (!pendingMessages.length || !data?.pages?.length) return; + const server = data.pages.flatMap((page) => page.messages); + const resolvedIds = pendingMessages + .filter((p) => + server.some((s) => s._id === p._id || findPendingMatchForServer(s, [p])) + ) + .map((p) => p._id); + if (resolvedIds.length) { + onPruneResolvedPendingRef.current?.(resolvedIds); + } + }, [data, pendingMessages]); + const grouped = useMemo( () => groupMessagesByDate(allMessages), [allMessages] @@ -523,6 +552,7 @@ const ChatMessageList = ({ String(userDetail?._id ?? getStoredUserId()); const stableKey = getStableKey(msg._id); const isNew = + isIncoming && hasInitializedRef.current && !seenMessageKeysRef.current.has(stableKey); diff --git a/src/lib/chat/dedupeMessages.ts b/src/lib/chat/dedupeMessages.ts index aba837e..cee5ac0 100644 --- a/src/lib/chat/dedupeMessages.ts +++ b/src/lib/chat/dedupeMessages.ts @@ -9,6 +9,13 @@ function matchesPendingServer(server: ChatMessage, pending: ChatMessage): boolea return Boolean(pending.fileType && server.fileType === pending.fileType); } +export function findPendingMatchForServer( + server: ChatMessage, + pending: ChatMessage[] +): ChatMessage | undefined { + return pending.find((p) => matchesPendingServer(server, p)); +} + export function filterResolvedPendingMessages( server: ChatMessage[], pending: ChatMessage[] diff --git a/src/lib/chat/socketClient.ts b/src/lib/chat/socketClient.ts new file mode 100644 index 0000000..35941b4 --- /dev/null +++ b/src/lib/chat/socketClient.ts @@ -0,0 +1,53 @@ +import { io, Socket } from "socket.io-client"; +import { SOCKET_URL } from "@/components/main/BaseUrl"; + +let sharedSocket: Socket | null = null; +let refCount = 0; + +export function acquireChatSocket(): Socket { + if (!sharedSocket) { + sharedSocket = io(SOCKET_URL, { + transports: ["websocket", "polling"], + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 800, + reconnectionDelayMax: 5000, + timeout: 20000, + withCredentials: true, + autoConnect: true, + }); + } + refCount += 1; + return sharedSocket; +} + +/** Use after acquire — does not change ref count */ +export function getChatSocket(): Socket | null { + return sharedSocket; +} + +export function releaseChatSocket(): void { + refCount = Math.max(0, refCount - 1); + if (refCount === 0 && sharedSocket) { + sharedSocket.removeAllListeners(); + sharedSocket.disconnect(); + sharedSocket = null; + } +} + +export function joinUserRoom(userId: string): void { + const id = String(userId).trim(); + if (!id || !sharedSocket) return; + sharedSocket.emit("joinUser", { userId: id }); +} + +export function joinChatRoom(userId: string, receiverId: string): void { + const uid = String(userId).trim(); + const rid = String(receiverId).trim(); + if (!uid || !rid || !sharedSocket) return; + sharedSocket.emit("joinChat", { userId: uid, receiverId: rid }); +} + +export function isChatSocketConnected(): boolean { + return Boolean(sharedSocket?.connected); +} diff --git a/src/lib/chat/threadCache.ts b/src/lib/chat/threadCache.ts index 3ae6850..f7319d6 100644 --- a/src/lib/chat/threadCache.ts +++ b/src/lib/chat/threadCache.ts @@ -34,3 +34,33 @@ export function appendMessageToThreadCache( return { ...oldData, pages: newPages }; } + +/** Add or update — avoids duplicate / flicker when optimistic + socket + HTTP overlap */ +export function upsertMessageInThreadCache( + oldData: ChatThreadData | undefined, + message: ChatMessage +): ChatThreadData { + if (!oldData?.pages?.length) { + return appendMessageToThreadCache(oldData, message); + } + + const messageId = String(message._id); + let found = false; + + const newPages = oldData.pages.map((page) => ({ + ...page, + messages: page.messages.map((m) => { + if (String(m._id) === messageId) { + found = true; + return { ...m, ...message }; + } + return m; + }), + })); + + if (found) { + return { ...oldData, pages: newPages }; + } + + return appendMessageToThreadCache(oldData, message); +}