reels
This commit is contained in:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -637,6 +637,7 @@ export interface Post {
|
||||
savesCount?: number;
|
||||
sendsCount?: number;
|
||||
sharesCount?: number;
|
||||
offersCount?: number;
|
||||
viewsCount?: number;
|
||||
author_allow_save?: boolean;
|
||||
is_saved?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user