chat
This commit is contained in:
10
Dockerfile
10
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"]
|
||||
CMD ["npm", "start"]
|
||||
@@ -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
24
ecosystem.config.cjs
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
48
scripts/deploy-server.sh
Normal 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
105
scripts/server-fix-502.sh
Normal 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)."
|
||||
@@ -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) +
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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[]
|
||||
|
||||
53
src/lib/chat/socketClient.ts
Normal file
53
src/lib/chat/socketClient.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user