diff --git a/package-lock.json b/package-lock.json index 3242a1b..9f3f933 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/public/manifest.json b/public/manifest.json index c7bf8b8..b9626c3 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -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": [ diff --git a/src/app/globals.css b/src/app/globals.css index f5574b7..2aace67 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { diff --git a/src/app/settings/chats/page.tsx b/src/app/settings/chats/page.tsx index 0435b36..d0ad4be 100644 --- a/src/app/settings/chats/page.tsx +++ b/src/app/settings/chats/page.tsx @@ -297,9 +297,11 @@ function Chats() { {isOnline ? onlineLabel : item.last_online || offlineLabel} - + {/* طبقِ خواسته‌ی کاربر فعلاً کامنت شد — این متن همیشه ثابت + نمایش داده می‌شد (نه پیشنمایشِ واقعیِ آخرین پیام) */} + {/* {E2EE_LOCKED_PREVIEW} - + */} {item.unread_messages_count ? ( {item.unread_messages_count} diff --git a/src/components/chat/ChatHeader.tsx b/src/components/chat/ChatHeader.tsx index 5bd78ff..ba4f871 100644 --- a/src/components/chat/ChatHeader.tsx +++ b/src/components/chat/ChatHeader.tsx @@ -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" > - + ) : ( -
+
)} diff --git a/src/components/chat/ChatMessageCard.tsx b/src/components/chat/ChatMessageCard.tsx index 6b1250f..11510de 100644 --- a/src/components/chat/ChatMessageCard.tsx +++ b/src/components/chat/ChatMessageCard.tsx @@ -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 ( + + e.stopPropagation()} + > + {type === "video" ? ( + + + ); +} + function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null { if (!content || isE2eeEnvelope(content)) return null; try { @@ -836,38 +895,11 @@ const ChatMessageCard = ({ {viewOncePreview && ( - void finishViewOnce()} - > - e.stopPropagation()} - > - {viewOncePreview.type === "video" ? ( - - + void finishViewOnce()} + /> )} {preview && ( { if (!scrollRef.current || index + 1 >= items.length) return; scrollRef.current.scrollTo({ diff --git a/src/components/main/ProfileAvatar.tsx b/src/components/main/ProfileAvatar.tsx index 53c0125..e269ad6 100644 --- a/src/components/main/ProfileAvatar.tsx +++ b/src/components/main/ProfileAvatar.tsx @@ -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 — 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 = ( {alt} ))}
- setShowServicesModal(true)} + requestCount={social.offersCount} + onClick={() => { + social.trackOffer(); + setShowServicesModal(true); + }} aria-label={t("models.actions.collaborationAria")} icon={ diff --git a/src/components/posts/ReelsPostActions.tsx b/src/components/posts/ReelsPostActions.tsx index e63868f..198a56f 100644 --- a/src/components/posts/ReelsPostActions.tsx +++ b/src/components/posts/ReelsPostActions.tsx @@ -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({
); + {/* mb-[7px]: چون بجِ فالو با -bottom-[7px] از جعبه‌ی آواتار بیرون می‌زنه، فاصله‌ی + واقعی‌ش تا آیکونِ بعدی (فنجون) از gap-4 ستون کمتر می‌شد؛ این مارجین همون ۷px + رو جبران می‌کنه تا فاصله با همه‌ی آیکون‌های دیگه یکی بشه */} if (isOwnPost) { return ( - + {avatar} ); @@ -81,11 +80,14 @@ function ReelsProfileFollowButton({ ); @@ -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 = ( <> - setShowServicesModal(true)} + requestCount={social.offersCount} + onClick={() => { + social.trackOffer(); + setShowServicesModal(true); + }} className={actionButtonClass} aria-label={t("posts.collaborationRequest")} icon={} @@ -307,9 +311,13 @@ export default function ReelsPostActions({ isOwnPost={social.isOwnPost} onToggleFollow={social.toggleFollow} /> - 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={ +
{/* دکمه‌ی جمع‌شونده همیشه باید آخرین فرزندِ این ستون باشه (نه هرچیزِ دیگه‌ای زیرش) — این ستون 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")} > -
diff --git a/src/components/posts/ReelsPostCard.tsx b/src/components/posts/ReelsPostCard.tsx index d0468b5..26f2f6e 100644 --- a/src/components/posts/ReelsPostCard.tsx +++ b/src/components/posts/ReelsPostCard.tsx @@ -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(null); const containerRef = useRef(null); - const captionRef = useRef(null); - // طبق خواسته‌ی کاربر: دکمه‌ی جمع‌شونده‌ی ستونِ اکشن‌ها (ReelsPostActions) باید همیشه - // دقیقاً روبروی خطِ اولِ کپشن باشه — چون طولِ کپشن/فونت فرق می‌کنه، به‌جای عددِ ثابتِ - // حدسی (bottom-36)، فاصله‌ی واقعیِ لبه‌ی پایینِ کارت تا لبه‌ی بالای بلوکِ کپشن رو - // اندازه می‌گیریم (هر دو نسبت به همون container، چون وقتی کامنت‌ها بسته‌ست بلوکِ - // مدیا هم‌اندازه‌ی کاملِ container ـه) - const [actionsColumnBottom, setActionsColumnBottom] = useState( - null, - ); const viewStartedAtRef = useRef(null); const watchCompleteTrackedRef = useRef(false); const holdTimerRef = useRef(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 && ( <>
{(captionBody || captionLinks) && ( -
+
setCommentsOpen(true) : undefined } onCommentsModalOpenChange={setCommentsModalOpen} - columnBottom={actionsColumnBottom ?? undefined} /> )} diff --git a/src/components/ui/SocialStatButton.tsx b/src/components/ui/SocialStatButton.tsx index 22f3890..6cd039f 100644 --- a/src/components/ui/SocialStatButton.tsx +++ b/src/components/ui/SocialStatButton.tsx @@ -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 & { count?: number; likeCount?: number; + /** عینِ likeCount ولی برای دکمه‌ی درخواستِ همکاری — زیرِ آستانه «درخواست»، بالاترش عدد */ + requestCount?: number; icon: React.ReactNode; showZero?: boolean; layout?: "vertical" | "horizontal"; @@ -26,6 +28,7 @@ type SocialStatButtonProps = React.ButtonHTMLAttributes & { 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); } diff --git a/src/hooks/usePostSocialActions.tsx b/src/hooks/usePostSocialActions.tsx index 9b11297..b6f54ee 100644 --- a/src/hooks/usePostSocialActions.tsx +++ b/src/hooks/usePostSocialActions.tsx @@ -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, }; } diff --git a/src/lib/chat/threadCache.ts b/src/lib/chat/threadCache.ts index 300368b..1e0f169 100644 --- a/src/lib/chat/threadCache.ts +++ b/src/lib/chat/threadCache.ts @@ -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; diff --git a/src/lib/formatLikeDisplay.ts b/src/lib/formatLikeDisplay.ts index 6376df6..899f941 100644 --- a/src/lib/formatLikeDisplay.ts +++ b/src/lib/formatLikeDisplay.ts @@ -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); +} diff --git a/src/lib/pwaConfig.ts b/src/lib/pwaConfig.ts index 11bd4a5..2f67311 100644 --- a/src/lib/pwaConfig.ts +++ b/src/lib/pwaConfig.ts @@ -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, diff --git a/src/types/types.ts b/src/types/types.ts index 93bcc38..6952782 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -637,6 +637,7 @@ export interface Post { savesCount?: number; sendsCount?: number; sharesCount?: number; + offersCount?: number; viewsCount?: number; author_allow_save?: boolean; is_saved?: boolean;