diff --git a/public/images/icons/chat/back.svg b/public/images/icons/chat/back.svg index 04df0b7..63e382c 100644 --- a/public/images/icons/chat/back.svg +++ b/public/images/icons/chat/back.svg @@ -1,3 +1,3 @@ - + diff --git a/public/images/icons/chat/reply.svg b/public/images/icons/chat/reply.svg new file mode 100644 index 0000000..d9fd401 --- /dev/null +++ b/public/images/icons/chat/reply.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/api/fetchPosts.ts b/src/api/fetchPosts.ts index fcd834c..3598abb 100644 --- a/src/api/fetchPosts.ts +++ b/src/api/fetchPosts.ts @@ -18,6 +18,7 @@ export async function fetchPosts( lng?: string; feedMode?: "grid" | "reels"; seedPostId?: string; + sort?: "latest"; }, token: string ) { diff --git a/src/api/fetchStories.ts b/src/api/fetchStories.ts index 4f7fcaf..a676391 100644 --- a/src/api/fetchStories.ts +++ b/src/api/fetchStories.ts @@ -1,9 +1,21 @@ import { getApiBaseUrl } from "@/components/main/BaseUrl"; +export type StoryTextOverlay = { + text: string; + x: number; + y: number; + font_size: number; + color: string; + background?: string; + rotation?: number; + align?: "left" | "center" | "right"; +}; + export type StoryItem = { _id: string; media_path: string; media_type: "image" | "video"; + overlays?: StoryTextOverlay[]; createdAt?: string; expires_at?: string; viewed?: boolean; @@ -56,7 +68,8 @@ export async function markStoryViewed( export async function createStoryBase64( file: { name: string; type: string; data: string }, - token: string + token: string, + overlays: StoryTextOverlay[] = [] ): Promise { const res = await fetch(`${getApiBaseUrl()}/stories/create-base64`, { method: "POST", @@ -64,10 +77,29 @@ export async function createStoryBase64( "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ file }), + body: JSON.stringify({ file, overlays }), }); if (!res.ok) { const json = await res.json().catch(() => ({})); throw new Error(json?.message || "خطا در انتشار استوری"); } } + +export async function updateStoryOverlays( + storyId: string, + overlays: StoryTextOverlay[], + token: string +): Promise { + const res = await fetch(`${getApiBaseUrl()}/stories/${storyId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ overlays }), + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json?.message || "خطا در ویرایش استوری"); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 7c55d5f..2069a53 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -609,16 +609,16 @@ select { color: var(--chat-bubble-out-fg); } -/* ─── Instagram-style typing dots ─── */ +/* ─── Instagram-style typing dots (waving) ─── */ @keyframes typing-bounce { - 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } - 30% { transform: translateY(-4px); opacity: 1; } + 0%, 60%, 100% { transform: translateY(0) scale(1); opacity: 0.45; } + 30% { transform: translateY(-5px) scale(1.15); opacity: 1; } } .typing-dot { - animation: typing-bounce 1.2s ease-in-out infinite; + animation: typing-bounce 1.3s cubic-bezier(0.4, 0, 0.2, 1) infinite; } -.typing-dot:nth-child(2) { animation-delay: 0.15s; } -.typing-dot:nth-child(3) { animation-delay: 0.3s; } +.typing-dot:nth-child(2) { animation-delay: 0.18s; } +.typing-dot:nth-child(3) { animation-delay: 0.36s; } /* ─── Read receipt check animation ─── */ @keyframes check-pop { diff --git a/src/app/new-post/page.tsx b/src/app/new-post/page.tsx index a180f6d..f0fb593 100644 --- a/src/app/new-post/page.tsx +++ b/src/app/new-post/page.tsx @@ -4,7 +4,8 @@ import useAxios from "@/hooks/useAxios"; import { optimizeImageToWebP, optimizeVideoToMp4 } from "@/lib/media"; -import { createStoryBase64 } from "@/api/fetchStories"; +import { createStoryBase64, StoryTextOverlay } from "@/api/fetchStories"; +import StoryEditor from "@/components/stories/StoryEditor"; import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker"; import BoldIcon from "@/components/ui/BoldIcon"; import Cookies from "js-cookie"; @@ -44,6 +45,7 @@ export default function NewPostPage() { const [extraPreviews, setExtraPreviews] = useState([""]); const [description, setDescription] = useState(""); const [taggedUsers, setTaggedUsers] = useState([]); + const [storyOverlays, setStoryOverlays] = useState([]); useEffect(() => { const t = searchParams.get("type") as CreateMode; @@ -57,6 +59,7 @@ export default function NewPostPage() { setPreview(""); setExtraImages([null]); setExtraPreviews([""]); + setStoryOverlays([]); }; const switchMode = (next: CreateMode) => { @@ -119,17 +122,29 @@ export default function NewPostPage() { } try { toast.loading("در حال انتشار استوری…", { id: "story-upload" }); - const optimized = - file.type.startsWith("video/") - ? await optimizeVideoToMp4(file) - : await optimizeImageToWebP(file); + let optimized: File; + if (file.type.startsWith("video/")) { + // اگر تبدیل ویدیو (ffmpeg) در دسترس نبود، فایل اصلی ارسال می‌شود + try { + optimized = await optimizeVideoToMp4(file); + } catch { + optimized = file; + } + } else { + try { + optimized = await optimizeImageToWebP(file); + } catch { + optimized = file; + } + } await createStoryBase64( { name: optimized.name, type: optimized.type, data: await fileToBase64(optimized), }, - token + token, + storyOverlays ); toast.success("استوری منتشر شد", { id: "story-upload" }); router.push("/"); @@ -235,7 +250,27 @@ export default function NewPostPage() { ))} - {/* Media area */} + {/* Story editor (media + draggable text) */} + {isStory && preview && file ? ( +
+ + +
+ ) : ( + /* Media area */
)}
+ )} {/* Extra images for photo post */} {mode === "image" && !isStory && ( diff --git a/src/app/settings/chats/[username]/[id]/page.tsx b/src/app/settings/chats/[username]/[id]/page.tsx index ca58a2f..2377f29 100644 --- a/src/app/settings/chats/[username]/[id]/page.tsx +++ b/src/app/settings/chats/[username]/[id]/page.tsx @@ -72,11 +72,24 @@ function TicketChat({ params }: ITicketChatProps) { useEffect(() => { if (!user?._id || !receiverId) return; - const socket = io(SOCKET_URL); + const socket = io(SOCKET_URL, { + transports: ["websocket", "polling"], + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 800, + }); socketRef.current = socket; - socket.emit("joinChat", { userId: user._id, receiverId }); - socket.emit("joinUser", { userId: user._id }); + + // عضویت در روم‌ها روی هر اتصال/اتصال مجدد تا بعد از قطعی هم پیام‌ها برسند + const joinRooms = () => { + socket.emit("joinChat", { userId: user._id, receiverId }); + socket.emit("joinUser", { userId: user._id }); + }; + socket.on("connect", joinRooms); + if (socket.connected) joinRooms(); + return () => { + socket.off("connect", joinRooms); socket.disconnect(); socketRef.current = null; }; @@ -527,6 +540,8 @@ function TicketChat({ params }: ITicketChatProps) { onConfirm={sendAllImages} viewOnceMedia={viewOnceMedia} onViewOnceChange={setViewOnceMedia} + selfDestructSeconds={selfDestructSeconds} + onSelfDestructChange={setSelfDestructSeconds} /> {forwardMessage && user?._id && ( void; @@ -35,7 +36,7 @@ export default function ChatActionBar({ onClick={onReply} className="chat-action-btn gentle-transition flex flex-1 items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white active:scale-[0.98]" > - + پاسخ e.stopPropagation()} > {fileType === "video" ? ( - diff --git a/src/components/chat/ChatMessageList.tsx b/src/components/chat/ChatMessageList.tsx index 47e91cf..0216104 100644 --- a/src/components/chat/ChatMessageList.tsx +++ b/src/components/chat/ChatMessageList.tsx @@ -184,9 +184,20 @@ const ChatMessageList = ({ useEffect(() => { if (!threadReady) return; - const socket = io(SOCKET_URL); + const socket = io(SOCKET_URL, { + transports: ["websocket", "polling"], + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 800, + }); - socket.emit("joinChat", { userId: senderId, receiverId }); + // عضویت در روم چت روی هر اتصال/اتصال مجدد + const joinRoom = () => { + socket.emit("joinChat", { userId: senderId, receiverId }); + socket.emit("joinUser", { userId: senderId }); + }; + socket.on("connect", joinRoom); + if (socket.connected) joinRoom(); const belongs = (msg: ChatMessage) => belongsToThread(msg, senderId, receiverId) || @@ -232,10 +243,19 @@ const ChatMessageList = ({ } ); + let typingHideTimer: ReturnType | null = null; socket.on("userTyping", ({ userId }: { userId: string }) => { if (userId === receiverId) { onTypingChange?.(true); - setTimeout(() => onTypingChange?.(false), 3000); + if (typingHideTimer) clearTimeout(typingHideTimer); + typingHideTimer = setTimeout(() => onTypingChange?.(false), 3500); + } + }); + + socket.on("userStoppedTyping", ({ userId }: { userId: string }) => { + if (userId === receiverId) { + if (typingHideTimer) clearTimeout(typingHideTimer); + onTypingChange?.(false); } }); @@ -261,10 +281,13 @@ const ChatMessageList = ({ ); return () => { + if (typingHideTimer) clearTimeout(typingHideTimer); + socket.off("connect", joinRoom); socket.off("newMessage"); socket.off("messageStatusUpdate"); socket.off("messagesDeleted"); socket.off("userTyping"); + socket.off("userStoppedTyping"); socket.disconnect(); }; }, [threadReady, senderId, receiverId, queryClient, onTypingChange]); diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index 9c1a13b..82df10c 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -67,6 +67,8 @@ const MessageInput = ({ const hasText = newMessage.trim().length > 0; const showSend = hasText && !isRecording; + // ردیف تایمر/یک‌بارمصرف فقط هنگام ضبط ویس (برای عکس داخل پیش‌نمایش نمایش داده می‌شود) + const showMediaOptions = isChatThread && isRecording; const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -173,9 +175,33 @@ const MessageInput = ({ }`} > {(replyLabel || + showMediaOptions || (selfDestructSeconds != null && isChatThread) || (viewOnceMedia && isChatThread)) && (
+ {showMediaOptions && ( +
+ + گزینه‌های ارسال مدیا + +
+ {onSelfDestructChange && ( + + )} + {onViewOnceChange && ( + + )} +
+
+ )} {replyLabel && (
@@ -214,20 +240,6 @@ const MessageInput = ({ className="pointer-events-auto relative z-[1] flex w-full max-w-lg items-end gap-2 sm:gap-2.5" >
- {isChatThread && onSelfDestructChange && ( - - )} - {isChatThread && onViewOnceChange && ( - - )} + + {single ? "پیش‌نمایش عکس" : `${files.length} عکس انتخاب شده`} + + +
+ + {/* Preview area */} +
{files.map((file, i) => ( + {!single && ( + + {i + 1} + + )} ))}
-
- {onViewOnceChange && ( -
- - ارسال به‌صورت یک‌بار مصرف -
- )} -
+ + {/* Footer */} +
+
+ {onSelfDestructChange && ( +
+ + + {selfDestructSeconds != null + ? formatTimedLabel(selfDestructSeconds) + : "زماندار"} + +
+ )} + {onViewOnceChange && ( +
+ + + یک‌بار مصرف + +
+ )} +
- -
diff --git a/src/components/explore/ExploreAcademyCard.tsx b/src/components/explore/ExploreAcademyCard.tsx index 7da043e..2d39251 100644 --- a/src/components/explore/ExploreAcademyCard.tsx +++ b/src/components/explore/ExploreAcademyCard.tsx @@ -82,7 +82,7 @@ export default function ExploreAcademyCard({ className="gentle-transition block active:scale-[0.98]" >
{media.kind === "video" ? (
- -
- - - - {item.likesCount ?? 0} - -
+ +
+ + + + {item.likesCount ?? 0} + +
); } diff --git a/src/components/explore/ExploreAuthor.tsx b/src/components/explore/ExploreAuthor.tsx index afadf52..4c55567 100644 --- a/src/components/explore/ExploreAuthor.tsx +++ b/src/components/explore/ExploreAuthor.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; import ProfileAvatar from "@/components/main/ProfileAvatar"; import useAxios from "@/hooks/useAxios"; @@ -32,8 +33,8 @@ export default function ExploreAuthor({ const avatarSrc = profileImage || data?.user?.profile_image; - return ( - + const content = ( + <> {userName ? `@${userName}` : displayName} -
+ + ); + + if (userName) { + return ( + e.stopPropagation()} + className="gentle-transition flex min-w-0 items-center gap-1.5 active:opacity-70" + > + {content} + + ); + } + + return ( + {content} ); } diff --git a/src/components/explore/ExploreGrid.tsx b/src/components/explore/ExploreGrid.tsx index 20299b5..dfdcbe7 100644 --- a/src/components/explore/ExploreGrid.tsx +++ b/src/components/explore/ExploreGrid.tsx @@ -124,7 +124,7 @@ function ExploreCard({ className="gentle-transition block active:scale-[0.98]" >
{media.kind === "video" ? (
- -
- - - - {post.likesCount ?? 0} - -
+ +
+ + + + {post.likesCount ?? 0} + +
); } @@ -334,7 +334,7 @@ export default function ExploreGrid({ return (
-
+
{items.map((item) => item.kind === "post" ? ( = - el.scrollHeight - window.innerHeight * 0.5 && + index >= items.length - PREFETCH_AHEAD && hasNextPage && !isFetchingNextPage ) { @@ -233,6 +236,23 @@ export default function ExploreReelsView({ return () => el.removeEventListener("scroll", handleScroll); }, [handleScroll]); + // بافر پس‌زمینه: با نزدیک‌شدن به انتهای آیتم‌های لودشده، صفحهٔ بعد از قبل گرفته می‌شود + const activeIndex = useMemo( + () => items.findIndex((i) => i.key === activeKey), + [items, activeKey] + ); + + useEffect(() => { + if (activeIndex < 0) return; + if ( + activeIndex >= items.length - PREFETCH_AHEAD && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage(); + } + }, [activeIndex, items.length, hasNextPage, isFetchingNextPage, fetchNextPage]); + useEffect(() => { if (!token) return; const item = items.find((i) => i.key === activeKey); diff --git a/src/components/main/BaseUrl.ts b/src/components/main/BaseUrl.ts index be881d2..eaacad8 100644 --- a/src/components/main/BaseUrl.ts +++ b/src/components/main/BaseUrl.ts @@ -40,7 +40,7 @@ export const IMAGE_BASE_URL = getStorageBaseUrl(); /** نسخه آیکون‌ها — با هر تغییر آیکون این مقدار را افزایش دهید */ export const ICON_VERSION = - process.env.NEXT_PUBLIC_ICON_VERSION ?? "2"; + process.env.NEXT_PUBLIC_ICON_VERSION ?? "5"; /** URL آیکون استاتیک با cache-busting */ export function staticIconUrl(path: string): string { diff --git a/src/components/main/Header.tsx b/src/components/main/Header.tsx index 97e9d12..390a6b2 100644 --- a/src/components/main/Header.tsx +++ b/src/components/main/Header.tsx @@ -16,7 +16,7 @@ function Header() { const { state, startSync, endSync, isOffline, isSyncing } = useNetworkStatus(); const [unreadMessages, setUnreadMessages] = useState(); const [unreadNotification, setUnreadNotification] = useState(); - const compact = useScrollDirection(20); + const { compact, hidden } = useScrollDirection(20); const token = Cookies.get("token"); const refreshBadges = async () => { @@ -120,8 +120,11 @@ function Header() { return (