This commit is contained in:
payacom
2026-07-16 21:31:59 +03:30
parent 58314d0ab5
commit 17f334d0f1
14 changed files with 467 additions and 192 deletions

View File

@@ -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"]
CMD ["npm", "start"]

View File

@@ -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

24
ecosystem.config.cjs Normal file
View File

@@ -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",
},
},
],
};

View File

@@ -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',

View File

@@ -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"
},

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://modstagram.com/sitemap.xml</loc><lastmod>2026-07-10T11:28:38.990Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/robots.txt</loc><lastmod>2026-07-10T11:28:38.990Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/explore</loc><lastmod>2026-07-10T11:28:38.991Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/academy/payment/success</loc><lastmod>2026-07-10T11:28:38.991Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/about-us</loc><lastmod>2026-07-10T11:28:38.991Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/academy/payment/failed</loc><lastmod>2026-07-10T11:28:38.991Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/sitemap.xml</loc><lastmod>2026-07-16T17:09:58.119Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/robots.txt</loc><lastmod>2026-07-16T17:09:58.120Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/academy/payment/success</loc><lastmod>2026-07-16T17:09:58.120Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/academy/payment/failed</loc><lastmod>2026-07-16T17:09:58.120Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com/about-us</loc><lastmod>2026-07-16T17:09:58.120Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
<url><loc>https://modstagram.com/explore</loc><lastmod>2026-07-16T17:09:58.120Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://modstagram.com</loc><changefreq>daily</changefreq><priority>1</priority></url>
<url><loc>https://modstagram.com/projects</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
<url><loc>https://modstagram.com/billboards</loc><changefreq>daily</changefreq><priority>0.9</priority></url>

48
scripts/deploy-server.sh Normal file
View File

@@ -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"

105
scripts/server-fix-502.sh Normal file
View File

@@ -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)."

View File

@@ -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<ReturnType<typeof io> | null>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const activeReplyRef = useRef<ChatMessage | null>(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) +

View File

@@ -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() {
<RoundedInput
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="جستجو"
className="w-full"
onKeyDown={(e) => {
if (e.key === "Enter") setSearch(searchText);
}}
placeholder="جستجو..."
/>
<button
type="button"
className="gentle-transition absolute left-3 top-[7px] active:scale-90"
onClick={() => setSearch(searchText)}
aria-label="جستجو"
>
<Image
width={25}
height={25}
alt="جستجو"
src="/images/icons/search-normal.svg"
/>
</button>
</div>
<div className="mt-5 w-full">
{isLoading ? (
<ChatListSkeleton count={7} />
) : isEmpty ? (
<p className="py-8 text-center text-neutral-500">
هنوز مکالمهای ندارید.
</p>
) : (
data?.pages?.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>
{page?.filteredUsersData?.map((item: IMessage) => (
<Link
href={`/settings/chats/${item?.user_name}/${item?._id}`}
key={item?._id}
className="gentle-transition mb-2 block w-full rounded-2xl border-b border-border-primary-light p-4 font-semibold hover:bg-black/[0.03] active:scale-[0.99] dark:hover:bg-white/[0.04]"
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
{item?.profile_image ? (
<Image
width={56}
height={56}
alt={item?.user_name}
src={buildStorageUrl(item?.profile_image)}
className="h-14 w-14 rounded-full object-cover"
/>
) : (
<div className="h-14 w-14 rounded-full bg-neutral-200 dark:bg-neutral-700" />
)}
<div>
<span className="text-sm">
{item.first_name} {item.last_name}
</span>
<div className="mt-1 flex items-center gap-1 text-neutral-500">
<span className="inline-flex items-center gap-1">
@{item?.user_name}
<VerificationBadge isVerified={item?.is_verified} />
</span>
</div>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<span className="text-[10px] text-neutral-400">
{item?.last_online}
</span>
{item?.unread_messages_count ? (
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-[#2aabee] px-1.5 text-[10px] text-white">
{item.unread_messages_count}
</span>
) : null}
</div>
{isLoading ? (
<ChatListSkeleton />
) : isEmpty ? (
<p className="py-12 text-center text-neutral-500">
هنوز مکالمهای ندارید.
</p>
) : (
<div className="mt-5 flex flex-col gap-3">
{data?.pages.map((page) =>
page.filteredUsersData?.map((item: IMessage) => (
<Link
key={item._id}
href={`/settings/chats/${item?.user_name}/${item?._id}`}
className="flex items-center justify-between rounded-2xl border border-gray-100 p-3 dark:border-gray-800"
>
<div className="flex items-center gap-3">
{item.profile_image ? (
<Image
src={buildStorageUrl(item.profile_image)}
width={48}
height={48}
alt={item.user_name}
className="rounded-2xl"
/>
) : (
<div className="h-12 w-12 rounded-2xl bg-neutral-200" />
)}
<div className="flex flex-col gap-1">
<span className="font-semibold">
{item.first_name} {item.last_name}
</span>
<span className="text-neutral-500">{item.user_name}</span>
</div>
</Link>
))}
</React.Fragment>
))
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<VerificationBadge isVerified={item.is_verified} />
{item.unread_messages_count ? (
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
{item.unread_messages_count}
</span>
) : null}
</div>
</Link>
))
)}
</div>
)}
</div>
</Container>
);

View File

@@ -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<HTMLDivElement | null>(null);
@@ -97,6 +105,12 @@ const ChatMessageList = ({
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(
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<typeof setTimeout> | 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);

View File

@@ -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[]

View File

@@ -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);
}

View File

@@ -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);
}