diff --git a/.claude/worktrees/agent-af1dfc8fe9a6cb198 b/.claude/worktrees/agent-af1dfc8fe9a6cb198
deleted file mode 160000
index 3b89bbf..0000000
--- a/.claude/worktrees/agent-af1dfc8fe9a6cb198
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 3b89bbfec327e50e9a1d5952f429864b48f058a3
diff --git a/public/service-worker.js b/public/service-worker.js
index e7c61e1..78d77a4 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -56,6 +56,17 @@ self.addEventListener("notificationclick", (event) => {
const CACHE_NAME = "pwa-cache-v12";
+const MAX_CACHED_IMAGES = 200;
+
+async function trimCache(name, maxEntries) {
+ const cache = await caches.open(name);
+ const keys = await cache.keys();
+ const extra = keys.length - maxEntries;
+ if (extra > 0) {
+ await Promise.all(keys.slice(0, extra).map((key) => cache.delete(key)));
+ }
+}
+
const PRECACHE_URLS = [
"/",
"/offline.html",
@@ -135,7 +146,8 @@ self.addEventListener("fetch", (event) => {
const clone = res.clone();
caches
.open(CACHE_NAME)
- .then((cache) => cache.put(event.request, clone));
+ .then((cache) => cache.put(event.request, clone))
+ .then(() => trimCache(CACHE_NAME, MAX_CACHED_IMAGES));
}
return res;
});
diff --git a/src/app/(auth)/(login)/login-with-username/page.tsx b/src/app/(auth)/(login)/login-with-username/page.tsx
index fc96bd9..0d12723 100644
--- a/src/app/(auth)/(login)/login-with-username/page.tsx
+++ b/src/app/(auth)/(login)/login-with-username/page.tsx
@@ -70,7 +70,7 @@ function LoginWithUsername() {
// قبل از اینکه password از scope خارج بشه، کلیدِ E2EE رو زیرِ همین رمز
// بکآپ/بازیابی کن — best-effort، نباید جلویِ خودِ لاگین رو بگیره
if (response.id) {
- void establishIdentityAtLogin(response.id, values.password);
+ // [E2EE] موقتاً غیرفعال — void establishIdentityAtLogin(response.id, values.password);
}
const loggedIn = await completeLogin(router, response, {
diff --git a/src/app/(auth)/(register)/register/password/page.tsx b/src/app/(auth)/(register)/register/password/page.tsx
index 690e8ce..38914d8 100644
--- a/src/app/(auth)/(register)/register/password/page.tsx
+++ b/src/app/(auth)/(register)/register/password/page.tsx
@@ -57,7 +57,7 @@ function PasswordPage() {
})) as IVerifyOtp;
// اولین رمزِ عبورِ این کاربره — همین الان کلیدِ E2EE رو زیرش بکآپ بگیر
const userId = getStoredUserId();
- if (userId) void establishIdentityAtLogin(userId, values.password);
+ // [E2EE] موقتاً غیرفعال — if (userId) void establishIdentityAtLogin(userId, values.password);
router.push("/register/fullname");
} catch (err: any) {
const message =
diff --git a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx
index 796283b..c3854b6 100644
--- a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx
+++ b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx
@@ -26,7 +26,7 @@ import { Comments } from "@/components/academy/CommentsModal";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ProfileAvatar from "@/components/main/ProfileAvatar";
-import { usePathname } from "next/navigation";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
import Cookies from "js-cookie";
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
import BillboardContactInfoModal from "@/components/billboards/BillboardPage/BillboardContactInfoModal";
@@ -112,6 +112,16 @@ export default function CourseDetail({
initialIsPurchased = false,
}: CourseDetailProps) {
const { t } = useTranslation("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const paymentResult = searchParams.get("payment");
+ useEffect(() => {
+ if (!paymentResult) return;
+ if (paymentResult === "success") toast.success(t("shops.paymentSuccessTitle"));
+ else toast.error(t("shops.paymentFailedTitle"));
+ router.replace(window.location.pathname);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [paymentResult]);
const [selectedVideo, setSelectedVideo] = useState(MOCK_VIDEOS[0]);
const [isPurchased, setIsPurchased] = useState(initialIsPurchased);
const [purchasing, setPurchasing] = useState(false);
diff --git a/src/app/(projects)/academy/payment/failed/page.tsx b/src/app/(projects)/academy/payment/failed/page.tsx
index 506fdc5..e65d4ee 100644
--- a/src/app/(projects)/academy/payment/failed/page.tsx
+++ b/src/app/(projects)/academy/payment/failed/page.tsx
@@ -113,7 +113,7 @@ function FailedProject() {
{project &&
}
-
+
{getDisplayTypeLabel(project?.project_type)}:{" "}
{Number(price).toLocaleString()} {t("settings.toman")}
@@ -129,7 +129,7 @@ function FailedProject() {
}}
disabled={paying}
variant="primary"
- className="w-32 h-9"
+ className="flex-1 h-9"
>
{t("academy.payment.retry")}
diff --git a/src/app/(projects)/projects/loading.tsx b/src/app/(projects)/projects/loading.tsx
new file mode 100644
index 0000000..2117200
--- /dev/null
+++ b/src/app/(projects)/projects/loading.tsx
@@ -0,0 +1,15 @@
+import IOSSpinner from "@/components/ui/IOSSpinner";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { translateCommon } from "@/lib/i18n/translate";
+
+export default async function ProjectsLoading() {
+ const lang = await getServerLanguage();
+ const label = translateCommon(lang, "common.loading");
+
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/src/app/(projects)/projects/payment/failed/page.tsx b/src/app/(projects)/projects/payment/failed/page.tsx
index c60324f..7b864dc 100644
--- a/src/app/(projects)/projects/payment/failed/page.tsx
+++ b/src/app/(projects)/projects/payment/failed/page.tsx
@@ -6,7 +6,7 @@ import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState } from "react";
import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
-import { useSearchParams } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import MainProjectCard from "@/components/projects/MainProjectCard";
import { useTranslation } from "react-i18next";
@@ -18,6 +18,7 @@ function FailedProject() {
const { t } = useTranslation("common");
const { request } = useAxios();
const searchParams = useSearchParams();
+ const router = useRouter();
const projectId = searchParams.get("projectId");
const [project, setProject] = useState();
@@ -120,7 +121,7 @@ function FailedProject() {
{project &&
}
-
+
{getDisplayTypeLabel(project?.project_type)}:{" "}
{Number(price).toLocaleString()} {t("settings.toman")}
@@ -134,10 +135,16 @@ function FailedProject() {
onClick={payHandler}
disabled={paying}
variant="primary"
- className="h-9 w-32"
+ className="h-9 flex-1"
>
{t("projects.payment.retry")}
+ router.push("/settings/workroom?filter=unpaid")}
+ className="h-9 flex-1"
+ >
+ {t("common.back")}
+
diff --git a/src/app/(projects)/projects/payment/success/page.tsx b/src/app/(projects)/projects/payment/success/page.tsx
index 182a096..6c81bde 100644
--- a/src/app/(projects)/projects/payment/success/page.tsx
+++ b/src/app/(projects)/projects/payment/success/page.tsx
@@ -8,7 +8,6 @@ import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
-import Link from "next/link";
import MainProjectCard from "@/components/projects/MainProjectCard";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -124,11 +123,13 @@ function SuccessProject() {
)}
{t("projects.payment.reviewNotice")}
-
-
- {t("projects.payment.workroom")}
-
-
+
(window.location.href = projectId ? `/projects/${projectId}` : "/settings/workroom")}
+ >
+ {t("settings.confirm")}
+
diff --git a/src/app/billboards/payment/failed/page.tsx b/src/app/billboards/payment/failed/page.tsx
index 58ea433..591656c 100644
--- a/src/app/billboards/payment/failed/page.tsx
+++ b/src/app/billboards/payment/failed/page.tsx
@@ -117,7 +117,7 @@ function FailedBillboard() {
{advertising &&
}
-
+
{getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
{Number(price).toLocaleString()} {t("settings.toman")}
@@ -137,10 +137,16 @@ function FailedBillboard() {
}}
disabled={paying}
variant="primary"
- className="w-32 h-9"
+ className="flex-1 h-9"
>
{t("billboards.payment.retry")}
+ router.push("/settings/my-billboards?filter=unpaid")}
+ className="flex-1 h-9"
+ >
+ {t("common.back")}
+
diff --git a/src/app/billboards/payment/success/page.tsx b/src/app/billboards/payment/success/page.tsx
index a245cc1..2340cc2 100644
--- a/src/app/billboards/payment/success/page.tsx
+++ b/src/app/billboards/payment/success/page.tsx
@@ -12,7 +12,6 @@ import PageTitle from "@/components/settings/PageTitle";
import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
-import Link from "next/link";
import { useTranslation } from "react-i18next";
function SuccessBillboard() {
@@ -59,11 +58,13 @@ function SuccessBillboard() {
{Number(price).toLocaleString()} {t("settings.toman")}
{t("billboards.payment.registeredMessage")}
-
-
- {t("billboards.payment.myBillboards")}
-
-
+
(window.location.href = projectId ? `/settings/my-billboards/${projectId}/billboard` : "/settings/my-billboards")}
+ >
+ {t("settings.confirm")}
+
diff --git a/src/app/gift/payment/failed/page.tsx b/src/app/gift/payment/failed/page.tsx
index 0a9e11b..0dd4308 100644
--- a/src/app/gift/payment/failed/page.tsx
+++ b/src/app/gift/payment/failed/page.tsx
@@ -4,13 +4,14 @@ import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
-import { useSearchParams } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
function GiftPaymentFailed() {
const { t } = useTranslation("common");
+ const router = useRouter();
const searchParams = useSearchParams();
const receiverId = searchParams.get("receiverId");
@@ -28,19 +29,17 @@ function GiftPaymentFailed() {
/>
{t("giftPage.paymentFailed.message")}
-
+
{receiverId ? (
-
-
+
+
{t("giftPage.paymentFailed.retry")}
) : null}
-
-
- {t("giftPage.paymentFailed.back")}
-
-
+ router.push(receiverId ? `/gift/${receiverId}` : "/")}>
+ {t("giftPage.paymentFailed.back")}
+
diff --git a/src/app/globals.css b/src/app/globals.css
index 7118307..63f693f 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -807,22 +807,20 @@ select {
}
.glass-modal-overlay {
- background: rgba(8, 8, 10, 0.52);
- backdrop-filter: blur(20px) saturate(120%);
- -webkit-backdrop-filter: blur(20px) saturate(120%);
+ background: transparent;
+ backdrop-filter: none;
+ -webkit-backdrop-filter: none;
}
.dark .glass-modal-overlay {
- background: rgba(0, 0, 0, 0.62);
- backdrop-filter: blur(22px) saturate(110%);
- -webkit-backdrop-filter: blur(22px) saturate(110%);
+ background: transparent;
}
.glass-modal-overlay--dark {
- background: rgba(0, 0, 0, 0.72);
- backdrop-filter: blur(24px) saturate(110%);
- -webkit-backdrop-filter: blur(24px) saturate(110%);
+ background: transparent;
+ backdrop-filter: none;
+ -webkit-backdrop-filter: none;
}
.dark .glass-modal-overlay--dark {
- background: rgba(0, 0, 0, 0.82);
+ background: transparent;
}
.glass-modal-panel {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index c9d72ad..5af2ba9 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,125 +1,127 @@
-import type { Metadata, Viewport } from "next";
-import localFont from "next/font/local";
-import { defaultSEOConfig } from "@/config/seoConfig";
-import { SITE_URL } from "@/config/site";
-import { getSiteSeoMeta, getSeoPage, buildHreflangAlternates } from "@/lib/i18n/seo";
-import { getLanguageDefinition } from "@/lib/i18n/registry";
-import { getServerLanguage } from "@/lib/i18n/server";
-import SiteJsonLd from "@/components/seo/SiteJsonLd";
-import Script from 'next/script';
-import "./globals.css";
-import Layout from "@/components/Layout";
-import RegisterSW from "@/components/RegisterSW";
-import PushNotificationListener from "@/components/PushNotificationListener";
-import VpnWarningGate from "@/components/VpnWarningGate";
-import ClientErrorBoundary from "@/components/ClientErrorBoundary";
-
-const iranSansFont = localFont({
- src: "../../public/fonts/IRANSansX-Regular.woff",
- display: "swap",
- fallback: ["Tahoma", "Arial", "sans-serif"],
-});
-
-export async function generateMetadata(): Promise
{
- const lang = await getServerLanguage();
- const site = getSiteSeoMeta(lang);
- const home = getSeoPage("home", lang);
-
- return {
- title: {
- default: home.title || site.name,
- template: `%s | ${site.titleSuffix}`,
- },
- description: home.description,
- metadataBase: new URL(SITE_URL),
- keywords: site.keywords,
- authors: [{ name: "Modstagram", url: SITE_URL }],
- creator: "Modstagram",
- alternates: buildHreflangAlternates(home.path || "/", lang),
- manifest: "/manifest.webmanifest",
- applicationName: site.name,
- icons: {
- icon: [
- { url: "/favicon.png", type: "image/png", sizes: "32x32" },
- { url: "/images/icons/192x192.png", type: "image/png", sizes: "192x192" },
- ],
- shortcut: ["/favicon.png"],
- apple: [
- { url: "/images/icons/apple-touch-icon.png", sizes: "180x180" },
- { url: "/images/icons/192x192.png", sizes: "192x192" },
- ],
- },
- appleWebApp: {
- capable: true,
- statusBarStyle: "default",
- title: site.name,
- },
- openGraph: {
- siteName: site.name,
- locale: lang === "fa" ? "fa_IR" : "en_US",
- type: "website",
- images: defaultSEOConfig.openGraph?.images || [],
- },
- robots: "index, follow",
- twitter: {
- card: "summary_large_image",
- creator: "@modstagram",
- site: "@modstagram",
- images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
- },
- verification: {
- google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
- },
- other: {
- "msapplication-TileColor": "#FF107D",
- samandehi: "522190795",
- },
- };
-}
-
-export const viewport: Viewport = {
- width: "device-width",
- initialScale: 1,
- maximumScale: 1,
- viewportFit: "cover",
- themeColor: [
- { media: "(prefers-color-scheme: light)", color: "#ffffff" },
- { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
- ],
-};
-
-export default async function RootLayout({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
- const lang = await getServerLanguage();
- const def = getLanguageDefinition(lang);
-
- return (
-
-
-
-
-
-
-
- {children}
-
- {/* ====================== گوگل آنالیتیکس ====================== */}
-
-
- {/* ======================================================== */}
-
- );
-}
+import type { Metadata, Viewport } from "next";
+import localFont from "next/font/local";
+import { defaultSEOConfig } from "@/config/seoConfig";
+import { SITE_URL } from "@/config/site";
+import { getSiteSeoMeta, getSeoPage, buildHreflangAlternates } from "@/lib/i18n/seo";
+import { getLanguageDefinition } from "@/lib/i18n/registry";
+import { getServerLanguage } from "@/lib/i18n/server";
+import SiteJsonLd from "@/components/seo/SiteJsonLd";
+import { toPwaLang } from "@/lib/i18n/pwaLang";
+import { PWA_VERSION } from "@/lib/pwaConfig";
+import Script from 'next/script';
+import "./globals.css";
+import Layout from "@/components/Layout";
+import RegisterSW from "@/components/RegisterSW";
+import PushNotificationListener from "@/components/PushNotificationListener";
+import VpnWarningGate from "@/components/VpnWarningGate";
+import ClientErrorBoundary from "@/components/ClientErrorBoundary";
+
+const iranSansFont = localFont({
+ src: "../../public/fonts/IRANSansX-Regular.woff",
+ display: "swap",
+ fallback: ["Tahoma", "Arial", "sans-serif"],
+});
+
+export async function generateMetadata(): Promise {
+ const lang = await getServerLanguage();
+ const site = getSiteSeoMeta(lang);
+ const home = getSeoPage("home", lang);
+
+ return {
+ title: {
+ default: home.title || site.name,
+ template: `%s | ${site.titleSuffix}`,
+ },
+ description: home.description,
+ metadataBase: new URL(SITE_URL),
+ keywords: site.keywords,
+ authors: [{ name: "Modstagram", url: SITE_URL }],
+ creator: "Modstagram",
+ alternates: buildHreflangAlternates(home.path || "/", lang),
+ manifest: `/manifest.webmanifest?lang=${toPwaLang(lang)}&v=${PWA_VERSION}`,
+ applicationName: site.name,
+ icons: {
+ icon: [
+ { url: "/favicon.png", type: "image/png", sizes: "32x32" },
+ { url: "/images/icons/192x192.png", type: "image/png", sizes: "192x192" },
+ ],
+ shortcut: ["/favicon.png"],
+ apple: [
+ { url: "/images/icons/apple-touch-icon.png", sizes: "180x180" },
+ { url: "/images/icons/192x192.png", sizes: "192x192" },
+ ],
+ },
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "default",
+ title: site.name,
+ },
+ openGraph: {
+ siteName: site.name,
+ locale: lang === "fa" ? "fa_IR" : "en_US",
+ type: "website",
+ images: defaultSEOConfig.openGraph?.images || [],
+ },
+ robots: "index, follow",
+ twitter: {
+ card: "summary_large_image",
+ creator: "@modstagram",
+ site: "@modstagram",
+ images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
+ },
+ verification: {
+ google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
+ },
+ other: {
+ "msapplication-TileColor": "#FF107D",
+ samandehi: "522190795",
+ },
+ };
+}
+
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ maximumScale: 1,
+ viewportFit: "cover",
+ themeColor: [
+ { media: "(prefers-color-scheme: light)", color: "#ffffff" },
+ { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
+ ],
+};
+
+export default async function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ const lang = await getServerLanguage();
+ const def = getLanguageDefinition(lang);
+
+ return (
+
+
+
+
+
+
+
+ {children}
+
+ {/* ====================== گوگل آنالیتیکس ====================== */}
+
+
+ {/* ======================================================== */}
+
+ );
+}
diff --git a/src/app/offer/[id]/page.tsx b/src/app/offer/[id]/page.tsx
index 6590930..04a89e8 100644
--- a/src/app/offer/[id]/page.tsx
+++ b/src/app/offer/[id]/page.tsx
@@ -1,357 +1,343 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-"use client";
-
-import Container from "@/components/elements/Container";
-import RoundedButton from "@/components/elements/RoundedButton";
-import RoundedDiv from "@/components/elements/RoundedDiv";
-import { selectionCardClass } from "@/lib/ui/buttonStyles";
-import { BASE_URL } from "@/components/main/BaseUrl";
-import ProfileAvatar from "@/components/main/ProfileAvatar";
-import VerificationBadge from "@/components/main/VerificationBadge";
-import UserInfoClient from "@/components/main/UserInfoClient";
-import ScoreRateStats from "@/components/main/ScoreRateStats";
-import { formatFullName } from "@/lib/formatFullName";
-import PageTitle from "@/components/settings/PageTitle";
-import useAxios from "@/hooks/useAxios";
-import { IOfferType, User } from "@/types/types";
-import axios from "axios";
-import { useFormik } from "formik";
-import { useRouter } from "next/navigation";
-import React, { useState, useEffect, useMemo } from "react";
-import toast from "react-hot-toast";
-import Cookies from "js-cookie";
-import * as Yup from "yup";
-import { useTranslation } from "react-i18next";
-import { useUser } from "@/hooks/useUser";
-import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
-import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
-type OfferTypeRow = IOfferType & {
- disabled?: boolean;
- unavailableReason?: string | null;
-};
-
-interface ITicketChatProps {
- params: Promise<{ id: string; title: string }>;
-}
-
-const FALLBACK_TYPES: OfferTypeRow[] = [
- { _id: "normal", name: "normal", price: 50000 },
- { _id: "free", name: "free", price: 0 },
-];
-
-function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
- const order = ["normal", "special", "highlight", "free"];
- return [...list].sort((a, b) => {
- const ai = order.indexOf(a.name);
- const bi = order.indexOf(b.name);
- return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
- });
-}
-
-function TicketChat({ params }: ITicketChatProps) {
- const { t } = useTranslation("common");
- const resolvedParams = React.use(params);
- const { request } = useAxios();
- const { id } = resolvedParams;
- const router = useRouter();
- const me = useUser();
-
- const validationSchema = Yup.object({
- selectedType: Yup.string().required(t("offerPage.typeRequired")),
- });
-
- const [userDetail, setUserDetail] = useState();
- const [selectedType, setSelectedType] = useState("normal");
- const [typeList, setTypeList] = useState(null);
- const [hasUsedFreeOffer, setHasUsedFreeOffer] = useState(false);
- const [walletBalance, setWalletBalance] = useState(0);
- const [showWalletPrompt, setShowWalletPrompt] = useState(false);
- const [socialSignupOpen, setSocialSignupOpen] = useState(false);
- const pendingPaymentRetryRef = React.useRef<(() => void) | null>(null);
-
- useEffect(() => {
- const fetchUser = async () => {
- const response = await request<{ user: User }>(
- "GET",
- `/users/get?user_id=${id}`
- );
- setUserDetail(response?.user);
- };
- void fetchUser();
- request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
- .then((res) => setWalletBalance(res?.charged?.available || 0))
- .catch(() => setWalletBalance(0));
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [id, request]);
-
- useEffect(() => {
- const monthly = Number(me?.monthly_free_offer ?? 1);
- setHasUsedFreeOffer(monthly <= 0);
- }, [me]);
-
- const fetchOfferTypes = async () => {
- try {
- const response = await request<{
- offerTypes: OfferTypeRow[];
- monthly_free_offer?: number;
- }>("GET", "/offers/types");
- const list = sortOfferTypes(
- response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
- );
- setTypeList(list);
- if (typeof response?.monthly_free_offer === "number") {
- setHasUsedFreeOffer(response.monthly_free_offer <= 0);
- }
- // همیشه پیشفرض روی پولی (normal)
- const paid =
- list.find((item) => item.name === "normal" && !item.disabled) ||
- list.find((item) => item.name !== "free" && !item.disabled);
- setSelectedType(paid?.name || "normal");
- } catch (err) {
- console.log(err);
- setTypeList(FALLBACK_TYPES);
- setSelectedType("normal");
- }
- };
-
- useEffect(() => {
- void fetchOfferTypes();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const getToken = (): string => {
- if (typeof window !== "undefined") {
- const token = Cookies.get("token");
- return token ? `Bearer ${token}` : "";
- }
- return "";
- };
-
- const startPayment = async (offerType: string, useWallet: boolean) => {
- try {
- const response = await axios.post(
- `${BASE_URL}/offers/initiate-payment-web`,
- {
- offerType,
- receiverId: id,
- useWallet,
- },
- {
- headers: {
- Authorization: getToken(),
- "Content-Type": "application/json",
- },
- }
- );
-
- const data: {
- authority?: string;
- paymentUrl?: string;
- type?: string;
- paidFromWallet?: boolean;
- } = response.data;
-
- if (offerType === "free" || data.type === "free") {
- toast.success(t("offerPage.freeSuccess"));
- setHasUsedFreeOffer(true);
- router.push(`/offer/payment/success?userId=${id}`);
- } else if (data.paidFromWallet) {
- toast.success(t("wallet.paidFromWalletSuccess"));
- router.push(`/offer/payment/success?userId=${id}`);
- } else if (data.paymentUrl) {
- // window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپآپبلاک میشه چون دیگه
- // synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
- // offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت میکنه، نه تبِ
- // اصلی — پس با _blank دو تب باز میموند. window.location.href هم امنتره
- // هم با بقیهی مسیرهای پرداختِ این اپ یکی میمونه
- window.location.href = data.paymentUrl;
- } else if (data.authority) {
- window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
- } else {
- toast.error(t("offerPage.paymentInfoError"));
- }
- } catch (err: any) {
- if (err?.response?.data?.type === "needs_username_password") {
- pendingPaymentRetryRef.current = () => {
- void startPayment(offerType, useWallet);
- };
- setSocialSignupOpen(true);
- return;
- }
-
- console.error("Payment initiation error:", err);
- toast.error(
- err?.response?.data?.error ||
- err?.response?.data?.message ||
- t("offerPage.paymentStartError")
- );
- }
- };
-
- const formik = useFormik({
- initialValues: {
- selectedType: "normal",
- },
- validationSchema,
- enableReinitialize: true,
- onSubmit: async (values, { setSubmitting }) => {
- const chosen = typeList?.find((t) => t.name === values.selectedType);
- if (
- chosen?.disabled ||
- (values.selectedType === "free" && hasUsedFreeOffer)
- ) {
- toast.error(
- chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
- );
- return;
- }
-
- try {
- if (values.selectedType !== "free" && walletBalance > 0) {
- setShowWalletPrompt(true);
- return;
- }
- await startPayment(values.selectedType, false);
- } finally {
- setSubmitting(false);
- }
- },
- });
-
- useEffect(() => {
- void formik.setFieldValue("selectedType", selectedType);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedType]);
-
- const displayTypes = useMemo(
- () => sortOfferTypes(typeList || FALLBACK_TYPES),
- [typeList]
- );
-
- const typeLabel = (name: string) => {
- if (name === "normal") return t("offerPage.types.normal");
- if (name === "free") return t("offerPage.types.free");
- if (name === "special") return t("offerPage.types.special");
- return t("offerPage.types.highlight");
- };
-
- // چیدمان این صفحه با UserInfo مشترک (استفادهشده توی نتایج جستجو/هدیه/...) فرق
- // داره — عین اندروید (OfferScreen.tsx): ستون نام+یوزرنیم کنار آواتار، ستون
- // سطح+ریت/امتیاز روبروش؛ بهجای تغییر کامپوننت مشترک، چیدمان مخصوص همین صفحهست
- const userHeader = userDetail ? (
-
-
-
-
-
- {formatFullName(userDetail?.first_name, userDetail?.last_name)}
-
-
- {userDetail?.user_name}
-
-
-
-
-
-
-
-
-
- ) : null;
-
- return (
-
-
-
{t("offerPage.paymentTitle")}
-
-
-
-
item.name === selectedType)?.price
- ) || 0
- }
- walletBalance={walletBalance}
- onAnswer={(useWallet) => {
- setShowWalletPrompt(false);
- void startPayment(selectedType, useWallet);
- }}
- />
-
- {
- setSocialSignupOpen(false);
- pendingPaymentRetryRef.current = null;
- }}
- onSuccess={() => {
- setSocialSignupOpen(false);
- pendingPaymentRetryRef.current?.();
- pendingPaymentRetryRef.current = null;
- }}
- />
-
-
- );
-}
-
-export default TicketChat;
+/* eslint-disable @typescript-eslint/no-explicit-any */
+"use client";
+
+import Container from "@/components/elements/Container";
+import RoundedButton from "@/components/elements/RoundedButton";
+import { BASE_URL } from "@/components/main/BaseUrl";
+import ProfileAvatar from "@/components/main/ProfileAvatar";
+import VerificationBadge from "@/components/main/VerificationBadge";
+import UserInfoClient from "@/components/main/UserInfoClient";
+import ScoreRateStats from "@/components/main/ScoreRateStats";
+import { formatFullName } from "@/lib/formatFullName";
+import PageTitle from "@/components/settings/PageTitle";
+import useAxios from "@/hooks/useAxios";
+import { IOfferType, User } from "@/types/types";
+import axios from "axios";
+import { useFormik } from "formik";
+import { useRouter } from "next/navigation";
+import React, { useState, useEffect, useMemo } from "react";
+import toast from "react-hot-toast";
+import Cookies from "js-cookie";
+import * as Yup from "yup";
+import { useTranslation } from "react-i18next";
+import { useUser } from "@/hooks/useUser";
+import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
+import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
+type OfferTypeRow = IOfferType & {
+ disabled?: boolean;
+ unavailableReason?: string | null;
+};
+
+interface ITicketChatProps {
+ params: Promise<{ id: string; title: string }>;
+}
+
+const FALLBACK_TYPES: OfferTypeRow[] = [
+ { _id: "normal", name: "normal", price: 50000 },
+ { _id: "free", name: "free", price: 0 },
+];
+
+function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
+ const order = ["normal", "special", "highlight", "free"];
+ return [...list].sort((a, b) => {
+ const ai = order.indexOf(a.name);
+ const bi = order.indexOf(b.name);
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
+ });
+}
+
+function TicketChat({ params }: ITicketChatProps) {
+ const { t } = useTranslation("common");
+ const resolvedParams = React.use(params);
+ const { request } = useAxios();
+ const { id } = resolvedParams;
+ const router = useRouter();
+ const me = useUser();
+
+ const validationSchema = Yup.object({
+ selectedType: Yup.string().required(t("offerPage.typeRequired")),
+ });
+
+ const [userDetail, setUserDetail] = useState();
+ const [selectedType, setSelectedType] = useState("normal");
+ const [typeList, setTypeList] = useState(null);
+ const [walletBalance, setWalletBalance] = useState(0);
+ const [showWalletPrompt, setShowWalletPrompt] = useState(false);
+ const [socialSignupOpen, setSocialSignupOpen] = useState(false);
+ const pendingPaymentRetryRef = React.useRef<(() => void) | null>(null);
+
+ useEffect(() => {
+ const fetchUser = async () => {
+ const response = await request<{ user: User }>(
+ "GET",
+ `/users/get?user_id=${id}`
+ );
+ setUserDetail(response?.user);
+ };
+ void fetchUser();
+ request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
+ .then((res) => setWalletBalance(res?.charged?.available || 0))
+ .catch(() => setWalletBalance(0));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [id, request]);
+
+
+ const fetchOfferTypes = async () => {
+ try {
+ const response = await request<{
+ offerTypes: OfferTypeRow[];
+ }>("GET", `/offers/types?userId=${id}`);
+ const list = sortOfferTypes(
+ response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
+ );
+ setTypeList(list);
+ // همیشه پیشفرض روی پولی (normal)
+ const paid =
+ list.find((item) => item.name === "normal" && !item.disabled) ||
+ list.find((item) => item.name !== "free" && !item.disabled);
+ setSelectedType(paid?.name || "normal");
+ } catch (err) {
+ console.log(err);
+ setTypeList(FALLBACK_TYPES);
+ setSelectedType("normal");
+ }
+ };
+
+ useEffect(() => {
+ void fetchOfferTypes();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const getToken = (): string => {
+ if (typeof window !== "undefined") {
+ const token = Cookies.get("token");
+ return token ? `Bearer ${token}` : "";
+ }
+ return "";
+ };
+
+ const startPayment = async (offerType: string, useWallet: boolean) => {
+ try {
+ const response = await axios.post(
+ `${BASE_URL}/offers/initiate-payment-web`,
+ {
+ offerType,
+ receiverId: id,
+ useWallet,
+ },
+ {
+ headers: {
+ Authorization: getToken(),
+ "Content-Type": "application/json",
+ },
+ }
+ );
+
+ const data: {
+ authority?: string;
+ paymentUrl?: string;
+ type?: string;
+ paidFromWallet?: boolean;
+ } = response.data;
+
+ if (offerType === "free" || data.type === "free") {
+ toast.success(t("offerPage.freeSuccess"));
+ router.push(`/offer/payment/success?userId=${id}`);
+ } else if (data.paidFromWallet) {
+ toast.success(t("wallet.paidFromWalletSuccess"));
+ router.push(`/offer/payment/success?userId=${id}`);
+ } else if (data.paymentUrl) {
+ // window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپآپبلاک میشه چون دیگه
+ // synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
+ // offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت میکنه، نه تبِ
+ // اصلی — پس با _blank دو تب باز میموند. window.location.href هم امنتره
+ // هم با بقیهی مسیرهای پرداختِ این اپ یکی میمونه
+ window.location.href = data.paymentUrl;
+ } else if (data.authority) {
+ window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
+ } else {
+ toast.error(t("offerPage.paymentInfoError"));
+ }
+ } catch (err: any) {
+ if (err?.response?.data?.type === "needs_username_password") {
+ pendingPaymentRetryRef.current = () => {
+ void startPayment(offerType, useWallet);
+ };
+ setSocialSignupOpen(true);
+ return;
+ }
+
+ console.error("Payment initiation error:", err);
+ toast.error(
+ err?.response?.data?.error ||
+ err?.response?.data?.message ||
+ t("offerPage.paymentStartError")
+ );
+ }
+ };
+
+ const formik = useFormik({
+ initialValues: {
+ selectedType: "normal",
+ },
+ validationSchema,
+ enableReinitialize: true,
+ onSubmit: async (values, { setSubmitting }) => {
+ const chosen = typeList?.find((t) => t.name === values.selectedType);
+ if (chosen?.disabled) {
+ toast.error(
+ chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
+ );
+ return;
+ }
+
+ try {
+ if (values.selectedType !== "free" && walletBalance > 0) {
+ setShowWalletPrompt(true);
+ return;
+ }
+ await startPayment(values.selectedType, false);
+ } finally {
+ setSubmitting(false);
+ }
+ },
+ });
+
+ useEffect(() => {
+ void formik.setFieldValue("selectedType", selectedType);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedType]);
+
+ const displayTypes = useMemo(
+ () => sortOfferTypes(typeList || FALLBACK_TYPES),
+ [typeList]
+ );
+
+ const typeLabel = (name: string) => {
+ if (name === "normal") return t("offerPage.types.normal");
+ if (name === "free") return t("offerPage.types.free");
+ if (name === "special") return t("offerPage.types.special");
+ return t("offerPage.types.highlight");
+ };
+
+ // چیدمان این صفحه با UserInfo مشترک (استفادهشده توی نتایج جستجو/هدیه/...) فرق
+ // داره — عین اندروید (OfferScreen.tsx): ستون نام+یوزرنیم کنار آواتار، ستون
+ // سطح+ریت/امتیاز روبروش؛ بهجای تغییر کامپوننت مشترک، چیدمان مخصوص همین صفحهست
+ const userHeader = userDetail ? (
+
+
+
+
+
+ {formatFullName(userDetail?.first_name, userDetail?.last_name)}
+
+
+ {userDetail?.user_name}
+
+
+
+
+
+
+
+
+
+ ) : null;
+
+ return (
+
+
+
{t("offerPage.paymentTitle")}
+
+
+
+
item.name === selectedType)?.price
+ ) || 0
+ }
+ walletBalance={walletBalance}
+ onAnswer={(useWallet) => {
+ setShowWalletPrompt(false);
+ void startPayment(selectedType, useWallet);
+ }}
+ />
+
+ {
+ setSocialSignupOpen(false);
+ pendingPaymentRetryRef.current = null;
+ }}
+ onSuccess={() => {
+ setSocialSignupOpen(false);
+ pendingPaymentRetryRef.current?.();
+ pendingPaymentRetryRef.current = null;
+ }}
+ />
+
+
+ );
+}
+
+export default TicketChat;
diff --git a/src/app/offer/payment/failed/page.tsx b/src/app/offer/payment/failed/page.tsx
index b8ae509..7a9c74b 100644
--- a/src/app/offer/payment/failed/page.tsx
+++ b/src/app/offer/payment/failed/page.tsx
@@ -5,13 +5,14 @@ import React from "react";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
-import { useSearchParams } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
function FailedBillboard() {
const { t } = useTranslation("common");
+ const router = useRouter();
const searchParams = useSearchParams();
const userId = searchParams.get("userId");
@@ -29,17 +30,19 @@ function FailedBillboard() {
/>
{t("offerPage.paymentFailed.message")}
-
-
-
+
+
+
{t("offerPage.paymentFailed.retry")}
-
-
- {t("offerPage.paymentFailed.back")}
-
-
+ router.push(`/offer/${userId}`)}
+ >
+ {t("offerPage.paymentFailed.back")}
+
diff --git a/src/app/offer/payment/success/page.tsx b/src/app/offer/payment/success/page.tsx
index 6df117f..865e7ff 100644
--- a/src/app/offer/payment/success/page.tsx
+++ b/src/app/offer/payment/success/page.tsx
@@ -7,7 +7,7 @@ import React, { useEffect, useState } from "react";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
-import { useSearchParams } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
@@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next";
function SuccessBillboard() {
const { t } = useTranslation("common");
const { request } = useAxios();
+ const router = useRouter();
const searchParams = useSearchParams();
const userId = searchParams.get("userId");
@@ -50,13 +51,23 @@ function SuccessBillboard() {
username: userDetail?.user_name ?? "",
})}
-
-
- {t("offerPage.paymentSuccess.sendMessage")}
-
-
+ {/* دکمههای پایین صفحه: ارسال پیام (به گفتگو) و بازگشت (به صفحهی درخواست) */}
+
+
+
+ {t("offerPage.paymentSuccess.sendMessage")}
+
+
+ router.push(`/offer/${userId}`)}
+ >
+ {t("offerPage.paymentFailed.back")}
+
+
);
diff --git a/src/app/settings/chats/[username]/[id]/page.tsx b/src/app/settings/chats/[username]/[id]/page.tsx
index 553c65e..4abfab2 100644
--- a/src/app/settings/chats/[username]/[id]/page.tsx
+++ b/src/app/settings/chats/[username]/[id]/page.tsx
@@ -1,849 +1,849 @@
-/* eslint-disable react-hooks/rules-of-hooks */
-"use client";
-
-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 { useRouter, useSearchParams } from "next/navigation";
-import {
- getChatSocket,
-} from "@/lib/chat/socketClient";
-import MessageInput from "@/components/chat/MessageInput";
-import GiftInChatModal from "@/components/chat/GiftInChatModal";
-import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
-import { AxiosError } from "axios";
-import GiftPaymentResultModal from "@/components/chat/GiftPaymentResultModal";
-import MultiImageModal from "@/components/chat/MultiImageModal";
-import MediaPreviewModal from "@/components/chat/MediaPreviewModal";
-import ChatActionBar from "@/components/chat/ChatActionBar";
-import ChatDeleteBar from "@/components/chat/ChatDeleteBar";
-import { useUser } from "@/hooks/useUser";
-import ChatMessageList from "@/components/chat/ChatMessageList";
-import ChatHeader from "@/components/chat/ChatHeader";
-import dynamic from "next/dynamic";
-import type { ChatMessage } from "@/components/chat/ChatMessageCard";
-import toast from "react-hot-toast";
-import { optimizeMediaFile } from "@/lib/media";
-import ForwardMessageModal from "@/components/chat/ForwardMessageModal";
-import { AnimatePresence } from "framer-motion";
-import { useQueryClient } from "@tanstack/react-query";
-import { chatThreadQueryKey } from "@/lib/chat/queryKeys";
-import { normalizeThreadId, upsertMessageInThreadCache, type ChatThreadData } from "@/lib/chat/threadCache";
-import { useStableMessageKeys } from "@/hooks/useStableMessageKeys";
-import { getStoredUserId } from "@/lib/auth/session";
-import { isViewOnceMediaType } from "@/lib/chat/viewOnce";
-import {
- copyTextToClipboard,
- getCopyableMessageText,
-} from "@/lib/chat/getCopyableMessageText";
-import {
- findPendingMatchForServer,
-} from "@/lib/chat/dedupeMessages";
-import { useTranslation } from "react-i18next";
-import LocalePageShell from "@/components/i18n/LocalePageShell";
-import {
- decryptDmContent,
- encryptDmContent,
- E2EE_LOCKED_PREVIEW,
- rememberOutboundPlaintext,
- ensureIdentityKeys,
-} from "@/lib/e2ee";
-
-interface ITicketChatProps {
- params: Promise<{ id: string; username: string }>;
-}
-
-function normalizeMessage(raw: ChatMessage & { data?: ChatMessage }): ChatMessage {
- const m = (raw as { data?: ChatMessage }).data ?? raw;
- return {
- ...m,
- replyTo: m.replyTo ?? undefined,
- };
-}
-
-function TicketChat({ params }: ITicketChatProps) {
- const { t } = useTranslation("common");
- const resolvedParams = React.use(params);
- const { request } = useAxios();
- const router = useRouter();
- const searchParams = useSearchParams();
- const { username, id: chatPartnerId } = resolvedParams;
- const [newMessage, setNewMessage] = useState("");
- const [showGiftModal, setShowGiftModal] = useState(false);
- const [giftResultStatus, setGiftResultStatus] = useState<"success" | "failed" | null>(null);
- const [userTwoDetail, setUserTwoDetail] = useState();
- const [pendingImages, setPendingImages] = useState([]);
- const [showMultiModal, setShowMultiModal] = useState(false);
- const [pendingMediaPreview, setPendingMediaPreview] = useState<{
- file: File;
- kind: "video" | "file";
- } | null>(null);
- const [pendingMessages, setPendingMessages] = useState([]);
- const [replyingTo, setReplyingTo] = useState(null);
- const [forwardMessage, setForwardMessage] = useState(null);
- const [selectedMessage, setSelectedMessage] = useState(null);
- const [actionMode, setActionMode] = useState(false);
- const [deleteMode, setDeleteMode] = useState(false);
- const [selectedDeleteIds, setSelectedDeleteIds] = useState([]);
- const [deleteLoading, setDeleteLoading] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
- const [selfDestructSeconds, setSelfDestructSeconds] = useState(
- null
- );
- const [viewOnceMedia, setViewOnceMedia] = useState(false);
- const [socialSignupOpen, setSocialSignupOpen] = useState(false);
- const pendingSendRetryRef = useRef<(() => void) | null>(null);
- const user = useUser();
- const queryClient = useQueryClient();
- const { linkIds, getStableKey } = useStableMessageKeys();
- const [receiverId, setReceiverId] = useState(chatPartnerId);
- const [isTyping, setIsTyping] = useState(false);
- const typingTimeoutRef = useRef(null);
- const activeReplyRef = useRef(null);
- const scrollToMessageRef = useRef<(targetId: string) => void>(() => {});
-
- const handleIncomingMessage = useCallback(
- (msg: ChatMessage) => {
- setPendingMessages((prev) => {
- const match = findPendingMatchForServer(msg, prev);
- if (match) {
- linkIds(match._id, msg._id);
- return prev.filter((m) => m._id !== match._id);
- }
- return prev;
- });
- },
- [linkIds]
- );
-
- 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: ChatThreadData | undefined) => upsertMessageInThreadCache(oldData, sentMsg)
- );
- }
-
- setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
- },
- [user?._id, userTwoDetail?._id, receiverId, chatPartnerId, linkIds, queryClient]
- );
-
- const handleTyping = useCallback(() => {
- 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(() => {
- getChatSocket()?.emit("stopTyping", {
- senderId: user._id,
- receiverId,
- });
- }, 2000);
- }, [user?._id, receiverId]);
-
- useEffect(() => {
- const fetchStates = async () => {
- try {
- const response = await request<{ user: User }>(
- "GET",
- `/users/get/web?user_name=${username}&_t=${Date.now()}`
- );
- setUserTwoDetail(response?.user);
- } catch (err) {
- console.error("Error fetching user:", err);
- }
- };
- fetchStates();
- }, [username, request]);
-
- useEffect(() => {
- setReceiverId(userTwoDetail?._id || chatPartnerId);
- }, [user, userTwoDetail, chatPartnerId]);
-
- const handleFileSelection = (e: React.ChangeEvent) => {
- const picked = Array.from(e.target.files || []);
- if (!picked.length) return;
- const MAX_SIZE = 200 * 1024 * 1024;
- const valid = picked.filter((f) => {
- if (f.size > MAX_SIZE) {
- toast.error(t("chats.toast.fileTooLargeNamed", { name: f.name }));
- return false;
- }
- return true;
- });
- if (!valid.length) return;
-
- const videos = valid.filter((f) => f.type.startsWith("video/"));
- const images = valid.filter((f) => f.type.startsWith("image/"));
- const others = valid.filter(
- (f) => !f.type.startsWith("video/") && !f.type.startsWith("image/")
- );
-
- if (videos.length === 1 && images.length === 0 && others.length === 0) {
- setPendingMediaPreview({ file: videos[0], kind: "video" });
- return;
- }
-
- if (others.length === 1 && images.length === 0 && videos.length === 0) {
- setPendingMediaPreview({ file: others[0], kind: "file" });
- return;
- }
-
- if (images.length) {
- setPendingImages((prev) => [...prev, ...images].slice(0, 10));
- setShowMultiModal(true);
- }
- };
-
- const handleVoiceUpload = (blob: Blob) => {
- const voiceFile = new File([blob], "voice-message.webm", {
- type: "audio/webm",
- });
- processSendMessage(voiceFile, "voice");
- };
-
- const handleLocationShare = () => {
- if (!navigator.geolocation) {
- toast.error(t("chats.toast.geolocationUnsupported"));
- return;
- }
- navigator.geolocation.getCurrentPosition(
- (pos) => {
- const locationContent = JSON.stringify({
- lat: pos.coords.latitude,
- lng: pos.coords.longitude,
- label: t("chats.locationLabel"),
- });
- processSendMessage(null, "location", locationContent);
- },
- () => toast.error(t("chats.toast.geolocationDenied"))
- );
- };
-
- const sendGiftMessage = useCallback(
- (gift: { _id: string; amount: number; message: string | null }) => {
- const content = JSON.stringify({
- sharedGift: {
- giftId: gift._id,
- amount: gift.amount,
- message: gift.message,
- },
- });
- void processSendMessage(null, undefined, content);
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [userTwoDetail, receiverId, chatPartnerId, selfDestructSeconds, viewOnceMedia]
- );
-
- useEffect(() => {
- const giftId = searchParams.get("giftId");
- const giftFailed = searchParams.get("giftPaymentFailed");
- if (!giftId && !giftFailed) return;
-
- if (giftId) {
- const giftAmount = Number(searchParams.get("giftAmount") || 0);
- const giftMessage = searchParams.get("giftMessage") || "";
- sendGiftMessage({ _id: giftId, amount: giftAmount, message: giftMessage || null });
- setGiftResultStatus("success");
- } else if (giftFailed) {
- setGiftResultStatus("failed");
- }
-
- const params = new URLSearchParams(searchParams.toString());
- params.delete("giftId");
- params.delete("giftAmount");
- params.delete("giftMessage");
- params.delete("giftPaymentFailed");
- const query = params.toString();
- router.replace(`/settings/chats/${username}/${chatPartnerId}${query ? `?${query}` : ""}`);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const sendMessage = () => {
- if (pendingImages.length > 0) {
- void sendAllImages();
- return;
- }
- processSendMessage(null);
- };
-
- const sendAllImages = async () => {
- const batch = [...pendingImages];
- setShowMultiModal(false);
- setPendingImages([]);
- for (const file of batch) {
- await processSendMessage(file, "image");
- }
- };
-
- const buildReplyPayload = (source: ChatMessage | null) => {
- if (!source) return undefined;
- return {
- _id: source._id,
- content: source.content?.slice(0, 120) || t("chats.message"),
- senderName:
- source.senderId === user?._id
- ? t("chats.you")
- : `${userTwoDetail?.first_name || ""} ${userTwoDetail?.last_name || ""}`.trim(),
- };
- };
-
- const processSendMessage = async (
- fileToUpload: File | null,
- fileType?: ChatMessage["fileType"],
- contentOverride?: string
- ) => {
- const replySource = activeReplyRef.current;
- let textContent = contentOverride ?? newMessage;
- if (textContent.trim() === "" && !fileToUpload) return;
-
- if (userTwoDetail?.blocked_you || userTwoDetail?.is_blocked) {
- toast.error(t("chats.toast.sendBlocked"));
- return;
- }
-
- const tempId = `temp-${Date.now()}-${Math.random()}`;
- const replyPayload = buildReplyPayload(replySource);
-
- const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId());
- const targetReceiverId = normalizeThreadId(
- userTwoDetail?._id ?? receiverId ?? chatPartnerId
- );
-
- const applyViewOnce =
- viewOnceMedia && fileType && isViewOnceMediaType(fileType);
-
- const optimisticMsg: ChatMessage = {
- _id: tempId,
- content: textContent,
- senderId: currentUserId,
- receiverId: targetReceiverId,
- createdAt: new Date().toISOString(),
- selfDestructSeconds: selfDestructSeconds || undefined,
- status: "pending",
- file: fileToUpload ? URL.createObjectURL(fileToUpload) : "",
- fileType: fileType,
- replyTo: replyPayload,
- viewOnce: applyViewOnce || undefined,
- };
-
- setPendingMessages((prev) => [...prev, optimisticMsg]);
- setNewMessage("");
-
- let fileForUpload = fileToUpload;
- if (fileToUpload && fileType !== "location") {
- try {
- toast.loading(t("chats.toast.optimizingFile"), { id: "media-opt" });
- fileForUpload = await optimizeMediaFile(fileToUpload);
- toast.dismiss("media-opt");
- } catch {
- toast.dismiss("media-opt");
- }
- }
-
- const isFileUpload = fileForUpload && fileType !== "location";
-
- try {
- // ارسالِ فایل/عکس/ویس/ویدیو فعلاً بدونِ e2ee میره (طبقِ تصمیمِ فعلی، رمزنگاریِ
- // مدیا بعداً اضافه میشه) — قبلاً حتی برای همین آپلودها هم این مرحله اجباری بود
- // و اگه encryptDmContent به هر دلیلی (مثلاً هنوز کلیدِ عمومیِ طرفِ مقابل واکشی
- // نشده) null برمیگردوند، کلِ ارسالِ فایل هم با «E2EE encryption failed» شکست
- // میخورد — دقیقاً مثلِ اندروید که از اول اصلاً این مرحله رو برای فایل صدا نمیزد
- const sendContent = isFileUpload
- ? textContent
- : await (async () => {
- await ensureIdentityKeys(currentUserId);
- const encrypted = await encryptDmContent(
- currentUserId,
- targetReceiverId,
- textContent
- );
- if (!encrypted) throw new Error("E2EE encryption failed");
- return encrypted;
- })();
-
- let messagePayload: Record = {
- content: sendContent,
- receiverId: targetReceiverId,
- };
-
- if (replySource?._id && !replySource._id.startsWith("temp")) {
- messagePayload.replyToId = replySource._id;
- }
- if (selfDestructSeconds) {
- messagePayload.selfDestructSeconds = selfDestructSeconds;
- }
- if (fileType) {
- messagePayload.fileType = fileType;
- }
- if (applyViewOnce) {
- messagePayload.viewOnce = true;
- }
-
- let response: ChatMessage | { data: ChatMessage };
-
- if (isFileUpload) {
- const formData = new FormData();
- for (const key in messagePayload) {
- formData.append(key, String(messagePayload[key]));
- }
- // isFileUpload بالا (که این بلوک زیرِ اونه) از قبل fileForUpload رو truthy چک کرده
- formData.append("file", fileForUpload as File);
-
- response = await request(
- "post",
- "/chat/file",
- formData
- );
- } else {
- response = await request(
- "POST",
- "/chat",
- messagePayload
- );
- }
-
- const serverMsg = normalizeMessage(response as ChatMessage);
- // همیشه متن اصلی خودمان را نگه دار — اگر decrypt fail شود نباید «رمزنگاریشده» ببینیم
- if (sendContent && sendContent !== textContent) {
- rememberOutboundPlaintext(sendContent, textContent);
- }
- if (serverMsg.content && serverMsg.content !== textContent) {
- rememberOutboundPlaintext(String(serverMsg.content), textContent);
- }
- try {
- const plain = await decryptDmContent(
- currentUserId,
- targetReceiverId,
- serverMsg.content || textContent
- );
- serverMsg.content =
- !plain || plain === E2EE_LOCKED_PREVIEW ? textContent : plain;
- } catch {
- serverMsg.content = textContent;
- }
- confirmSentMessage(tempId, serverMsg, replyPayload);
- void queryClient.invalidateQueries({ queryKey: ["messages"] });
- } catch (error) {
- setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
-
- const errType = (error as AxiosError<{ type?: string }>)?.response?.data
- ?.type;
- if (errType === "needs_username_password") {
- pendingSendRetryRef.current = () => {
- void processSendMessage(fileToUpload, fileType, textContent);
- };
- setSocialSignupOpen(true);
- return;
- }
-
- console.error("Error sending message:", error);
- toast.error(t("chats.toast.sendFailedDot"));
- return;
- }
-
- setReplyingTo(null);
- activeReplyRef.current = null;
- setSelfDestructSeconds(null);
- if (applyViewOnce) setViewOnceMedia(false);
- };
-
- const handleExpirePending = useCallback((ids: string[]) => {
- setPendingMessages((prev) => prev.filter((m) => !ids.includes(m._id)));
- }, []);
-
- const openActionFor = (msg: ChatMessage) => {
- setSelectedMessage(msg);
- setActionMode(true);
- };
-
- const closeActionMode = () => {
- setActionMode(false);
- setSelectedMessage(null);
- };
-
- const startReplyFor = useCallback((msg: ChatMessage) => {
- setReplyingTo(msg);
- activeReplyRef.current = msg;
- closeActionMode();
- }, []);
-
- const startReply = () => {
- if (!selectedMessage) return;
- startReplyFor(selectedMessage);
- };
-
- const startForward = () => {
- if (!selectedMessage) return;
- setForwardMessage(selectedMessage);
- closeActionMode();
- };
-
- const copySelectedMessage = async () => {
- if (!selectedMessage) return;
- const text = getCopyableMessageText(selectedMessage);
- if (!text) {
- toast.error(t("chats.toast.copyEmpty"));
- return;
- }
-
- const copied = await copyTextToClipboard(text);
- if (copied) {
- toast.success(t("chats.toast.copySuccess"));
- closeActionMode();
- return;
- }
-
- toast.error(t("chats.toast.copyFailed"));
- };
-
- const reactToMessage = async (emoji: string) => {
- if (!selectedMessage?._id || selectedMessage._id.startsWith("temp-")) return;
- const messageId = selectedMessage._id;
- const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId());
- const targetReceiverId = normalizeThreadId(
- userTwoDetail?._id ?? receiverId ?? chatPartnerId
- );
-
- const toggleReaction = (
- reactions: ChatMessage["reactions"] = []
- ): ChatMessage["reactions"] => {
- const list = [...(reactions ?? [])];
- const idx = list.findIndex((r) => String(r.userId) === currentUserId);
- if (idx >= 0 && list[idx].emoji === emoji) {
- list.splice(idx, 1);
- } else if (idx >= 0) {
- list[idx] = { userId: currentUserId, emoji };
- } else {
- list.push({ userId: currentUserId, emoji });
- }
- return list;
- };
-
- const optimisticReactions = toggleReaction(selectedMessage.reactions);
- if (currentUserId && targetReceiverId) {
- queryClient.setQueryData(
- chatThreadQueryKey(currentUserId, targetReceiverId),
- (oldData: ChatThreadData | undefined) => {
- if (!oldData) return oldData;
- return {
- ...oldData,
- pages: oldData.pages.map((page) => ({
- ...page,
- messages: page.messages.map((m) =>
- m._id === messageId
- ? { ...m, reactions: optimisticReactions }
- : m
- ),
- })),
- };
- }
- );
- }
-
- try {
- const response = await request<{ data?: ChatMessage }>(
- "POST",
- "/chat/reaction",
- {
- messageId,
- emoji,
- receiverId: targetReceiverId,
- }
- );
- const updated = response?.data;
- if (updated && currentUserId && targetReceiverId) {
- queryClient.setQueryData(
- chatThreadQueryKey(currentUserId, targetReceiverId),
- (oldData: ChatThreadData | undefined) => upsertMessageInThreadCache(oldData, updated)
- );
- }
- closeActionMode();
- } catch {
- if (currentUserId && targetReceiverId) {
- queryClient.setQueryData(
- chatThreadQueryKey(currentUserId, targetReceiverId),
- (oldData: ChatThreadData | undefined) => {
- if (!oldData) return oldData;
- return {
- ...oldData,
- pages: oldData.pages.map((page) => ({
- ...page,
- messages: page.messages.map((m) =>
- m._id === messageId
- ? { ...m, reactions: selectedMessage.reactions }
- : m
- ),
- })),
- };
- }
- );
- }
- toast.error(t("chats.toast.reactFailed"));
- }
- };
-
- const handleBlockChange = (blocked?: boolean) => {
- if (typeof blocked === "boolean") {
- setUserTwoDetail((prev) =>
- prev ? { ...prev, is_blocked: blocked } : prev
- );
- }
- void request<{ user: User }>(
- "GET",
- `/users/get/web?user_name=${username}&_t=${Date.now()}`
- ).then((res) => {
- if (res?.user) setUserTwoDetail(res.user);
- });
- };
-
- const startDeleteMode = () => {
- closeActionMode();
- setSearchQuery("");
- setDeleteMode(true);
- setSelectedDeleteIds([]);
- };
-
- const cancelDeleteMode = () => {
- setDeleteMode(false);
- setSelectedDeleteIds([]);
- };
-
- const toggleDeleteSelect = (msg: ChatMessage) => {
- setSelectedDeleteIds((prev) =>
- prev.includes(msg._id)
- ? prev.filter((id) => id !== msg._id)
- : [...prev, msg._id]
- );
- };
-
- const confirmDeleteMessages = async () => {
- if (!selectedDeleteIds.length || !user?._id) return;
- setDeleteLoading(true);
- try {
- const response = await request<{ deletedIds: string[]; message?: string }>(
- "DELETE",
- "/chat",
- { messageIds: selectedDeleteIds }
- );
- const deleted = new Set(response?.deletedIds || []);
- setPendingMessages((prev) => prev.filter((m) => !deleted.has(m._id)));
- queryClient.setQueryData(
- chatThreadQueryKey(
- normalizeThreadId(user._id),
- normalizeThreadId(userTwoDetail?._id ?? receiverId ?? chatPartnerId)
- ),
- (oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => {
- if (!oldData) return oldData;
- return {
- ...oldData,
- pages: oldData.pages.map((page) => ({
- ...page,
- messages: page.messages.filter((m) => !deleted.has(m._id)),
- })),
- };
- }
- );
- toast.success(response?.message || t("chats.toast.messagesDeleted"));
- cancelDeleteMode();
- } catch {
- toast.error(t("chats.toast.deleteMessagesFailed"));
- } finally {
- setDeleteLoading(false);
- }
- };
-
- return (
-
-
-
- {
- scrollToMessageRef.current = fn;
- }}
- extraBottomRem={
- (replyingTo ? 2.75 : 0) +
- (selfDestructSeconds != null ? 2 : 0) +
- (viewOnceMedia ? 2 : 0)
- }
- />
-
-
- {deleteMode ? (
-
- ) : actionMode ? (
-
- ) : (
- {
- setReplyingTo(null);
- activeReplyRef.current = null;
- }}
- onReplyPreviewClick={() => {
- if (replyingTo?._id) scrollToMessageRef.current(replyingTo._id);
- }}
- replyLabel={
- replyingTo
- ? replyingTo.content?.slice(0, 80) || t("chats.message")
- : undefined
- }
- selfDestructSeconds={selfDestructSeconds}
- onSelfDestructChange={setSelfDestructSeconds}
- viewOnceMedia={viewOnceMedia}
- onViewOnceChange={setViewOnceMedia}
- onGiftClick={() => setShowGiftModal(true)}
- giftDisabled={userTwoDetail?.gift_enabled === false}
- />
- )}
-
-
- setShowGiftModal(false)}
- receiverId={String(userTwoDetail?._id ?? receiverId ?? chatPartnerId)}
- returnPath={`/settings/chats/${username}/${chatPartnerId}`}
- onWalletGiftSent={sendGiftMessage}
- />
-
- setGiftResultStatus(null)}
- onRetry={() => {
- setGiftResultStatus(null);
- setShowGiftModal(true);
- }}
- />
-
- {
- setSocialSignupOpen(false);
- pendingSendRetryRef.current = null;
- }}
- onSuccess={() => {
- setSocialSignupOpen(false);
- pendingSendRetryRef.current?.();
- pendingSendRetryRef.current = null;
- }}
- />
-
-
- setPendingImages((prev) => {
- const next = prev.filter((_, idx) => idx !== i);
- if (next.length === 0) setShowMultiModal(false);
- return next;
- })
- }
- onCancel={() => {
- setPendingImages([]);
- setShowMultiModal(false);
- }}
- onConfirm={sendAllImages}
- viewOnceMedia={viewOnceMedia}
- onViewOnceChange={setViewOnceMedia}
- selfDestructSeconds={selfDestructSeconds}
- onSelfDestructChange={setSelfDestructSeconds}
- />
- setPendingMediaPreview(null)}
- onConfirm={() => {
- if (!pendingMediaPreview) return;
- const { file, kind } = pendingMediaPreview;
- setPendingMediaPreview(null);
- void processSendMessage(file, kind === "video" ? "video" : "file");
- }}
- viewOnceMedia={viewOnceMedia}
- onViewOnceChange={setViewOnceMedia}
- selfDestructSeconds={selfDestructSeconds}
- onSelfDestructChange={setSelfDestructSeconds}
- />
- {forwardMessage && user?._id && (
- setForwardMessage(null)}
- message={forwardMessage}
- currentUserId={user._id}
- currentReceiverId={receiverId}
- />
- )}
-
-
- );
-}
-
-export default dynamic(() => Promise.resolve(TicketChat), { ssr: false });
+/* eslint-disable react-hooks/rules-of-hooks */
+"use client";
+
+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 { useRouter, useSearchParams } from "next/navigation";
+import {
+ getChatSocket,
+} from "@/lib/chat/socketClient";
+import MessageInput from "@/components/chat/MessageInput";
+import GiftInChatModal from "@/components/chat/GiftInChatModal";
+import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
+import { AxiosError } from "axios";
+import GiftPaymentResultModal from "@/components/chat/GiftPaymentResultModal";
+import MultiImageModal from "@/components/chat/MultiImageModal";
+import MediaPreviewModal from "@/components/chat/MediaPreviewModal";
+import ChatActionBar from "@/components/chat/ChatActionBar";
+import ChatDeleteBar from "@/components/chat/ChatDeleteBar";
+import { useUser } from "@/hooks/useUser";
+import ChatMessageList from "@/components/chat/ChatMessageList";
+import ChatHeader from "@/components/chat/ChatHeader";
+import dynamic from "next/dynamic";
+import type { ChatMessage } from "@/components/chat/ChatMessageCard";
+import toast from "react-hot-toast";
+import { optimizeMediaFile } from "@/lib/media";
+import ForwardMessageModal from "@/components/chat/ForwardMessageModal";
+import { AnimatePresence } from "framer-motion";
+import { useQueryClient } from "@tanstack/react-query";
+import { chatThreadQueryKey } from "@/lib/chat/queryKeys";
+import { normalizeThreadId, upsertMessageInThreadCache, type ChatThreadData } from "@/lib/chat/threadCache";
+import { useStableMessageKeys } from "@/hooks/useStableMessageKeys";
+import { getStoredUserId } from "@/lib/auth/session";
+import { isViewOnceMediaType } from "@/lib/chat/viewOnce";
+import {
+ copyTextToClipboard,
+ getCopyableMessageText,
+} from "@/lib/chat/getCopyableMessageText";
+import {
+ findPendingMatchForServer,
+} from "@/lib/chat/dedupeMessages";
+import { useTranslation } from "react-i18next";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import {
+ decryptDmContent,
+ encryptDmContent,
+ E2EE_LOCKED_PREVIEW,
+ rememberOutboundPlaintext,
+ ensureIdentityKeys,
+} from "@/lib/e2ee";
+
+interface ITicketChatProps {
+ params: Promise<{ id: string; username: string }>;
+}
+
+function normalizeMessage(raw: ChatMessage & { data?: ChatMessage }): ChatMessage {
+ const m = (raw as { data?: ChatMessage }).data ?? raw;
+ return {
+ ...m,
+ replyTo: m.replyTo ?? undefined,
+ };
+}
+
+function TicketChat({ params }: ITicketChatProps) {
+ const { t } = useTranslation("common");
+ const resolvedParams = React.use(params);
+ const { request } = useAxios();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { username, id: chatPartnerId } = resolvedParams;
+ const [newMessage, setNewMessage] = useState("");
+ const [showGiftModal, setShowGiftModal] = useState(false);
+ const [giftResultStatus, setGiftResultStatus] = useState<"success" | "failed" | null>(null);
+ const [userTwoDetail, setUserTwoDetail] = useState();
+ const [pendingImages, setPendingImages] = useState([]);
+ const [showMultiModal, setShowMultiModal] = useState(false);
+ const [pendingMediaPreview, setPendingMediaPreview] = useState<{
+ file: File;
+ kind: "video" | "file";
+ } | null>(null);
+ const [pendingMessages, setPendingMessages] = useState([]);
+ const [replyingTo, setReplyingTo] = useState(null);
+ const [forwardMessage, setForwardMessage] = useState(null);
+ const [selectedMessage, setSelectedMessage] = useState(null);
+ const [actionMode, setActionMode] = useState(false);
+ const [deleteMode, setDeleteMode] = useState(false);
+ const [selectedDeleteIds, setSelectedDeleteIds] = useState([]);
+ const [deleteLoading, setDeleteLoading] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [selfDestructSeconds, setSelfDestructSeconds] = useState(
+ null
+ );
+ const [viewOnceMedia, setViewOnceMedia] = useState(false);
+ const [socialSignupOpen, setSocialSignupOpen] = useState(false);
+ const pendingSendRetryRef = useRef<(() => void) | null>(null);
+ const user = useUser();
+ const queryClient = useQueryClient();
+ const { linkIds, getStableKey } = useStableMessageKeys();
+ const [receiverId, setReceiverId] = useState(chatPartnerId);
+ const [isTyping, setIsTyping] = useState(false);
+ const typingTimeoutRef = useRef(null);
+ const activeReplyRef = useRef(null);
+ const scrollToMessageRef = useRef<(targetId: string) => void>(() => {});
+
+ const handleIncomingMessage = useCallback(
+ (msg: ChatMessage) => {
+ setPendingMessages((prev) => {
+ const match = findPendingMatchForServer(msg, prev);
+ if (match) {
+ linkIds(match._id, msg._id);
+ return prev.filter((m) => m._id !== match._id);
+ }
+ return prev;
+ });
+ },
+ [linkIds]
+ );
+
+ 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: ChatThreadData | undefined) => upsertMessageInThreadCache(oldData, sentMsg)
+ );
+ }
+
+ setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
+ },
+ [user?._id, userTwoDetail?._id, receiverId, chatPartnerId, linkIds, queryClient]
+ );
+
+ const handleTyping = useCallback(() => {
+ 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(() => {
+ getChatSocket()?.emit("stopTyping", {
+ senderId: user._id,
+ receiverId,
+ });
+ }, 2000);
+ }, [user?._id, receiverId]);
+
+ useEffect(() => {
+ const fetchStates = async () => {
+ try {
+ const response = await request<{ user: User }>(
+ "GET",
+ `/users/get/web?user_name=${username}&_t=${Date.now()}`
+ );
+ setUserTwoDetail(response?.user);
+ } catch (err) {
+ console.error("Error fetching user:", err);
+ }
+ };
+ fetchStates();
+ }, [username, request]);
+
+ useEffect(() => {
+ setReceiverId(userTwoDetail?._id || chatPartnerId);
+ }, [user, userTwoDetail, chatPartnerId]);
+
+ const handleFileSelection = (e: React.ChangeEvent) => {
+ const picked = Array.from(e.target.files || []);
+ if (!picked.length) return;
+ const MAX_SIZE = 200 * 1024 * 1024;
+ const valid = picked.filter((f) => {
+ if (f.size > MAX_SIZE) {
+ toast.error(t("chats.toast.fileTooLargeNamed", { name: f.name }));
+ return false;
+ }
+ return true;
+ });
+ if (!valid.length) return;
+
+ const videos = valid.filter((f) => f.type.startsWith("video/"));
+ const images = valid.filter((f) => f.type.startsWith("image/"));
+ const others = valid.filter(
+ (f) => !f.type.startsWith("video/") && !f.type.startsWith("image/")
+ );
+
+ if (videos.length === 1 && images.length === 0 && others.length === 0) {
+ setPendingMediaPreview({ file: videos[0], kind: "video" });
+ return;
+ }
+
+ if (others.length === 1 && images.length === 0 && videos.length === 0) {
+ setPendingMediaPreview({ file: others[0], kind: "file" });
+ return;
+ }
+
+ if (images.length) {
+ setPendingImages((prev) => [...prev, ...images].slice(0, 10));
+ setShowMultiModal(true);
+ }
+ };
+
+ const handleVoiceUpload = (blob: Blob) => {
+ const voiceFile = new File([blob], "voice-message.webm", {
+ type: "audio/webm",
+ });
+ processSendMessage(voiceFile, "voice");
+ };
+
+ const handleLocationShare = () => {
+ if (!navigator.geolocation) {
+ toast.error(t("chats.toast.geolocationUnsupported"));
+ return;
+ }
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ const locationContent = JSON.stringify({
+ lat: pos.coords.latitude,
+ lng: pos.coords.longitude,
+ label: t("chats.locationLabel"),
+ });
+ processSendMessage(null, "location", locationContent);
+ },
+ () => toast.error(t("chats.toast.geolocationDenied"))
+ );
+ };
+
+ const sendGiftMessage = useCallback(
+ (gift: { _id: string; amount: number; message: string | null }) => {
+ const content = JSON.stringify({
+ sharedGift: {
+ giftId: gift._id,
+ amount: gift.amount,
+ message: gift.message,
+ },
+ });
+ void processSendMessage(null, undefined, content);
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [userTwoDetail, receiverId, chatPartnerId, selfDestructSeconds, viewOnceMedia]
+ );
+
+ useEffect(() => {
+ const giftId = searchParams.get("giftId");
+ const giftFailed = searchParams.get("giftPaymentFailed");
+ if (!giftId && !giftFailed) return;
+
+ if (giftId) {
+ const giftAmount = Number(searchParams.get("giftAmount") || 0);
+ const giftMessage = searchParams.get("giftMessage") || "";
+ sendGiftMessage({ _id: giftId, amount: giftAmount, message: giftMessage || null });
+ setGiftResultStatus("success");
+ } else if (giftFailed) {
+ setGiftResultStatus("failed");
+ }
+
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete("giftId");
+ params.delete("giftAmount");
+ params.delete("giftMessage");
+ params.delete("giftPaymentFailed");
+ const query = params.toString();
+ router.replace(`/settings/chats/${username}/${chatPartnerId}${query ? `?${query}` : ""}`);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const sendMessage = () => {
+ if (pendingImages.length > 0) {
+ void sendAllImages();
+ return;
+ }
+ processSendMessage(null);
+ };
+
+ const sendAllImages = async () => {
+ const batch = [...pendingImages];
+ setShowMultiModal(false);
+ setPendingImages([]);
+ for (const file of batch) {
+ await processSendMessage(file, "image");
+ }
+ };
+
+ const buildReplyPayload = (source: ChatMessage | null) => {
+ if (!source) return undefined;
+ return {
+ _id: source._id,
+ content: source.content?.slice(0, 120) || t("chats.message"),
+ senderName:
+ source.senderId === user?._id
+ ? t("chats.you")
+ : `${userTwoDetail?.first_name || ""} ${userTwoDetail?.last_name || ""}`.trim(),
+ };
+ };
+
+ const processSendMessage = async (
+ fileToUpload: File | null,
+ fileType?: ChatMessage["fileType"],
+ contentOverride?: string
+ ) => {
+ const replySource = activeReplyRef.current;
+ let textContent = contentOverride ?? newMessage;
+ if (textContent.trim() === "" && !fileToUpload) return;
+
+ if (userTwoDetail?.blocked_you || userTwoDetail?.is_blocked) {
+ toast.error(t("chats.toast.sendBlocked"));
+ return;
+ }
+
+ const tempId = `temp-${Date.now()}-${Math.random()}`;
+ const replyPayload = buildReplyPayload(replySource);
+
+ const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId());
+ const targetReceiverId = normalizeThreadId(
+ userTwoDetail?._id ?? receiverId ?? chatPartnerId
+ );
+
+ const applyViewOnce =
+ viewOnceMedia && fileType && isViewOnceMediaType(fileType);
+
+ const optimisticMsg: ChatMessage = {
+ _id: tempId,
+ content: textContent,
+ senderId: currentUserId,
+ receiverId: targetReceiverId,
+ createdAt: new Date().toISOString(),
+ selfDestructSeconds: selfDestructSeconds || undefined,
+ status: "pending",
+ file: fileToUpload ? URL.createObjectURL(fileToUpload) : "",
+ fileType: fileType,
+ replyTo: replyPayload,
+ viewOnce: applyViewOnce || undefined,
+ };
+
+ setPendingMessages((prev) => [...prev, optimisticMsg]);
+ setNewMessage("");
+
+ let fileForUpload = fileToUpload;
+ if (fileToUpload && fileType !== "location") {
+ try {
+ toast.loading(t("chats.toast.optimizingFile"), { id: "media-opt" });
+ fileForUpload = await optimizeMediaFile(fileToUpload);
+ toast.dismiss("media-opt");
+ } catch {
+ toast.dismiss("media-opt");
+ }
+ }
+
+ const isFileUpload = fileForUpload && fileType !== "location";
+
+ try {
+ // ارسالِ فایل/عکس/ویس/ویدیو فعلاً بدونِ e2ee میره (طبقِ تصمیمِ فعلی، رمزنگاریِ
+ // مدیا بعداً اضافه میشه) — قبلاً حتی برای همین آپلودها هم این مرحله اجباری بود
+ // و اگه encryptDmContent به هر دلیلی (مثلاً هنوز کلیدِ عمومیِ طرفِ مقابل واکشی
+ // نشده) null برمیگردوند، کلِ ارسالِ فایل هم با «E2EE encryption failed» شکست
+ // میخورد — دقیقاً مثلِ اندروید که از اول اصلاً این مرحله رو برای فایل صدا نمیزد
+ const sendContent = isFileUpload
+ ? textContent
+ : await (async () => {
+ await ensureIdentityKeys(currentUserId);
+ const encrypted = await encryptDmContent(
+ currentUserId,
+ targetReceiverId,
+ textContent
+ );
+ if (encrypted === null) throw new Error("E2EE encryption failed");
+ return encrypted;
+ })();
+
+ let messagePayload: Record = {
+ content: sendContent,
+ receiverId: targetReceiverId,
+ };
+
+ if (replySource?._id && !replySource._id.startsWith("temp")) {
+ messagePayload.replyToId = replySource._id;
+ }
+ if (selfDestructSeconds) {
+ messagePayload.selfDestructSeconds = selfDestructSeconds;
+ }
+ if (fileType) {
+ messagePayload.fileType = fileType;
+ }
+ if (applyViewOnce) {
+ messagePayload.viewOnce = true;
+ }
+
+ let response: ChatMessage | { data: ChatMessage };
+
+ if (isFileUpload) {
+ const formData = new FormData();
+ for (const key in messagePayload) {
+ formData.append(key, String(messagePayload[key]));
+ }
+ // isFileUpload بالا (که این بلوک زیرِ اونه) از قبل fileForUpload رو truthy چک کرده
+ formData.append("file", fileForUpload as File);
+
+ response = await request(
+ "post",
+ "/chat/file",
+ formData
+ );
+ } else {
+ response = await request(
+ "POST",
+ "/chat",
+ messagePayload
+ );
+ }
+
+ const serverMsg = normalizeMessage(response as ChatMessage);
+ // همیشه متن اصلی خودمان را نگه دار — اگر decrypt fail شود نباید «رمزنگاریشده» ببینیم
+ if (sendContent && sendContent !== textContent) {
+ rememberOutboundPlaintext(sendContent, textContent);
+ }
+ if (serverMsg.content && serverMsg.content !== textContent) {
+ rememberOutboundPlaintext(String(serverMsg.content), textContent);
+ }
+ try {
+ const plain = await decryptDmContent(
+ currentUserId,
+ targetReceiverId,
+ serverMsg.content || textContent
+ );
+ serverMsg.content =
+ !plain || plain === E2EE_LOCKED_PREVIEW ? textContent : plain;
+ } catch {
+ serverMsg.content = textContent;
+ }
+ confirmSentMessage(tempId, serverMsg, replyPayload);
+ void queryClient.invalidateQueries({ queryKey: ["messages"] });
+ } catch (error) {
+ setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
+
+ const errType = (error as AxiosError<{ type?: string }>)?.response?.data
+ ?.type;
+ if (errType === "needs_username_password") {
+ pendingSendRetryRef.current = () => {
+ void processSendMessage(fileToUpload, fileType, textContent);
+ };
+ setSocialSignupOpen(true);
+ return;
+ }
+
+ console.error("Error sending message:", error);
+ toast.error(t("chats.toast.sendFailedDot"));
+ return;
+ }
+
+ setReplyingTo(null);
+ activeReplyRef.current = null;
+ setSelfDestructSeconds(null);
+ if (applyViewOnce) setViewOnceMedia(false);
+ };
+
+ const handleExpirePending = useCallback((ids: string[]) => {
+ setPendingMessages((prev) => prev.filter((m) => !ids.includes(m._id)));
+ }, []);
+
+ const openActionFor = (msg: ChatMessage) => {
+ setSelectedMessage(msg);
+ setActionMode(true);
+ };
+
+ const closeActionMode = () => {
+ setActionMode(false);
+ setSelectedMessage(null);
+ };
+
+ const startReplyFor = useCallback((msg: ChatMessage) => {
+ setReplyingTo(msg);
+ activeReplyRef.current = msg;
+ closeActionMode();
+ }, []);
+
+ const startReply = () => {
+ if (!selectedMessage) return;
+ startReplyFor(selectedMessage);
+ };
+
+ const startForward = () => {
+ if (!selectedMessage) return;
+ setForwardMessage(selectedMessage);
+ closeActionMode();
+ };
+
+ const copySelectedMessage = async () => {
+ if (!selectedMessage) return;
+ const text = getCopyableMessageText(selectedMessage);
+ if (!text) {
+ toast.error(t("chats.toast.copyEmpty"));
+ return;
+ }
+
+ const copied = await copyTextToClipboard(text);
+ if (copied) {
+ toast.success(t("chats.toast.copySuccess"));
+ closeActionMode();
+ return;
+ }
+
+ toast.error(t("chats.toast.copyFailed"));
+ };
+
+ const reactToMessage = async (emoji: string) => {
+ if (!selectedMessage?._id || selectedMessage._id.startsWith("temp-")) return;
+ const messageId = selectedMessage._id;
+ const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId());
+ const targetReceiverId = normalizeThreadId(
+ userTwoDetail?._id ?? receiverId ?? chatPartnerId
+ );
+
+ const toggleReaction = (
+ reactions: ChatMessage["reactions"] = []
+ ): ChatMessage["reactions"] => {
+ const list = [...(reactions ?? [])];
+ const idx = list.findIndex((r) => String(r.userId) === currentUserId);
+ if (idx >= 0 && list[idx].emoji === emoji) {
+ list.splice(idx, 1);
+ } else if (idx >= 0) {
+ list[idx] = { userId: currentUserId, emoji };
+ } else {
+ list.push({ userId: currentUserId, emoji });
+ }
+ return list;
+ };
+
+ const optimisticReactions = toggleReaction(selectedMessage.reactions);
+ if (currentUserId && targetReceiverId) {
+ queryClient.setQueryData(
+ chatThreadQueryKey(currentUserId, targetReceiverId),
+ (oldData: ChatThreadData | undefined) => {
+ if (!oldData) return oldData;
+ return {
+ ...oldData,
+ pages: oldData.pages.map((page) => ({
+ ...page,
+ messages: page.messages.map((m) =>
+ m._id === messageId
+ ? { ...m, reactions: optimisticReactions }
+ : m
+ ),
+ })),
+ };
+ }
+ );
+ }
+
+ try {
+ const response = await request<{ data?: ChatMessage }>(
+ "POST",
+ "/chat/reaction",
+ {
+ messageId,
+ emoji,
+ receiverId: targetReceiverId,
+ }
+ );
+ const updated = response?.data;
+ if (updated && currentUserId && targetReceiverId) {
+ queryClient.setQueryData(
+ chatThreadQueryKey(currentUserId, targetReceiverId),
+ (oldData: ChatThreadData | undefined) => upsertMessageInThreadCache(oldData, updated)
+ );
+ }
+ closeActionMode();
+ } catch {
+ if (currentUserId && targetReceiverId) {
+ queryClient.setQueryData(
+ chatThreadQueryKey(currentUserId, targetReceiverId),
+ (oldData: ChatThreadData | undefined) => {
+ if (!oldData) return oldData;
+ return {
+ ...oldData,
+ pages: oldData.pages.map((page) => ({
+ ...page,
+ messages: page.messages.map((m) =>
+ m._id === messageId
+ ? { ...m, reactions: selectedMessage.reactions }
+ : m
+ ),
+ })),
+ };
+ }
+ );
+ }
+ toast.error(t("chats.toast.reactFailed"));
+ }
+ };
+
+ const handleBlockChange = (blocked?: boolean) => {
+ if (typeof blocked === "boolean") {
+ setUserTwoDetail((prev) =>
+ prev ? { ...prev, is_blocked: blocked } : prev
+ );
+ }
+ void request<{ user: User }>(
+ "GET",
+ `/users/get/web?user_name=${username}&_t=${Date.now()}`
+ ).then((res) => {
+ if (res?.user) setUserTwoDetail(res.user);
+ });
+ };
+
+ const startDeleteMode = () => {
+ closeActionMode();
+ setSearchQuery("");
+ setDeleteMode(true);
+ setSelectedDeleteIds([]);
+ };
+
+ const cancelDeleteMode = () => {
+ setDeleteMode(false);
+ setSelectedDeleteIds([]);
+ };
+
+ const toggleDeleteSelect = (msg: ChatMessage) => {
+ setSelectedDeleteIds((prev) =>
+ prev.includes(msg._id)
+ ? prev.filter((id) => id !== msg._id)
+ : [...prev, msg._id]
+ );
+ };
+
+ const confirmDeleteMessages = async () => {
+ if (!selectedDeleteIds.length || !user?._id) return;
+ setDeleteLoading(true);
+ try {
+ const response = await request<{ deletedIds: string[]; message?: string }>(
+ "DELETE",
+ "/chat",
+ { messageIds: selectedDeleteIds }
+ );
+ const deleted = new Set(response?.deletedIds || []);
+ setPendingMessages((prev) => prev.filter((m) => !deleted.has(m._id)));
+ queryClient.setQueryData(
+ chatThreadQueryKey(
+ normalizeThreadId(user._id),
+ normalizeThreadId(userTwoDetail?._id ?? receiverId ?? chatPartnerId)
+ ),
+ (oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => {
+ if (!oldData) return oldData;
+ return {
+ ...oldData,
+ pages: oldData.pages.map((page) => ({
+ ...page,
+ messages: page.messages.filter((m) => !deleted.has(m._id)),
+ })),
+ };
+ }
+ );
+ toast.success(response?.message || t("chats.toast.messagesDeleted"));
+ cancelDeleteMode();
+ } catch {
+ toast.error(t("chats.toast.deleteMessagesFailed"));
+ } finally {
+ setDeleteLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {
+ scrollToMessageRef.current = fn;
+ }}
+ extraBottomRem={
+ (replyingTo ? 2.75 : 0) +
+ (selfDestructSeconds != null ? 2 : 0) +
+ (viewOnceMedia ? 2 : 0)
+ }
+ />
+
+
+ {deleteMode ? (
+
+ ) : actionMode ? (
+
+ ) : (
+ {
+ setReplyingTo(null);
+ activeReplyRef.current = null;
+ }}
+ onReplyPreviewClick={() => {
+ if (replyingTo?._id) scrollToMessageRef.current(replyingTo._id);
+ }}
+ replyLabel={
+ replyingTo
+ ? replyingTo.content?.slice(0, 80) || t("chats.message")
+ : undefined
+ }
+ selfDestructSeconds={selfDestructSeconds}
+ onSelfDestructChange={setSelfDestructSeconds}
+ viewOnceMedia={viewOnceMedia}
+ onViewOnceChange={setViewOnceMedia}
+ onGiftClick={() => setShowGiftModal(true)}
+ giftDisabled={userTwoDetail?.gift_enabled === false}
+ />
+ )}
+
+
+ setShowGiftModal(false)}
+ receiverId={String(userTwoDetail?._id ?? receiverId ?? chatPartnerId)}
+ returnPath={`/settings/chats/${username}/${chatPartnerId}`}
+ onWalletGiftSent={sendGiftMessage}
+ />
+
+ setGiftResultStatus(null)}
+ onRetry={() => {
+ setGiftResultStatus(null);
+ setShowGiftModal(true);
+ }}
+ />
+
+ {
+ setSocialSignupOpen(false);
+ pendingSendRetryRef.current = null;
+ }}
+ onSuccess={() => {
+ setSocialSignupOpen(false);
+ pendingSendRetryRef.current?.();
+ pendingSendRetryRef.current = null;
+ }}
+ />
+
+
+ setPendingImages((prev) => {
+ const next = prev.filter((_, idx) => idx !== i);
+ if (next.length === 0) setShowMultiModal(false);
+ return next;
+ })
+ }
+ onCancel={() => {
+ setPendingImages([]);
+ setShowMultiModal(false);
+ }}
+ onConfirm={sendAllImages}
+ viewOnceMedia={viewOnceMedia}
+ onViewOnceChange={setViewOnceMedia}
+ selfDestructSeconds={selfDestructSeconds}
+ onSelfDestructChange={setSelfDestructSeconds}
+ />
+ setPendingMediaPreview(null)}
+ onConfirm={() => {
+ if (!pendingMediaPreview) return;
+ const { file, kind } = pendingMediaPreview;
+ setPendingMediaPreview(null);
+ void processSendMessage(file, kind === "video" ? "video" : "file");
+ }}
+ viewOnceMedia={viewOnceMedia}
+ onViewOnceChange={setViewOnceMedia}
+ selfDestructSeconds={selfDestructSeconds}
+ onSelfDestructChange={setSelfDestructSeconds}
+ />
+ {forwardMessage && user?._id && (
+ setForwardMessage(null)}
+ message={forwardMessage}
+ currentUserId={user._id}
+ currentReceiverId={receiverId}
+ />
+ )}
+
+
+ );
+}
+
+export default dynamic(() => Promise.resolve(TicketChat), { ssr: false });
diff --git a/src/app/settings/chats/rooms/[roomId]/page.tsx b/src/app/settings/chats/rooms/[roomId]/page.tsx
index e85817e..ac4585c 100644
--- a/src/app/settings/chats/rooms/[roomId]/page.tsx
+++ b/src/app/settings/chats/rooms/[roomId]/page.tsx
@@ -15,8 +15,7 @@ import {
joinGroupRoom,
joinUserRoom,
leaveGroupRoom,
- releaseChatSocket,
-} from "@/lib/chat/socketClient";
+ releaseChatSocket, isChatSocketConnected, } from "@/lib/chat/socketClient";
import MessageInput from "@/components/chat/MessageInput";
import ChatActionBar from "@/components/chat/ChatActionBar";
import AddChatRoomMembersModal from "@/components/chat/AddChatRoomMembersModal";
@@ -549,7 +548,12 @@ export default function ChatRoomThreadPage({
useEffect(() => {
if (loading) return;
+ // وقتی سوکت وصله، پیامهای گروه real-time (newGroupMessage) میرسن؛ polling فقط
+ // همگامسازیِ ایمنی با فاصلهی بیشتره، وگرنه همون ۸ ثانیهی قبلی
+ let tick = 0;
const poll = window.setInterval(() => {
+ tick += 1;
+ if (isChatSocketConnected() && tick % 8 !== 0) return;
void loadMessages();
void loadRoom();
}, 8000);
@@ -630,7 +634,7 @@ export default function ChatRoomThreadPage({
: await (async () => {
await ensureIdentityKeys(senderId);
const encrypted = await encryptRoomContent(senderId, roomId, text);
- if (!encrypted) throw new Error("E2EE encryption failed");
+ if (encrypted === null) throw new Error("E2EE encryption failed");
return encrypted;
})();
diff --git a/src/app/settings/edit/password/page.tsx b/src/app/settings/edit/password/page.tsx
index 178d6f2..0e39c46 100644
--- a/src/app/settings/edit/password/page.tsx
+++ b/src/app/settings/edit/password/page.tsx
@@ -64,7 +64,7 @@ function PasswordPage() {
// رمزِ عبور عوض شد (یا اولینبار تنظیم شد) — کلیدِ E2EEِ همین مرورگر
// (دستنخورده توی IndexedDB) رو زیرِ رمزِ جدید دوباره بکآپ بگیر
if (user?._id) {
- void establishIdentityAtLogin(user._id, values.password);
+ // [E2EE] موقتاً غیرفعال — void establishIdentityAtLogin(user._id, values.password);
}
toast.success(
isGoogleUser
diff --git a/src/app/settings/my-billboards/page.tsx b/src/app/settings/my-billboards/page.tsx
index c72ab86..6ee749b 100644
--- a/src/app/settings/my-billboards/page.tsx
+++ b/src/app/settings/my-billboards/page.tsx
@@ -1,5 +1,7 @@
"use client";
+import { useSearchParams } from "next/navigation";
+
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
@@ -31,6 +33,8 @@ function MyBillboard() {
const { t } = useTranslation("common");
const router = useRouter();
+ const searchParams = useSearchParams();
+ const unpaidOnly = searchParams.get("filter") === "unpaid";
const [searchText, setSearchText] = useState("");
const [search, setSearch] = useState("");
const [showFilterModal, setShowFilterModal] = useState(false);
@@ -106,7 +110,9 @@ function MyBillboard() {
) : (
data?.pages?.map((page, pageIndex) => (
- {page?.advertisings?.map((item: IAdvertising) => (
+ {page?.advertisings
+ ?.filter((item: IAdvertising) => !unpaidOnly || (item as { effective_status?: string })?.effective_status === "pending_payment")
+ .map((item: IAdvertising) => (
diff --git a/src/app/settings/wallet/page.tsx b/src/app/settings/wallet/page.tsx
index 15e450f..b0aa041 100644
--- a/src/app/settings/wallet/page.tsx
+++ b/src/app/settings/wallet/page.tsx
@@ -8,7 +8,6 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
-import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -31,7 +30,6 @@ type WalletSummary = {
export default function WalletSettingsPage() {
const { t } = useTranslation("common");
- const searchParams = useSearchParams();
const { request, loading } = useAxios();
const [bucket, setBucket] = useState
(BUCKET_SHOP);
@@ -47,9 +45,17 @@ export default function WalletSettingsPage() {
useEffect(() => {
loadSummary();
- const charge = searchParams.get("charge");
- if (charge === "success") toast.success(t("shops.paymentSuccessTitle"));
- if (charge === "failed") toast.error(t("shops.paymentFailedTitle"));
+ const onVisible = () => {
+ if (document.visibilityState === "visible") loadSummary();
+ };
+ window.addEventListener("pageshow", loadSummary);
+ window.addEventListener("focus", loadSummary);
+ document.addEventListener("visibilitychange", onVisible);
+ return () => {
+ window.removeEventListener("pageshow", loadSummary);
+ window.removeEventListener("focus", loadSummary);
+ document.removeEventListener("visibilitychange", onVisible);
+ };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -81,16 +87,19 @@ export default function WalletSettingsPage() {
const amount = Number(chargeAmount);
if (!amount || amount <= 0) return;
try {
- const response = await request<{ authority?: string }>(
+ const response = await request<{ authority?: string; paymentUrl?: string }>(
"POST",
"/wallet/charge/initiate",
{ amount }
);
- if (response?.authority) {
- // لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
- // مطمئنتره (خصوصاً سافاری)
- window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
- }
+ const target =
+ response?.paymentUrl ||
+ (response?.authority
+ ? `https://www.zarinpal.com/pg/StartPay/${response.authority}`
+ : null);
+ // لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
+ // مطمئنتره (خصوصاً سافاری)
+ if (target) window.location.href = target;
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
@@ -99,9 +108,21 @@ export default function WalletSettingsPage() {
}
};
+ const cta =
+ bucket === BUCKET_CHARGED
+ ? { label: t("shops.chargeWallet"), onClick: handleCharge, disabled: loading }
+ : {
+ label: t("shops.requestWithdrawal"),
+ onClick: handleWithdraw,
+ disabled:
+ loading ||
+ (bucket === BUCKET_GIFT &&
+ (!summary || summary.gift.available < summary.gift.minWithdrawal)),
+ };
+
return (
-
+
{t("settings.nav.wallet")}
@@ -173,14 +194,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
-
- {t("shops.requestWithdrawal")}
-
>
)}
@@ -206,14 +219,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
-
- {t("shops.requestWithdrawal")}
-
>
)}
@@ -234,14 +239,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
-
- {t("shops.requestWithdrawal")}
-
>
)}
@@ -262,14 +259,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
-
- {t("shops.requestWithdrawal")}
-
>
)}
@@ -290,14 +279,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
-
- {t("shops.requestWithdrawal")}
-
>
)}
@@ -319,19 +300,24 @@ export default function WalletSettingsPage() {
value={chargeAmount}
onChange={(e) => setChargeAmount(e.target.value)}
/>
-
- {t("shops.chargeWallet")}
-
>
)}
+
+ {summary ? (
+
+
+ {cta.label}
+
+
+ ) : null}
);
}
diff --git a/src/app/settings/wallet/payment/failed/page.tsx b/src/app/settings/wallet/payment/failed/page.tsx
new file mode 100644
index 0000000..195796f
--- /dev/null
+++ b/src/app/settings/wallet/payment/failed/page.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import Container from "@/components/elements/Container";
+import RoundedButton from "@/components/elements/RoundedButton";
+import RoundedDiv from "@/components/elements/RoundedDiv";
+import PageTitle from "@/components/settings/PageTitle";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import useAxios from "@/hooks/useAxios";
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useState } from "react";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+
+export default function WalletPaymentFailedPage() {
+ const { t } = useTranslation("common");
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { request } = useAxios();
+ const [paying, setPaying] = useState(false);
+
+ const amount = Number(searchParams.get("amount") || 0);
+
+ const retryPayment = async () => {
+ if (!amount || amount <= 0 || paying) return;
+
+ setPaying(true);
+ try {
+ const response = await request<{ authority?: string; paymentUrl?: string }>(
+ "POST",
+ "/wallet/charge/initiate",
+ { amount },
+ { noToast: true }
+ );
+
+ const paymentUrl =
+ response.paymentUrl ||
+ (response.authority
+ ? `https://www.zarinpal.com/pg/StartPay/${response.authority}`
+ : null);
+
+ if (!paymentUrl) {
+ toast.error(t("settings.paymentStartError"));
+ setPaying(false);
+ return;
+ }
+
+ window.location.href = paymentUrl;
+ } catch (error: unknown) {
+ const message =
+ (error as { response?: { data?: { message?: string } } })?.response
+ ?.data?.message || t("settings.paymentStartError");
+ toast.error(message);
+ setPaying(false);
+ }
+ };
+
+ return (
+
+
+ {t("shops.paymentFailedTitle")}
+
+
+
+ {t("shops.paymentFailedHint")}
+
+
+
+ {amount > 0 && (
+
+ {amount.toLocaleString()} {t("settings.toman")}
+
+ )}
+
+
+
+ {paying ? "..." : t("offerPage.paymentFailed.retry")}
+
+
+ router.push("/settings/wallet")}
+ className="h-9 flex-1"
+ >
+ {t("common.back")}
+
+
+
+
+
+ );
+}
diff --git a/src/app/settings/wallet/payment/success/page.tsx b/src/app/settings/wallet/payment/success/page.tsx
new file mode 100644
index 0000000..a03ace3
--- /dev/null
+++ b/src/app/settings/wallet/payment/success/page.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import Container from "@/components/elements/Container";
+import RoundedButton from "@/components/elements/RoundedButton";
+import RoundedDiv from "@/components/elements/RoundedDiv";
+import PageTitle from "@/components/settings/PageTitle";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import Image from "next/image";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { useTranslation } from "react-i18next";
+
+export default function WalletPaymentSuccessPage() {
+ const { t } = useTranslation("common");
+ const searchParams = useSearchParams();
+ const amount = Number(searchParams.get("amount") || 0);
+
+ return (
+
+
+ {t("shops.paymentSuccessTitle")}
+
+
+
+
+
+ {t("shops.paymentSuccessHint")}
+
+
+
+ {amount > 0 && (
+
+
+ {amount.toLocaleString()} {t("settings.toman")}
+
+
+ )}
+
+
+
?
+
?
+ {t("settings.confirm")}
+
+
+
+
+
+ );
+}
diff --git a/src/app/settings/workroom/page.tsx b/src/app/settings/workroom/page.tsx
index a7c2e21..df8dd50 100644
--- a/src/app/settings/workroom/page.tsx
+++ b/src/app/settings/workroom/page.tsx
@@ -1,5 +1,7 @@
"use client";
+import { useSearchParams } from "next/navigation";
+
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
@@ -37,7 +39,8 @@ const Workroom = () => {
const { t } = useTranslation("common");
const router = useRouter();
const user = useUser();
- const [statusFilter, setStatusFilter] = useState("");
+ const searchParams = useSearchParams();
+ const [statusFilter, setStatusFilter] = useState(searchParams.get("filter") === "unpaid" ? "unpaid" : "");
const [showDrafts, setShowDrafts] = useState(false);
const [drafts, setDrafts] = useState([]);
diff --git a/src/app/shops/listing/[listingId]/ListingDetailClient.tsx b/src/app/shops/listing/[listingId]/ListingDetailClient.tsx
index d592f83..fdd5a65 100644
--- a/src/app/shops/listing/[listingId]/ListingDetailClient.tsx
+++ b/src/app/shops/listing/[listingId]/ListingDetailClient.tsx
@@ -564,7 +564,7 @@ export default function ListingDetailClient() {
{showAddressModal && (
-
+
{t("shops.addressRequiredHint")}
{t("shops.paymentFailedHint")}
-
+
{t("shops.retryPayment")}
- router.push(shopId ? `/shops/profile/${shopId}` : "/shops/discover")
+ router.push("/settings/shop")
}
>
- {t("shops.backToShopList")}
+ {t("common.back")}
diff --git a/src/app/shops/payment/success/page.tsx b/src/app/shops/payment/success/page.tsx
index 31f1583..1fcfe98 100644
--- a/src/app/shops/payment/success/page.tsx
+++ b/src/app/shops/payment/success/page.tsx
@@ -32,17 +32,9 @@ export default function ShopPaymentSuccessPage() {
- router.push(orderId ? `/shops/orders/${orderId}` : "/settings/shop")
- }
+ onClick={() => router.push("/settings/shop")}
>
- {t("shops.viewOrder")}
-
-
router.push("/")}
- >
- {t("shops.backToHome")}
+ {t("settings.confirm")}
diff --git a/src/components/auth/CompleteSocialSignupModal.tsx b/src/components/auth/CompleteSocialSignupModal.tsx
index df2aba3..247282b 100644
--- a/src/components/auth/CompleteSocialSignupModal.tsx
+++ b/src/components/auth/CompleteSocialSignupModal.tsx
@@ -104,7 +104,7 @@ export default function CompleteSocialSignupModal({
);
// اولین باریه این کاربر رمزِ عبور داره — همین الان کلیدِ E2EE رو زیرش بکآپ بگیر
const userId = getStoredUserId();
- if (userId) void establishIdentityAtLogin(userId, password);
+ // [E2EE] موقتاً غیرفعال — if (userId) void establishIdentityAtLogin(userId, password);
reset();
onSuccess(response.user_name);
} catch (err) {
diff --git a/src/components/chat/ForwardMessageModal.tsx b/src/components/chat/ForwardMessageModal.tsx
index e1f8706..14d0993 100644
--- a/src/components/chat/ForwardMessageModal.tsx
+++ b/src/components/chat/ForwardMessageModal.tsx
@@ -101,7 +101,7 @@ export default function ForwardMessageModal({
receiverId,
content
);
- if (!encrypted) throw new Error("E2EE encryption failed");
+ if (encrypted === null) throw new Error("E2EE encryption failed");
const sendContent = encrypted;
return request("POST", "/chat", {
content: sendContent,
diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx
index 45aa34a..d2f9606 100644
--- a/src/components/chat/MessageInput.tsx
+++ b/src/components/chat/MessageInput.tsx
@@ -40,7 +40,9 @@ interface MessageInputProps {
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
- return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
+ return `${mins.toString().padStart(2, "0")}:${secs
+ .toString()
+ .padStart(2, "0")}`;
};
const MessageInput = ({
@@ -64,17 +66,21 @@ const MessageInput = ({
giftDisabled = false,
}: MessageInputProps) => {
const { t } = useTranslation("common");
+
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [menuOpen, setMenuOpen] = useState(false);
const [isLocked, setIsLocked] = useState(false);
const [dragProgress, setDragProgress] = useState(0);
+
const mediaRecorderRef = useRef
(null);
const audioChunksRef = useRef([]);
const timerRef = useRef(null);
const streamRef = useRef(null);
const dragStartYRef = useRef(null);
+
const LOCK_THRESHOLD_PX = 80;
+
const attachBtnRef = useRef(null);
const imageInputRef = useRef(null);
const videoInputRef = useRef(null);
@@ -84,8 +90,11 @@ const MessageInput = ({
const adjustTextareaHeight = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
+
el.style.height = "auto";
+
const maxHeight = 160;
+
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
}, []);
@@ -94,24 +103,32 @@ const MessageInput = ({
}, [newMessage, adjustTextareaHeight]);
const hasText = newMessage.trim().length > 0;
+
const showSend = hasText && !isRecording;
const handleSend = useCallback(() => {
- if (hasText && !blocked_you) sendMessage();
+ if (hasText && !blocked_you) {
+ sendMessage();
+ }
}, [hasText, blocked_you, sendMessage]);
- // ردیف تایمر/یکبارمصرف فقط هنگام ضبط ویس (برای عکس داخل پیشنمایش نمایش داده میشود)
+
+ // ردیف تایمر/یکبارمصرف فقط هنگام ضبط ویس
const showMediaOptions = isChatThread && isRecording;
const handleFileChange = (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
+
if (file) {
const MAX_SIZE = 200 * 1024 * 1024;
+
if (file.size > MAX_SIZE) {
alert(t("chats.toast.fileTooLarge"));
return;
}
}
+
handleFileSelection(e);
+
e.target.value = "";
};
@@ -121,11 +138,17 @@ const MessageInput = ({
setRecordingTime((prev) => prev + 1);
}, 1000);
} else {
- if (timerRef.current) clearInterval(timerRef.current);
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ }
+
setRecordingTime(0);
}
+
return () => {
- if (timerRef.current) clearInterval(timerRef.current);
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ }
};
}, [isRecording]);
@@ -134,14 +157,19 @@ const MessageInput = ({
if (mediaRecorderRef.current?.state === "recording") {
mediaRecorderRef.current.stop();
}
+
setIsRecording(false);
setIsLocked(false);
setDragProgress(0);
+
dragStartYRef.current = null;
+
if (!shouldSend) {
audioChunksRef.current = [];
}
- streamRef.current?.getTracks().forEach((t) => t.stop());
+
+ streamRef.current?.getTracks().forEach((track) => track.stop());
+
streamRef.current = null;
},
[]
@@ -149,11 +177,18 @@ const MessageInput = ({
const startRecording = async () => {
if (blocked_you) return;
+
try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ });
+
streamRef.current = stream;
+
const mediaRecorder = new MediaRecorder(stream);
+
mediaRecorderRef.current = mediaRecorder;
+
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
@@ -165,32 +200,53 @@ const MessageInput = ({
const audioBlob = new Blob(audioChunksRef.current, {
type: "audio/webm",
});
+
handleVoiceUpload(audioBlob);
}
+
audioChunksRef.current = [];
};
mediaRecorder.start();
+
setIsRecording(true);
} catch {
alert(t("chats.toast.micDenied"));
}
};
- const handleMicPointerDown = (e: React.PointerEvent) => {
+ const handleMicPointerDown = (
+ e: React.PointerEvent
+ ) => {
if (!hasText && !blocked_you) {
dragStartYRef.current = e.clientY;
+
setIsLocked(false);
setDragProgress(0);
+
e.currentTarget.setPointerCapture(e.pointerId);
+
startRecording();
}
};
- const handleMicPointerMove = (e: React.PointerEvent) => {
- if (!isRecording || isLocked || dragStartYRef.current == null) return;
+ const handleMicPointerMove = (
+ e: React.PointerEvent
+ ) => {
+ if (
+ !isRecording ||
+ isLocked ||
+ dragStartYRef.current == null
+ ) {
+ return;
+ }
+
const delta = dragStartYRef.current - e.clientY;
- setDragProgress(Math.max(0, Math.min(1, delta / LOCK_THRESHOLD_PX)));
+
+ setDragProgress(
+ Math.max(0, Math.min(1, delta / LOCK_THRESHOLD_PX))
+ );
+
if (delta >= LOCK_THRESHOLD_PX) {
setIsLocked(true);
dragStartYRef.current = null;
@@ -198,8 +254,12 @@ const MessageInput = ({
};
const handleMicPointerUp = () => {
- if (isRecording && !isLocked) stopRecording(true);
+ if (isRecording && !isLocked) {
+ stopRecording(true);
+ }
+
dragStartYRef.current = null;
+
setDragProgress(0);
};
@@ -209,12 +269,15 @@ const MessageInput = ({
case "camera":
imageInputRef.current?.click();
break;
+
case "video":
videoInputRef.current?.click();
break;
+
case "file":
fileInputRef.current?.click();
break;
+
case "location":
onLocationShare?.();
break;
@@ -224,7 +287,9 @@ const MessageInput = ({
return (
{(replyLabel ||
@@ -237,6 +302,7 @@ const MessageInput = ({
{t("chats.mediaOptions")}
+
{onSelfDestructChange && (
)}
+
{onViewOnceChange && (
)}
+
{replyLabel && (
)}
+
{selfDestructSeconds != null && isChatThread && (
-
+
+
{t("chats.timedBanner", {
- duration: formatTimedLabel(selfDestructSeconds, t),
+ duration: formatTimedLabel(
+ selfDestructSeconds,
+ t
+ ),
})}
)}
+
{viewOnceMedia && isChatThread && (
- {t("chats.viewOnceBadge")}
+
+ {t("chats.viewOnceBadge")}
+
+
{t("chats.viewOnceBanner", {
label: viewOnceLabel("image", t),
@@ -306,9 +388,16 @@ const MessageInput = ({
)}
)}
+
+ {/*
+ مهم:
+ این ردیف همان max-w-lg هدر را دارد.
+ min-w-0 روی ردیف و کادر پیام باعث میشود
+ در موبایل از عرض تعیینشده بیرون نزند.
+ */}
+
setMenuOpen(false)}
onSelect={handleAttachmentSelect}
anchorRef={attachBtnRef}
/>
+
{onGiftClick && (
)}
{/* Glass input box */}
+
+
{isRecording ? (
-
-
-
+
+
+
+
+
{formatTime(recordingTime)}
+
{!isLocked && (
-
+
{t("chats.voice.slideUpToLock")}
)}
+
{isLocked && (
-
+
)}
-
+
+
+
{isLocked && (
)}
@@ -434,7 +560,7 @@ const MessageInput = ({
}
}}
disabled={blocked_you}
- className="min-h-[1.5rem] max-h-40 flex-1 resize-none overflow-y-auto bg-transparent text-[15px] leading-6 outline-none placeholder:text-neutral-400 dark:text-white"
+ className="min-h-[1.5rem] min-w-0 max-h-40 flex-1 resize-none overflow-y-auto bg-transparent text-[15px] leading-6 outline-none placeholder:text-neutral-400 dark:text-white"
/>
)}
@@ -455,21 +581,42 @@ const MessageInput = ({
style={chatHeaderGlassStyle}
aria-label={t("chats.aria.send")}
>
-
+
) : (
-
+
{isRecording && !isLocked && (
-
+
-
+
+
)}
+
isRecording && !isLocked && stopRecording(true)}
+ onPointerLeave={() =>
+ isRecording &&
+ !isLocked &&
+ stopRecording(true)
+ }
className={`${chatHeaderCircleBtn} gentle-transition ${
- isRecording ? "scale-110 !bg-red-500 text-white" : ""
+ isRecording
+ ? "scale-110 !bg-red-500 text-white"
+ : ""
} active:scale-90`}
- style={isRecording ? undefined : chatHeaderGlassStyle}
+ style={
+ isRecording
+ ? undefined
+ : chatHeaderGlassStyle
+ }
aria-label={t("chats.aria.recordVoice")}
>
- {isRecording ? : }
+ {isRecording ? (
+
+ ) : (
+
+ )}
)}
@@ -497,4 +667,4 @@ const MessageInput = ({
);
};
-export default MessageInput;
+export default MessageInput;
\ No newline at end of file
diff --git a/src/components/explore/ExploreReelsView.tsx b/src/components/explore/ExploreReelsView.tsx
index 78d04cd..f2ca129 100644
--- a/src/components/explore/ExploreReelsView.tsx
+++ b/src/components/explore/ExploreReelsView.tsx
@@ -417,7 +417,7 @@ export default function ExploreReelsView({
if (!scrollRef.current || index + 1 >= items.length) return;
scrollRef.current.scrollTo({
top: (index + 1) * getReelsStepHeight(scrollRef.current),
- behavior: "smooth",
+ behavior: "auto",
});
}}
/>
diff --git a/src/components/models/InfinitePosts.tsx b/src/components/models/InfinitePosts.tsx
index 9f4489c..f81ec8c 100644
--- a/src/components/models/InfinitePosts.tsx
+++ b/src/components/models/InfinitePosts.tsx
@@ -74,7 +74,7 @@ export default function InfinitePosts({
queryFn: ({ pageParam = 1 }) =>
fetchPosts(
pageParam as number,
- 4,
+ 12,
{ ...filters, feedMode: "grid" },
token
),
@@ -94,7 +94,7 @@ export default function InfinitePosts({
try {
const peek = await fetchPosts(
1,
- 4,
+ 12,
{ ...filters, feedMode: "grid" },
token
);
diff --git a/src/components/models/MainModelCard/MainModelCard.tsx b/src/components/models/MainModelCard/MainModelCard.tsx
index f9e6861..e7d9633 100644
--- a/src/components/models/MainModelCard/MainModelCard.tsx
+++ b/src/components/models/MainModelCard/MainModelCard.tsx
@@ -33,6 +33,9 @@ import { useTranslation } from "react-i18next";
import { getPostVideoSrc } from "@/lib/videoPreload";
import { getVideoPoster } from "@/lib/explore/postMedia";
import { resolvePostMediaType } from "@/lib/reelsMediaType";
+import { usePostSocialState } from "@/hooks/usePostSocialActions";
+
+const DOUBLE_TAP_MS = 280;
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارتها
const MutedContext = createContext<{
@@ -81,11 +84,6 @@ function MainModelCard({
}, [postData._id]);
const [imageLoading, setImageLoading] = useState(true);
const videoRef = useRef(null);
- const [ripple, setRipple] = useState<{
- x: number;
- y: number;
- key: number;
- } | null>(null);
// استفاده از Context برای وضعیت muted گلوبال
const { muted, setMuted } = useContext(MutedContext);
@@ -122,27 +120,38 @@ function MainModelCard({
const videoPoster =
mediaType === "video" ? getVideoPoster(postData) : undefined;
- // تابع برای ایجاد افکت ripple و تغییر وضعیت صدا
- const handleVideoClick = (e: React.MouseEvent) => {
+ const social = usePostSocialState(postData);
+ const singleTapTimerRef = useRef(null);
+ const [likeBurstKey, setLikeBurstKey] = useState(null);
+
+ // یک ضربه = باز کردن ریلز (مثل اندروید)، دو ضربهی سریع = پسندیدن. دکمهی صدا جدا و
+ // کلیکپذیره، پس با کلیک روی ویدیو دیگه صدا عوض نمیشه
+ const handleMediaTap = () => {
+ if (singleTapTimerRef.current) {
+ window.clearTimeout(singleTapTimerRef.current);
+ singleTapTimerRef.current = null;
+ if (!social.liked) void social.toggleLike();
+ setLikeBurstKey(Date.now());
+ window.setTimeout(() => setLikeBurstKey(null), 900);
+ return;
+ }
+ if (!onMediaClick) return;
+ singleTapTimerRef.current = window.setTimeout(() => {
+ singleTapTimerRef.current = null;
+ onMediaClick();
+ }, DOUBLE_TAP_MS);
+ };
+
+ const toggleVideoMute = (e: React.MouseEvent) => {
+ e.stopPropagation();
const v = videoRef.current;
if (!v) return;
-
- const rect = v.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const y = e.clientY - rect.top;
-
- setRipple({ x, y, key: Date.now() });
-
- // --- مهمترین اصلاح ---
v.muted = !v.muted;
setVideoMuted(v.muted);
setMuted(v.muted);
-
- if (!v.muted) {
- v.play().catch(() => {});
+ if (!v.muted && v.paused) {
+ void v.play().catch(() => {});
}
-
- setTimeout(() => setRipple(null), 600);
};
// استفاده از IntersectionObserver برای پخش/توقف ویدیو بر اساس ویوپورت
@@ -376,7 +385,7 @@ function MainModelCard({
{
@@ -401,14 +410,6 @@ function MainModelCard({
{mediaType === "video" && videoSrc && (
)}
-
+
{caption?.includes("@") && (
);
diff --git a/src/components/models/MainModelCard/MainModelCardActions.tsx b/src/components/models/MainModelCard/MainModelCardActions.tsx
index 5ed9d2d..968c625 100644
--- a/src/components/models/MainModelCard/MainModelCardActions.tsx
+++ b/src/components/models/MainModelCard/MainModelCardActions.tsx
@@ -17,7 +17,13 @@ import {
} from "@/hooks/usePostSocialActions";
import { useTranslation } from "react-i18next";
-function MainModelCardActions({ postData }: { postData: Post }) {
+function MainModelCardActions({
+ postData,
+ social: sharedSocial,
+}: {
+ postData: Post;
+ social?: ReturnType;
+}) {
const { _id: postId, user_id } = postData;
const { t } = useTranslation("common");
@@ -36,7 +42,8 @@ function MainModelCardActions({ postData }: { postData: Post }) {
const [showSendModal, setShowSendModal] = useState(false);
const [showServicesModal, setShowServicesModal] = useState(false);
- const social = usePostSocialState(postData);
+ const ownSocial = usePostSocialState(postData);
+ const social = sharedSocial ?? ownSocial;
const iconSize = "sm" as const;
// عین allowSave توی ReelsPostCard.tsx/ReelsPostActions.tsx — این کارت (فید خانه) این
// چک رو نداشت و دکمهی ذخیره همیشه نمایش داده میشد، حتی وقتی نویسنده allow_save_posts
diff --git a/src/components/posts/PostFeedView.tsx b/src/components/posts/PostFeedView.tsx
index c7c1dbb..bb26bae 100644
--- a/src/components/posts/PostFeedView.tsx
+++ b/src/components/posts/PostFeedView.tsx
@@ -807,7 +807,7 @@ export default function PostFeedView({
if (!scrollRef.current || index + 1 >= displayPosts.length) return;
scrollRef.current.scrollTo({
top: (index + 1) * getReelsStepHeight(scrollRef.current),
- behavior: "smooth",
+ behavior: "auto",
});
}}
/>
diff --git a/src/components/posts/ReelsPostCard.tsx b/src/components/posts/ReelsPostCard.tsx
index a906955..3a0153e 100644
--- a/src/components/posts/ReelsPostCard.tsx
+++ b/src/components/posts/ReelsPostCard.tsx
@@ -97,9 +97,9 @@ export default function ReelsPostCard({
const videoSrc = type === "video" ? getPostVideoSrc(postData) : undefined;
const videoPreload =
- preloadPriority === "active" || preloadPriority === "nearby"
+ preloadPriority === "active"
? "auto"
- : preloadPriority === "idle"
+ : preloadPriority === "idle" || preloadPriority === "nearby"
? "metadata"
: "none";
@@ -153,6 +153,10 @@ export default function ReelsPostCard({
const holdTriggeredRef = useRef(false);
const singleTapTimerRef = useRef(null);
+ const [commentsMountedOnce, setCommentsMountedOnce] = useState(false);
+ useEffect(() => {
+ if (commentsOpen) setCommentsMountedOnce(true);
+ }, [commentsOpen]);
const { muted, setMuted } = useContext(MutedContext);
const [videoMuted, setVideoMuted] = useState(true);
const exploreUi = useExploreReelsUi();
@@ -227,57 +231,40 @@ export default function ReelsPostCard({
holdTriggeredRef.current = false;
}, [clearHoldTimer, exploreUi]);
- const handleMediaClick = useCallback(
- (e: React.MouseEvent) => {
- if (postPage || commentsOpen || !isActive) return;
- if (singleTapTimerRef.current) {
- window.clearTimeout(singleTapTimerRef.current);
- }
- singleTapTimerRef.current = window.setTimeout(() => {
- singleTapTimerRef.current = null;
- if (exploreUi?.chromeHidden) {
- exploreUi.setChromeHidden(false);
- return;
- }
- if (e.target === e.currentTarget) {
- // tap on empty overlay only
- }
- }, DOUBLE_TAP_MS);
- },
- [postPage, commentsOpen, isActive, exploreUi]
- );
+ // یک ضربه = پخش/توقف (یا نمایش/مخفیکردنِ آیکونها)، دو ضربهی سریع = پسندیدن — عینِ
+ // اندروید. با تایمر خودمون انجام میشه، نه dblclick مرورگر (که روی لمسی معمولاً اجرا نمیشه)
+ const handleMediaTap = useCallback(() => {
+ if (postPage || commentsOpen || !isActive || holdTriggeredRef.current) return;
- const handleMediaDoubleClick = useCallback(
- (e: React.MouseEvent) => {
- if (postPage || commentsOpen || !isActive) return;
- e.stopPropagation();
- if (singleTapTimerRef.current) {
- window.clearTimeout(singleTapTimerRef.current);
- singleTapTimerRef.current = null;
- }
+ if (singleTapTimerRef.current) {
+ window.clearTimeout(singleTapTimerRef.current);
+ singleTapTimerRef.current = null;
if (!social.liked) {
void social.toggleLike();
}
setLikeBurstKey(Date.now());
window.setTimeout(() => setLikeBurstKey(null), 900);
- },
- [postPage, commentsOpen, isActive, social]
- );
-
- const handleVideoClick = (e: React.MouseEvent) => {
- if (holdTriggeredRef.current || e.detail > 1) return;
- e.stopPropagation();
- const v = videoRef.current;
- if (!v) return;
-
- if (v.paused) {
- void v.play().catch(() => {});
- setVideoPaused(false);
- } else {
- v.pause();
- setVideoPaused(true);
+ return;
}
- };
+
+ singleTapTimerRef.current = window.setTimeout(() => {
+ singleTapTimerRef.current = null;
+ if (exploreUi?.chromeHidden) {
+ exploreUi.setChromeHidden(false);
+ return;
+ }
+ if (type !== "video") return;
+ const v = videoRef.current;
+ if (!v) return;
+ if (v.paused) {
+ void v.play().catch(() => {});
+ setVideoPaused(false);
+ } else {
+ v.pause();
+ setVideoPaused(true);
+ }
+ }, DOUBLE_TAP_MS);
+ }, [postPage, commentsOpen, isActive, exploreUi, social, type]);
const toggleVideoMute = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -350,7 +337,7 @@ export default function ReelsPostCard({
useEffect(() => {
if (type !== "video" || !videoSrc) return;
- if (preloadPriority === "active" || preloadPriority === "nearby") {
+ if (preloadPriority === "active") {
prefetchVideoUrl(videoSrc, "high");
}
}, [type, videoSrc, preloadPriority]);
@@ -494,8 +481,7 @@ export default function ReelsPostCard({
onPointerUp={handleMediaPointerUp}
onPointerLeave={handleMediaPointerUp}
onPointerCancel={handleMediaPointerUp}
- onClick={handleMediaClick}
- onDoubleClick={handleMediaDoubleClick}
+ onClick={handleMediaTap}
>
{type !== "video" && imageFiles.length > 0 && (
@@ -520,7 +506,7 @@ export default function ReelsPostCard({
// دقیقاً روی لبهی پایینِ کادر بشینه (نه وسطچین) — تا صرفنظر از نسبتِ
// ابعادِ صفحهنمایشِ گوشی، این خط ثابت بمونه؛ پوستر هم باید با همون
// لنگرگاه نمایش داده بشه، وگرنه لحظهی لود شدنِ ویدیو یه پرش دیده میشه
- className="object-contain object-bottom"
+ className="object-cover object-center"
priority={isActive || preloadPriority === "nearby"}
// unoptimized: این فقط یه پیشنمایشِ کوتاهمدته تا ویدیو آماده بشه — رد
// کردنش از مسیرِ بهینهسازیِ next/image (ریسایز+تبدیل به avif/webp روی
@@ -543,10 +529,9 @@ export default function ReelsPostCard({
preload={videoPreload}
controlsList="nodownload"
className={cn(
- "h-full w-full object-contain object-bottom",
+ "h-full w-full object-cover object-center",
videoPoster && !videoReady && "opacity-0"
)}
- onClick={handleVideoClick}
// مرورگر نمیتونه از اسکرینشات واقعی جلوگیری کنه (هیچ API مرورگری برای این
// وجود نداره)؛ این فقط راههای سادهی دانلود/ذخیره (کلیک راست، درگ) رو میبندد
onContextMenu={
@@ -599,6 +584,21 @@ export default function ReelsPostCard({
>
)}
+ {type === "video" && isActive && !commentsOpen && !postPage && !videoPaused && !chromeHidden && (
+
+ )}
+
{imageLoading && isActive && !commentsOpen && }
{likeBurstKey ? (
@@ -622,7 +622,7 @@ export default function ReelsPostCard({
{showLongVideoBar && (
-
+
{formatVideoTime(remaining)}
@@ -638,11 +638,11 @@ export default function ReelsPostCard({
>
@@ -674,7 +674,7 @@ export default function ReelsPostCard({
-
+
@@ -711,7 +711,7 @@ export default function ReelsPostCard({
// justify-between (ریت/امتیاز) زیرِ RTL فیزیکاً همون سمت میشینه، این
// فاصلهی اضافه (۶۴px، هماندازهی عرضِ ستونِ آیکونها) از افتادنِ
// ریت/امتیاز زیرِ دکمهی جمعشونده جلوگیری میکنه
- "pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/60 to-transparent pl-16 pr-4 pt-16",
+ "pointer-events-none absolute inset-x-0 bottom-0 z-20 pl-16 pr-4 pt-16",
// وقتی نوار پیشرفت ویدیو نمایش داده میشه، این بلوک باید بالای خطِ اون
// بشینه نه روش؛ وگرنه (تصویر/ویدیوی کوتاه) فقط فاصلهی پایینِ معمولی کافیه
showLongVideoBar
@@ -842,7 +842,7 @@ export default function ReelsPostCard({
/>
)}
- {useCommentsSheet && (
+ {useCommentsSheet && (commentsOpen || commentsMountedOnce) && (
!open && setCommentsOpen(false)}
diff --git a/src/components/projects/Workroom/ProjectRequestsAction.tsx b/src/components/projects/Workroom/ProjectRequestsAction.tsx
index 7748c2e..995168b 100644
--- a/src/components/projects/Workroom/ProjectRequestsAction.tsx
+++ b/src/components/projects/Workroom/ProjectRequestsAction.tsx
@@ -201,7 +201,7 @@ function ProjectRequestsAction({
{viewerUrl ? (
setViewerUrl(null)}
>