Files
modstagram-next/src/hooks/usePostSocialActions.tsx
payacom 3b89bbfec3 reels
2026-09-19 12:28:28 +03:30

351 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useState } from "react";
import Image from "next/image";
import { Post } from "@/types/types";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import { AxiosError } from "axios";
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
import { getStoredUserId } from "@/lib/auth/session";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { buildPostPublicUrl } from "@/lib/postSlug";
import Cookies from "js-cookie";
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
import { updateReelsSeedPostEngagement } from "@/lib/reelsSeedPost";
import { useTranslation } from "react-i18next";
function coerceBool(value: unknown): boolean {
return value === true || value === 1 || value === "true" || value === "1";
}
export function usePostSocialState(postData: Post) {
const {
_id: postId,
user_id,
likesCount,
is_liked,
savesCount: initialSaves = 0,
sendsCount: initialSends = 0,
sharesCount: initialShares = 0,
offersCount: initialOffers = 0,
is_saved: initialSaved = false,
is_following: initialFollowing = false,
type: postType,
} = postData;
const [liked, setLiked] = useState(() => coerceBool(is_liked));
const [likesCountState, setLikesCount] = useState(likesCount || 0);
const [saved, setSaved] = useState(() => coerceBool(initialSaved));
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(() => {
setLiked(coerceBool(is_liked));
setLikesCount(likesCount || 0);
setSaved(coerceBool(initialSaved));
setSavesCount(initialSaves);
setSendsCount(initialSends);
setSharesCount(initialShares);
setOffersCount(initialOffers);
setFollowing(coerceBool(initialFollowing));
}, [
postId,
is_liked,
likesCount,
initialSaved,
initialSaves,
initialSends,
initialShares,
initialOffers,
initialFollowing,
]);
const { t } = useTranslation("common");
const { request, loading } = useAxios();
const requireAuth = useRequireAuth();
const currentUserId = getStoredUserId();
const isOwnPost = Boolean(currentUserId && user_id && currentUserId === String(user_id));
const shareUrl = buildPostPublicUrl(postId, postData);
const { data } = useInfiniteScroll({
endpoint: "/users/comments",
queryKey: ["comments", user_id, postId || "all"],
params: postId ? { user_id, post_id: postId } : { user_id },
});
const commentCount =
data?.pages
?.flatMap((p) => p?.comments || [])
.filter((c: { post_id?: string; postId?: string }) =>
postId
? c.post_id === postId || c.postId === postId || (!c.post_id && !c.postId)
: true
).length ?? 0;
const toggleLike = useCallback(async () => {
if (!requireAuth()) return;
if (!user_id || !postId) {
toast.error(t("posts.loginToPost"));
return;
}
if (loading) return;
const newLiked = !liked;
const newLikesCount = newLiked ? likesCountState + 1 : likesCountState - 1;
setLiked(newLiked);
setLikesCount(newLikesCount);
try {
const response = await request<{ likesCount?: number; is_liked?: boolean }>(
"POST",
"/posts/like",
{ postId },
{ noToast: true }
);
setLikesCount(response?.likesCount ?? newLikesCount);
const resolvedLiked = coerceBool(response?.is_liked ?? newLiked);
setLiked(resolvedLiked);
updateReelsSeedPostEngagement(String(postId), {
is_liked: resolvedLiked,
likesCount: response?.likesCount ?? newLikesCount,
});
const token = Cookies.get("token") || "";
if (token && newLiked) {
trackExploreInteraction(
{
targetType: "post",
targetId: postId,
authorId: user_id,
contentType: postType === "video" ? "video" : "image",
action: "like",
},
token
);
}
} catch (error: unknown) {
let errorMessage = t("errors.serverConnection");
if (error instanceof Error) errorMessage = error.message;
else if (error instanceof AxiosError)
errorMessage = error.response?.data?.message ?? error.message;
toast.error(errorMessage);
setLiked(!newLiked);
setLikesCount(likesCountState);
}
}, [
requireAuth,
user_id,
postId,
loading,
liked,
likesCountState,
request,
postType,
t,
]);
const toggleSave = useCallback(async () => {
if (!requireAuth()) return;
if (!postId || loading) return;
const newSaved = !saved;
const newCount = newSaved ? savesCount + 1 : Math.max(0, savesCount - 1);
setSaved(newSaved);
setSavesCount(newCount);
try {
const response = await request<{ savesCount?: number; is_saved?: boolean }>(
"POST",
"/posts/bookmark",
{ postId },
{ noToast: true }
);
setSavesCount(response?.savesCount ?? newCount);
const resolvedSaved = coerceBool(response?.is_saved ?? newSaved);
setSaved(resolvedSaved);
updateReelsSeedPostEngagement(String(postId), {
is_saved: resolvedSaved,
savesCount: response?.savesCount ?? newCount,
});
} catch {
toast.error(t("posts.bookmarkError"));
setSaved(!newSaved);
setSavesCount(savesCount);
}
}, [requireAuth, postId, loading, saved, savesCount, request, t]);
const toggleFollow = useCallback(async () => {
if (!requireAuth()) return;
if (!user_id || isOwnPost || loading) return;
const newFollowing = !following;
setFollowing(newFollowing);
try {
const response = await request<{ is_following?: boolean }>(
"POST",
"/posts/follow",
{ userId: user_id },
{ noToast: true }
);
setFollowing(response?.is_following ?? newFollowing);
toast.success(
newFollowing ? t("posts.followed") : t("stories.unfollowed")
);
} catch {
toast.error(t("stories.followError"));
setFollowing(!newFollowing);
}
}, [requireAuth, user_id, isOwnPost, loading, following, request, t]);
const trackShare = useCallback(async () => {
const token = Cookies.get("token") || "";
if (!token) return;
trackExploreInteraction(
{
targetType: "post",
targetId: postId,
authorId: user_id,
contentType: postType === "video" ? "video" : "image",
action: "share",
},
token
);
if (!postId) return;
try {
const response = await request<{ sharesCount?: number }>(
"POST",
"/posts/share",
{ postId },
{ noToast: true }
);
if (typeof response?.sharesCount === "number") {
setSharesCount(response.sharesCount);
}
} catch {
/* best-effort share count */
}
}, [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,
saved,
savesCount,
sendsCount,
sharesCount,
offersCount,
setSendsCount,
following,
commentCount,
shareUrl,
currentUserId,
isOwnPost,
toggleLike,
toggleSave,
toggleFollow,
trackShare,
trackOffer,
};
}
export type PostSocialIconSize = "sm" | "md";
export const POST_SOCIAL_ICONS = {
like: (liked: boolean, size: PostSocialIconSize) => ({
src: liked ? "/images/icons/red-heart.svg" : "/images/icons/heart.svg",
w: size === "sm" ? 20 : 28,
className: liked
? "drop-shadow-lg"
: size === "sm"
? "dark:invert drop-shadow-lg"
: "invert brightness-125 drop-shadow-lg",
}),
comment: (size: PostSocialIconSize) => ({
src: "/images/icons/message-text.svg",
w: size === "sm" ? 20 : 28,
className:
size === "sm" ? "dark:invert" : "invert brightness-125 drop-shadow-lg",
}),
share: (size: PostSocialIconSize) => ({
src: "/images/icons/share.svg",
w: size === "sm" ? 20 : 28,
className:
size === "sm" ? "dark:invert" : "invert brightness-125 drop-shadow-lg",
}),
send: (size: PostSocialIconSize) => ({
src: "/images/icons/send-post.svg",
w: size === "sm" ? 20 : 28,
className:
size === "sm" ? "dark:invert" : "invert brightness-125 drop-shadow-lg",
}),
save: (saved: boolean, size: PostSocialIconSize) => ({
src: "/images/icons/bookmark-post.svg",
w: size === "sm" ? 20 : 28,
className: saved
? "drop-shadow-lg"
: size === "sm"
? "dark:invert"
: "invert brightness-125 drop-shadow-lg",
}),
follow: (following: boolean, size: PostSocialIconSize) => ({
src: "/images/icons/follow-user.svg",
w: size === "sm" ? 20 : 28,
className: following
? "opacity-60 drop-shadow-lg"
: size === "sm"
? "dark:invert"
: "invert brightness-125 drop-shadow-lg",
}),
collaboration: (size: PostSocialIconSize) => ({
src: "/images/icons/collaboration-request.svg",
w: size === "sm" ? 20 : 28,
className:
size === "sm" ? "dark:invert" : "invert brightness-125 drop-shadow-lg",
}),
} as const;
export function PostSocialIcon({
src,
w,
className,
alt = "",
}: {
src: string;
w: number;
className?: string;
alt?: string;
}) {
return (
<Image width={w} height={w} alt={alt} src={src} className={className} />
);
}