351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
"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} />
|
||
);
|
||
}
|