This commit is contained in:
payacom
2026-09-19 12:28:28 +03:30
parent 76d5652252
commit 3b89bbfec3
18 changed files with 275 additions and 145 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "modstagram-next",
"version": "0.1.0",
"version": "4.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modstagram-next",
"version": "0.1.0",
"version": "4.1.5",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",

View File

@@ -8,7 +8,7 @@
"start_url": "/?source=pwa&lang=fa",
"id": "modstagram-pwa",
"display": "standalone",
"display_override": ["standalone", "minimal-ui", "browser"],
"display_override": ["fullscreen", "standalone", "minimal-ui", "browser"],
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [

View File

@@ -749,22 +749,30 @@ select {
--btn-accent-text: #ffffff;
}
.btn-modern {
border: 1px solid var(--btn-border-color);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.btn-modern:hover:not(:disabled) {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
}
.btn-modern:active:not(:disabled) {
transform: scale(0.98);
}
.dark .btn-modern {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
}
.dark .btn-modern:hover:not(:disabled) {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.38);
/* داخلِ @layer components (برخلافِ btn-modern--primary/--selected زیر که عمداً
بیرونِ لایه‌ست) تا رنگِ بوردرِ اینجا (خاکستریِ پیش‌فرض) رویِ کلاس‌های Tailwind
مثلِ border-[#FC8EAC] که کامپوننت‌ها (مثلِ btnPrimary) پاس می‌دن سوار نشه —
قبلاً چون این رول بیرونِ هر @layer بود، همیشه برنده می‌شد و باعث می‌شد بوردرِ
دکمه‌های انتخاب‌شده (مثلاً btnPrimary توی تنظیماتِ فروشگاه) هیچ‌وقت صورتی نشه،
با اینکه رنگِ متنش درست صورتی می‌شد (چون فقط border ست شده بود، نه color) */
@layer components {
.btn-modern {
border: 1px solid var(--btn-border-color);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.btn-modern:hover:not(:disabled) {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
}
.btn-modern:active:not(:disabled) {
transform: scale(0.98);
}
.dark .btn-modern {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
}
.dark .btn-modern:hover:not(:disabled) {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.38);
}
}
.btn-modern--primary {

View File

@@ -297,9 +297,11 @@ function Chats() {
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
{isOnline ? onlineLabel : item.last_online || offlineLabel}
</span>
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
{/* طبقِ خواسته‌ی کاربر فعلاً کامنت شد — این متن همیشه ثابت
نمایش داده می‌شد (نه پیشنمایشِ واقعیِ آخرین پیام) */}
{/* <span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
{E2EE_LOCKED_PREVIEW}
</span>
</span> */}
{item.unread_messages_count ? (
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
{item.unread_messages_count}

View File

@@ -5,7 +5,6 @@ import Link from "next/link";
import { useState } from "react";
import ChatBoldIcon from "./ChatBoldIcon";
import ProfileAvatar from "../main/ProfileAvatar";
import { useRouter } from "next/navigation";
import ChatHeaderMenu from "./ChatHeaderMenu";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
@@ -21,8 +20,11 @@ export const chatHeaderCircleBtn = cn(
"dark:text-neutral-100"
);
// اندازه‌ی آواتار از propِ sizePx روی ProfileAvatar میاد (کلاسِ CSS اینجا بی‌اثره
// چون این کامپوننت با userId کار می‌کنه) — فعلاً 38px؛ pr پایین هم ۳px هست تا با
// فاصله‌ی بالا/پایینِ خودکار (h-11=44px منهایِ ۳۸px آواتار، تقسیم‌بر۲) یکی بشه
export const chatHeaderInfoPill = cn(
"chat-header-glass gentle-transition flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full py-[5px] pr-[5px] pl-3",
"chat-header-glass gentle-transition flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full py-[5px] pr-[3px] pl-3",
"active:scale-[0.98]"
);
@@ -42,7 +44,6 @@ function ChatHeader({
deleteMode?: boolean;
}) {
const { t } = useTranslation("common");
const router = useRouter();
const [searchOpen, setSearchOpen] = useState(false);
const onlineLabel = t("chats.online");
@@ -59,15 +60,17 @@ function ChatHeader({
dir="rtl"
className="pointer-events-auto mx-auto flex max-w-lg items-center gap-2"
>
<button
type="button"
onClick={() => router.back()}
{/* router.back() نامطمئن بود — اگه کاربر مستقیم (لینک/نوتیفیکیشن) وارد این
صفحه شده باشه، تاریخچه‌ی مرورگر برای برگشت وجود نداره و دکمه هیچ کاری
نمی‌کرد. عینِ هدرِ گروه (rooms/[roomId]/page.tsx) یه مقصدِ ثابت داره */}
<Link
href="/settings/chats"
className={chatHeaderCircleBtn}
style={chatHeaderGlassStyle}
aria-label={t("chats.actions.back")}
>
<ChatBoldIcon name="back" size={22} className="text-current" />
</button>
</Link>
<Link
href={user?.blocked_you ? "#" : `/users/${user?.user_name}`}
@@ -104,14 +107,13 @@ function ChatHeader({
<ProfileAvatar
src={user.profile_image}
alt={user.user_name || ""}
size="xs"
sizePx={38}
rounded="full"
className="h-[34px] w-[34px] shrink-0"
userId={user._id}
verifyBadge={user.verify_badge}
/>
) : (
<div className="h-[34px] w-[34px] shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-600" />
<div className="h-[38px] w-[38px] shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-600" />
)}
</Link>

View File

@@ -203,6 +203,65 @@ function PreviewOverlay({
);
}
/** عینِ PreviewOverlay: با دکمه‌ی بکِ مرورگر/موبایل هم باید finishViewOnce صدا زده
* بشه (که فایلِ یکبارمصرف رو سمتِ سرور کامل/حذف می‌کنه) — قبلاً این مودال فقط با
* کلیکِ صریح بسته می‌شد، پس با بکِ سیستم، فایل سمتِ سرور هیچ‌وقت finalize نمی‌شد */
function ViewOnceOverlay({
url,
type,
onClose,
}: {
url: string;
type: "image" | "video";
onClose: () => void;
}) {
useEffect(() => {
window.history.pushState({ chatMediaPreview: true }, "");
const onPopState = () => onClose();
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, [onClose]);
const requestClose = () => {
if (window.history.state?.chatMediaPreview) {
window.history.back();
return;
}
onClose();
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="glass-modal-overlay glass-modal-overlay--dark fixed inset-0 z-[9999] flex items-center justify-center"
onClick={requestClose}
>
<motion.div
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
className="max-h-[92vh] max-w-[95vw] p-4"
onClick={(e) => e.stopPropagation()}
>
{type === "video" ? (
<video
src={url}
controls
autoPlay
muted
playsInline
className="max-h-[88vh] rounded-lg"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt="" className="max-h-[88vh] object-contain" />
)}
</motion.div>
</motion.div>
);
}
function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
if (!content || isE2eeEnvelope(content)) return null;
try {
@@ -836,38 +895,11 @@ const ChatMessageCard = ({
<AnimatePresence>
{viewOncePreview && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="glass-modal-overlay glass-modal-overlay--dark fixed inset-0 z-[9999] flex items-center justify-center"
onClick={() => void finishViewOnce()}
>
<motion.div
initial={{ scale: 0.9 }}
animate={{ scale: 1 }}
className="max-h-[92vh] max-w-[95vw] p-4"
onClick={(e) => e.stopPropagation()}
>
{viewOncePreview.type === "video" ? (
<video
src={viewOncePreview.url}
controls
autoPlay
muted
playsInline
className="max-h-[88vh] rounded-lg"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={viewOncePreview.url}
alt=""
className="max-h-[88vh] object-contain"
/>
)}
</motion.div>
</motion.div>
<ViewOnceOverlay
url={viewOncePreview.url}
type={viewOncePreview.type}
onClose={() => void finishViewOnce()}
/>
)}
{preview && (
<PreviewOverlay

View File

@@ -406,6 +406,13 @@ export default function ExploreReelsView({
<ReelsPostCard
postData={item.post}
isActive={item.key === activeKey}
preloadPriority={
item.key === activeKey
? "active"
: Math.abs(index - activeIndex) <= 1
? "nearby"
: "idle"
}
onRequestNext={() => {
if (!scrollRef.current || index + 1 >= items.length) return;
scrollRef.current.scrollTo({

View File

@@ -30,6 +30,10 @@ type ProfileAvatarProps = {
src?: string | null;
alt?: string;
size?: keyof typeof sizeMap;
/** Exact pixel size, overriding the `size` preset — for spots that need a value
* between presets (e.g. 38px). Works whether or not `userId`/StoryRing is used,
* unlike `className` (see note below). */
sizePx?: number;
rounded?: "2xl" | "xl" | "full";
className?: string;
fallback?: string;
@@ -42,6 +46,7 @@ function ProfileAvatar({
src,
alt = "",
size = "md",
sizePx,
rounded = "2xl",
className,
fallback = "/images/fake-avatar.png",
@@ -56,22 +61,25 @@ function ProfileAvatar({
: "rounded-2xl";
const imageSrc = src ? buildStorageUrl(src) : fallback;
const resolvedSizePx = sizePx ?? sizePxMap[size];
const { hasStory, hasUnviewed } = useStoryMetaForUser(userId);
// When wrapped in a StoryRing, `className` (margins, shrink, size overrides) must land
// on the ring's outer box, not this inner <Image> — the ring's middle layer is a fixed-size
// overflow-hidden box, so any margin applied here gets silently clipped instead of creating
// visible spacing (see the search-list "avatar stuck to text" regression).
// visible spacing (see the search-list "avatar stuck to text" regression). `sizePx` isn't
// affected by that — it always drives the actual pixel size via inline style below.
const image = (
<Image
src={imageSrc}
alt={alt}
width={sizePxMap[size]}
height={sizePxMap[size]}
width={resolvedSizePx}
height={resolvedSizePx}
style={sizePx ? { width: sizePx, height: sizePx } : undefined}
className={cn(
"profile-avatar aspect-square object-cover shrink-0",
sizeMap[size],
!sizePx && sizeMap[size],
roundedClass,
!userId && className
)}
@@ -82,15 +90,15 @@ function ProfileAvatar({
return image;
}
// Keep the total rendered footprint equal to sizePxMap[size] in both states.
// Keep the total rendered footprint equal to resolvedSizePx in both states.
// StoryRing only adds ring padding when hasStory is true, so when it *is* true we
// pre-shrink the photo by the ring's width; when it's false we pass the full size
// (StoryRing then renders it 1:1, matching a plain avatar exactly). Either way the
// outer box never grows/shrinks relative to a plain avatar, so wrapping a row's
// avatar with a ring never shifts that row's height or the next element's position.
const avatarSizeForRing = hasStory
? Math.max(0, sizePxMap[size] - STORY_RING_PX * 2)
: sizePxMap[size];
? Math.max(0, resolvedSizePx - STORY_RING_PX * 2)
: resolvedSizePx;
const cornerRadius = rounded === "full" ? avatarSizeForRing / 2 : rounded === "xl" ? 12 : 16;
return (

View File

@@ -96,9 +96,13 @@ function MainModelCardActions({ postData }: { postData: Post }) {
/>
))}
<div className="mt-2 flex w-full flex-row items-center justify-between px-6 sm:px-8">
<SocialIconButton
<SocialStatButton
layout="horizontal"
onClick={() => setShowServicesModal(true)}
requestCount={social.offersCount}
onClick={() => {
social.trackOffer();
setShowServicesModal(true);
}}
aria-label={t("models.actions.collaborationAria")}
icon={
<PostSocialIcon

View File

@@ -733,7 +733,9 @@ export default function PostFeedView({
{showReelsTabs ? (
<div
className={cn(
"pointer-events-none fixed inset-x-0 top-0 z-[90] pt-[calc(2.25rem+env(safe-area-inset-top))] transition-[transform,opacity] duration-300 ease-out",
// ارتفاعِ pt طوری حساب شده که مرکزِ عمودیِ متنِ تب‌ها با مرکزِ عمودیِ
// دکمه‌ی بک (h-9=36px, top: 1rem+safe-area) هم‌تراز بشه
"pointer-events-none fixed inset-x-0 top-0 z-[90] pt-[calc(1.5rem+env(safe-area-inset-top))] transition-[transform,opacity] duration-300 ease-out",
tabsHidden ? "-translate-y-full opacity-0" : "translate-y-0 opacity-100"
)}
>

View File

@@ -12,7 +12,7 @@ import ModelServiceModal from "@/components/models/ModelServiceModal";
import { useUserById } from "@/hooks/getUserById";
import SharePostModal from "@/components/main/SharePostModal";
import SendPostModal from "@/components/posts/SendPostModal";
import { buildStorageUrl, staticIconUrl } from "@/components/main/BaseUrl";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { SocialStatButton, SocialIconButton } from "@/components/ui/SocialStatButton";
import {
POST_SOCIAL_ICONS,
@@ -37,10 +37,6 @@ interface ReelsPostActionsProps {
/** وقتی onCommentClick پاس داده نشه، این کامپوننت خودش CommentsModal رو مدیریت می‌کنه —
* والد (ReelsPostCard) برای مخفی‌کردن نوار پیشرفت ویدیو حین بازبودنِ این مودال باید بدونه */
onCommentsModalOpenChange?: (open: boolean) => void;
/** فاصله‌ی px ستون اکشن‌ها از پایینِ کارت — والد (ReelsPostCard) این رو از رویِ خطِ
* اولِ کپشنِ واقعی اندازه می‌گیره تا دکمه‌ی جمع‌شونده همیشه روبروش بشینه؛ نبودش
* (postPage یا قبل از اولین اندازه‌گیری) یعنی مقدارِ پیش‌فرضِ کلاسِ bottom-36 */
columnBottom?: number;
}
function ReelsProfileFollowButton({
@@ -69,9 +65,12 @@ function ReelsProfileFollowButton({
</div>
);
{/* mb-[7px]: چون بجِ فالو با -bottom-[7px] از جعبه‌ی آواتار بیرون می‌زنه، فاصله‌ی
واقعی‌ش تا آیکونِ بعدی (فنجون) از gap-4 ستون کمتر می‌شد؛ این مارجین همون ۷px
رو جبران می‌کنه تا فاصله با همه‌ی آیکون‌های دیگه یکی بشه */}
if (isOwnPost) {
return (
<Link href={`/users/${userName}`} className="mb-0 block">
<Link href={`/users/${userName}`} className="mb-[7px] block">
{avatar}
</Link>
);
@@ -81,11 +80,14 @@ function ReelsProfileFollowButton({
<button
type="button"
onClick={onToggleFollow}
className="relative mb-0 block"
className="relative mb-[7px] block"
aria-label={following ? t("posts.unfollow") : t("posts.follow")}
>
{avatar}
<span className="absolute -bottom-1 left-1/2 flex h-5 w-5 -translate-x-1/2 items-center justify-center rounded-full border border-white bg-white shadow-md">
{/* -bottom-1(-4px) بود؛ طبقِ خواسته‌ی کاربر ۳px پایین‌تر رفت (مثلِ اندروید) —
چون بزرگ‌ترشدنِ مقدارِ منفیِ bottom روی absolute یعنی پایین‌ترِ لبه‌ی
پرنت، عددِ درست -7px بود نه -1px (اشتباهِ قبلی: جهتِ عکس رفته بود) */}
<span className="absolute -bottom-[7px] left-1/2 flex h-5 w-5 -translate-x-1/2 items-center justify-center rounded-full border border-white bg-white shadow-md">
{following ? (
<BoldIcon
name="tick-circle"
@@ -122,16 +124,15 @@ function ReelsStoryShareButton({
className="inline-flex shrink-0 items-center justify-center disabled:opacity-60"
aria-label={t("posts.addToStory")}
>
{/* اندازه و رنگ دقیقاً هم‌اندازه‌ی بقیه‌ی آیکون‌های ستون اکشن‌ها (reelsIconSize=md:
w-7/28px + همون فیلترِ invert brightness-125) — قبلاً ۳۶px بود، هم‌اندازه‌ی
بقیه نبود */}
<Image
src={staticIconUrl("/images/icons/story.png")}
alt=""
width={28}
height={28}
className="invert brightness-125 drop-shadow-lg"
unoptimized
{/* قبلاً story.png (رستر) با فیلترِ invert/brightness رنگ می‌گرفت که رنگ/ضخامتش
با بقیه‌ی آیکون‌های ستون (که ماسکِ bg-current رو رویِ SVG وکتور می‌زنن) یکی
نمی‌شد؛ همون آیکونِ story.svg از ست vuesax/bold با تکنیکِ ماسکِ BoldIcon
استفاده می‌شه تا دقیقاً هم‌رنگ و هم‌ضخامتِ بقیه بشه */}
<BoldIcon
name="story"
size={28}
tinted
className="text-white drop-shadow-lg"
/>
</button>
);
@@ -146,7 +147,6 @@ export default function ReelsPostActions({
sharingStory = false,
onShareToStory,
onCommentsModalOpenChange,
columnBottom,
}: ReelsPostActionsProps) {
const { t } = useTranslation("common");
const { user_id, user_name } = postData;
@@ -216,9 +216,13 @@ export default function ReelsPostActions({
const postPageActions = (
<>
<SocialIconButton
<SocialStatButton
layout="horizontal"
onClick={() => setShowServicesModal(true)}
requestCount={social.offersCount}
onClick={() => {
social.trackOffer();
setShowServicesModal(true);
}}
className={actionButtonClass}
aria-label={t("posts.collaborationRequest")}
icon={<PostSocialIcon {...POST_SOCIAL_ICONS.collaboration(postIconSize)} />}
@@ -307,9 +311,13 @@ export default function ReelsPostActions({
isOwnPost={social.isOwnPost}
onToggleFollow={social.toggleFollow}
/>
<SocialIconButton
<SocialStatButton
layout="vertical"
onClick={() => setShowServicesModal(true)}
requestCount={social.offersCount}
onClick={() => {
social.trackOffer();
setShowServicesModal(true);
}}
className={reelsActionButtonClass}
aria-label={t("posts.collaborationRequest")}
icon={
@@ -320,7 +328,7 @@ export default function ReelsPostActions({
layout="vertical"
likeCount={social.likesCountState}
onClick={social.toggleLike}
className={reelsActionButtonClass}
className={cn(reelsActionButtonClass, "-mt-[3px]")}
aria-label={t("posts.like")}
icon={
<PostSocialIcon
@@ -421,10 +429,7 @@ export default function ReelsPostActions({
/* عرضِ ستون ثابت (w-12، هم‌اندازه‌ی آواتار که پهن‌ترین آیتمشه) — وگرنه توی
حالتِ جمع‌شده که آواتار نیست، ستون به‌اندازه‌ی آیکونِ باریک‌تر جمع می‌شد و
دکمه‌ی جمع‌شونده (که items-center وسط‌چینش می‌کنه) چند پیکسل به چپ می‌رفت */
<div
className="absolute bottom-36 left-3 z-30 flex w-12 flex-col items-center gap-4"
style={columnBottom != null ? { bottom: columnBottom } : undefined}
>
<div className="absolute bottom-[72px] left-3 z-30 flex w-12 flex-col items-center gap-4">
{/* دکمه‌ی جمع‌شونده همیشه باید آخرین فرزندِ این ستون باشه (نه هرچیزِ دیگه‌ای زیرش)
— این ستون absolute + bottom-36 هست، پس لبه‌ی پایینِ خودِ ستون همیشه ثابته،
اما وقتی فرزندی که زیرِ دکمه‌ی toggle قرار داره (مثلاً منوی سه‌نقطه) شرطی
@@ -455,12 +460,27 @@ export default function ReelsPostActions({
className="flex items-center justify-center"
aria-label={expanded ? t("posts.close") : t("posts.showActions")}
>
<Image
width={28}
height={28}
alt=""
src="/images/icons/actions-toggle.svg"
className={cn("drop-shadow-lg invert", expanded && "opacity-80")}
{/* قبلاً با Image + فیلترِ invert/brightness رنگ می‌گرفت — چون fill خودِ
SVG (#292D32) دقیقاً سیاهِ خالص نیست، بسته به مرورگر گاهی به‌جای
سفیدِ خالص یه سایه‌ی نزدیک‌به‌سفید درمی‌اومد. با ماسکِ bg-current
(عینِ بقیه‌ی آیکون‌های این ستون) دیگه به fill اصلیِ svg وابسته
نیست و همیشه دقیقاً هم‌رنگِ بقیه سفیدِ خالصه */}
<span
aria-hidden
className={cn(
"inline-block h-7 w-7 shrink-0 bg-white drop-shadow-lg",
expanded && "opacity-80"
)}
style={{
WebkitMaskImage: "url(/images/icons/actions-toggle.svg)",
maskImage: "url(/images/icons/actions-toggle.svg)",
WebkitMaskRepeat: "no-repeat",
maskRepeat: "no-repeat",
WebkitMaskPosition: "center",
maskPosition: "center",
WebkitMaskSize: "contain",
maskSize: "contain",
}}
/>
</button>
</div>

View File

@@ -121,22 +121,6 @@ export default function ReelsPostCard({
setCaptionExpanded(false);
}, [postData._id]);
useEffect(() => {
const captionEl = captionRef.current;
const containerEl = containerRef.current;
// اگه بلوکِ کپشن الان mount نیست (کپشنِ خالی، یا chrome/کامنت‌ها مخفیش کرده)، آخرین
// مقدارِ اندازه‌گیری‌شده رو نگه می‌داریم تا با جمع‌کردنِ آیکون‌ها ستون نپره
if (!captionEl || !containerEl) return;
const update = () => {
const containerRect = containerEl.getBoundingClientRect();
const captionRect = captionEl.getBoundingClientRect();
setActionsColumnBottom(containerRect.bottom - captionRect.top);
};
update();
const ro = new ResizeObserver(update);
ro.observe(captionEl);
return () => ro.disconnect();
}, [captionBody, captionLinks, captionExpanded]);
const [imageLoading, setImageLoading] = useState(() => (files?.length ?? 0) > 0);
const [commentsOpen, setCommentsOpen] = useState(false);
// وقتی postPage=true، کامنت‌ها توی CommentsModal جدا (نه شیت این کامپوننت) باز می‌شه
@@ -154,15 +138,6 @@ export default function ReelsPostCard({
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLElement | null>(null);
const captionRef = useRef<HTMLDivElement | null>(null);
// طبق خواسته‌ی کاربر: دکمه‌ی جمع‌شونده‌ی ستونِ اکشن‌ها (ReelsPostActions) باید همیشه
// دقیقاً روبروی خطِ اولِ کپشن باشه — چون طولِ کپشن/فونت فرق می‌کنه، به‌جای عددِ ثابتِ
// حدسی (bottom-36)، فاصله‌ی واقعیِ لبه‌ی پایینِ کارت تا لبه‌ی بالای بلوکِ کپشن رو
// اندازه می‌گیریم (هر دو نسبت به همون container، چون وقتی کامنت‌ها بسته‌ست بلوکِ
// مدیا هم‌اندازه‌ی کاملِ container ـه)
const [actionsColumnBottom, setActionsColumnBottom] = useState<number | null>(
null,
);
const viewStartedAtRef = useRef<number | null>(null);
const watchCompleteTrackedRef = useRef(false);
const holdTimerRef = useRef<number | null>(null);
@@ -384,6 +359,13 @@ export default function ReelsPostCard({
}
}, [isActive, muted, type, commentsOpen, videoPaused]);
// videoPaused هم برای «کاربر دستی پاز کرده» و هم برای پازِ خودکارِ همین افکت (وقتی
// isActive می‌شه false) استفاده می‌شه؛ بدون این ریست، برگشتن به ریلزی که قبلاً دیده
// شده autoplay نمی‌کرد چون videoPaused از آخرین بار true مونده بود
useEffect(() => {
if (!isActive) setVideoPaused(false);
}, [isActive]);
useEffect(() => {
if (!isActive) {
setCommentsOpen(false);
@@ -647,11 +629,17 @@ export default function ReelsPostCard({
{!chromeHidden && !anyCommentsOpen && actionsExpanded && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/60 to-transparent px-4 pt-16",
// pl (نه px یکسان): زیرِ dir=rtl سمتِ فیزیکیِ چپ همون سمتیه که ستونِ
// آیکون‌ها (left-3 در ReelsPostActions) روشه؛ چون آخرین آیتمِ ردیفِ
// 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",
// وقتی نوار پیشرفت ویدیو نمایش داده می‌شه، این بلوک باید بالای خطِ اون
// بشینه نه روش؛ وگرنه (تصویر/ویدیوی کوتاه) فقط فاصله‌ی پایینِ معمولی کافیه
showLongVideoBar
@@ -721,7 +709,7 @@ export default function ReelsPostCard({
</Link>
</div>
{(captionBody || captionLinks) && (
<div ref={captionRef} className="pointer-events-auto mt-2">
<div className="pointer-events-auto mt-2">
<ExpandableCaption
body={captionBody}
links={captionLinks}
@@ -750,7 +738,6 @@ export default function ReelsPostCard({
useCommentsSheet ? () => setCommentsOpen(true) : undefined
}
onCommentsModalOpenChange={setCommentsModalOpen}
columnBottom={actionsColumnBottom ?? undefined}
/>
)}

View File

@@ -2,7 +2,7 @@
import React from "react";
import { cn } from "@/lib/utils";
import { formatLikeDisplay } from "@/lib/formatLikeDisplay";
import { formatLikeDisplay, formatRequestDisplay } from "@/lib/formatLikeDisplay";
/** آیکون بالا + عدد پایین — ریلز */
export const socialStatButtonClassVertical =
@@ -18,6 +18,8 @@ export const socialStatButtonClass = socialStatButtonClassVertical;
type SocialStatButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
count?: number;
likeCount?: number;
/** عینِ likeCount ولی برای دکمه‌ی درخواستِ همکاری — زیرِ آستانه «درخواست»، بالاترش عدد */
requestCount?: number;
icon: React.ReactNode;
showZero?: boolean;
layout?: "vertical" | "horizontal";
@@ -26,6 +28,7 @@ type SocialStatButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
export function SocialStatButton({
count,
likeCount,
requestCount,
icon,
showZero = true,
layout = "vertical",
@@ -37,6 +40,8 @@ export function SocialStatButton({
if (likeCount !== undefined) {
displayLabel = formatLikeDisplay(likeCount);
} else if (requestCount !== undefined) {
displayLabel = formatRequestDisplay(requestCount);
} else if (count !== undefined && (showZero || count > 0)) {
displayLabel = String(count);
}

View File

@@ -28,6 +28,7 @@ export function usePostSocialState(postData: Post) {
savesCount: initialSaves = 0,
sendsCount: initialSends = 0,
sharesCount: initialShares = 0,
offersCount: initialOffers = 0,
is_saved: initialSaved = false,
is_following: initialFollowing = false,
type: postType,
@@ -39,6 +40,7 @@ export function usePostSocialState(postData: Post) {
const [savesCount, setSavesCount] = useState(initialSaves);
const [sendsCount, setSendsCount] = useState(initialSends);
const [sharesCount, setSharesCount] = useState(initialShares);
const [offersCount, setOffersCount] = useState(initialOffers);
const [following, setFollowing] = useState(() => coerceBool(initialFollowing));
useEffect(() => {
@@ -48,6 +50,7 @@ export function usePostSocialState(postData: Post) {
setSavesCount(initialSaves);
setSendsCount(initialSends);
setSharesCount(initialShares);
setOffersCount(initialOffers);
setFollowing(coerceBool(initialFollowing));
}, [
postId,
@@ -57,6 +60,7 @@ export function usePostSocialState(postData: Post) {
initialSaves,
initialSends,
initialShares,
initialOffers,
initialFollowing,
]);
@@ -230,6 +234,27 @@ export function usePostSocialState(postData: Post) {
}
}, [postId, user_id, postType, request]);
// برخلافِ share/send، درخواستِ همکاری endpoint اختصاصیِ برگردوننده‌ی شمارشِ تازه
// نداره — فقط رویدادِ ExploreInteraction(action:"offer") ثبت می‌شه (همونی که
// postEngagement.js سمتِ بک‌اند برای offersCount می‌شمردش) و شمارش سمتِ کلاینت
// خوش‌بینانه +۱ می‌شه
const trackOffer = useCallback(() => {
const token = Cookies.get("token") || "";
if (!token || !postId) return;
setOffersCount((c) => c + 1);
trackExploreInteraction(
{
targetType: "post",
targetId: postId,
authorId: user_id,
contentType: postType === "video" ? "video" : "image",
action: "offer",
},
token
);
}, [postId, user_id, postType]);
return {
liked,
likesCountState,
@@ -237,6 +262,7 @@ export function usePostSocialState(postData: Post) {
savesCount,
sendsCount,
sharesCount,
offersCount,
setSendsCount,
following,
commentCount,
@@ -247,6 +273,7 @@ export function usePostSocialState(postData: Post) {
toggleSave,
toggleFollow,
trackShare,
trackOffer,
};
}

View File

@@ -66,7 +66,13 @@ export function upsertMessageInThreadCache(
return appendMessageToThreadCache(oldData, message);
}
/** هنگام refetch، پیام‌های تازهٔ cache که هنوز در پاسخ API نیستند حفظ می‌شوند */
/** هنگام refetch، پیام‌های تازهٔ cache که هنوز در پاسخ API نیستند حفظ می‌شوند —
* ولی فقط اگه واقعاً «تازه‌تر از جدیدترین پیامِ همین پاسخ» باشن (یعنی احتمالاً یه
* پیامِ لحظاتی‌پیش‌فرستاده‌شده که هنوز به این fetch نرسیده). قبلاً هر پیامِ کش‌شده‌ای
* که غایب بود حفظ می‌شد، صرف‌نظر از تاریخش — یعنی وقتی سوکت قطع بود (پولینگ)، یه
* پیامِ حذف‌شده/یکبارمصرفِ تمام‌شده هیچ‌وقت از کشِ محلیِ طرفِ مقابل پاک نمی‌شد، چون
* صفحه‌ی اول همیشه شاملِ کلِ پیام‌های تا اون لحظه‌ست — پس هر پیامِ قدیمی‌ترِ غایب
* یعنی روی سرور دیگه وجود نداره، نه اینکه هنوز sync نشده */
export function mergeFetchedThreadPage(
fetched: ChatMessage[],
cachedFirstPage: ChatMessage[] | undefined
@@ -74,10 +80,15 @@ export function mergeFetchedThreadPage(
if (!cachedFirstPage?.length) return fetched;
const fetchedIds = new Set(fetched.map((m) => String(m._id)));
const newestFetchedTime = fetched.reduce(
(max, m) => Math.max(max, parseMessageTime(m)),
-Infinity
);
const preserved = cachedFirstPage.filter(
(m) =>
/^[a-f\d]{24}$/i.test(String(m._id)) &&
!fetchedIds.has(String(m._id))
!fetchedIds.has(String(m._id)) &&
parseMessageTime(m) > newestFetchedTime
);
if (!preserved.length) return fetched;

View File

@@ -1,9 +1,20 @@
export const LIKE_DISPLAY_THRESHOLD = 50;
export const LIKE_LABEL_BELOW_THRESHOLD = "پسند";
export const LIKE_DISPLAY_THRESHOLD = 10;
export const LIKE_LABEL_BELOW_THRESHOLD = "لایک";
/** زیر ۵۰ → «پسند»، از ۵۰ به بالا → عدد */
/** زیر ۱۰ → «لایک»، از ۱۰ به بالا → عدد */
export function formatLikeDisplay(count: number): string {
const n = Math.max(0, Math.floor(Number(count) || 0));
if (n < LIKE_DISPLAY_THRESHOLD) return LIKE_LABEL_BELOW_THRESHOLD;
return String(n);
}
export const REQUEST_DISPLAY_THRESHOLD = 10;
export const REQUEST_LABEL_BELOW_THRESHOLD = "درخواست";
/** زیر ۱۰ → «درخواست»، از ۱۰ به بالا → عدد — عینِ formatLikeDisplay برای دکمه‌ی
* درخواستِ همکاری */
export function formatRequestDisplay(count: number): string {
const n = Math.max(0, Math.floor(Number(count) || 0));
if (n < REQUEST_DISPLAY_THRESHOLD) return REQUEST_LABEL_BELOW_THRESHOLD;
return String(n);
}

View File

@@ -95,8 +95,11 @@ export function buildWebManifest(lang: PwaLang) {
scope: "/",
start_url: `/?source=pwa&lang=${lang}`,
id: PWA_ID,
// طبق خواسته‌ی کاربر: نوارِ وضعیتِ سیستم‌عامل هم مثلِ اندروید حذف بشه — standalone
// فقط نوارِ آدرسِ مرورگر رو مخفی می‌کنه، نه نوارِ وضعیتِ OS (ساعت/باتری)؛ fullscreen
// (که مرورگرهایی که پشتیبانی نمی‌کنن به standalone برمی‌گردن) اون رو هم می‌بره
display: "standalone",
display_override: ["standalone", "minimal-ui", "browser"],
display_override: ["fullscreen", "standalone", "minimal-ui", "browser"],
orientation: "portrait-primary",
prefer_related_applications: false,
theme_color: PWA_THEME_COLOR,

View File

@@ -637,6 +637,7 @@ export interface Post {
savesCount?: number;
sendsCount?: number;
sharesCount?: number;
offersCount?: number;
viewsCount?: number;
author_allow_save?: boolean;
is_saved?: boolean;